Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve empty text element hit interaction #1002

Merged
merged 6 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"mode": "pre",
"tag": "next",
"initialVersions": {
"@plait/angular-board": "0.74.0",
"@plait/angular-text": "0.74.0",
"@plait/common": "0.74.0",
"@plait/core": "0.74.0",
"@plait/draw": "0.74.0",
"@plait/flow": "0.74.0",
"@plait/graph-viz": "0.74.0",
"@plait/layouts": "0.74.0",
"@plait/mind": "0.74.0",
"@plait/text-plugins": "0.74.0"
},
"changesets": []
}
5 changes: 5 additions & 0 deletions .changeset/silver-kings-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plait/draw': minor
---

isHitDrawElement support isStrict mode to match dblClick editing scene(isStrict is false)
5 changes: 5 additions & 0 deletions .changeset/thick-apricots-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plait/core': minor
---

override isHit method support isStrict param
2 changes: 1 addition & 1 deletion packages/core/src/interfaces/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface PlaitBoard {
drawElement: (context: PlaitPluginElementContext) => ComponentType<ElementFlavour>;
isRectangleHit: (element: PlaitElement, range: Selection) => boolean;
// When the element has no fill color, it is considered a hit only if it hits the border.
isHit: (element: PlaitElement, point: Point) => boolean;
isHit: (element: PlaitElement, point: Point, isStrict?: boolean) => boolean;
isInsidePoint: (element: PlaitElement, point: Point) => boolean;
// the hit element is determined by the plugin
getOneHitElement: (hitElements: PlaitElement[]) => PlaitElement;
Expand Down
41 changes: 30 additions & 11 deletions packages/core/src/utils/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function getNearestPointBetweenPointAndEllipse(point: Point, center: Poin
const a = Math.abs(rectangleClient.width) / 2;
const b = Math.abs(rectangleClient.height) / 2;

[0, 1, 2, 3].forEach(x => {
[0, 1, 2, 3].forEach((x) => {
const xx = a * tx;
const yy = b * ty;

Expand Down Expand Up @@ -179,26 +179,45 @@ export const isLineHitLine = (a: Point, b: Point, c: Point, d: Point): boolean =
return crossProduct(ab, ac) * crossProduct(ab, ad) <= 0 && crossProduct(cd, ca) * crossProduct(cd, cb) <= 0;
};

export const isPolylineHitRectangle = (points: Point[], rectangle: RectangleClient, isClose: boolean = true) => {
export const isLineHitRectangle = (points: Point[], rectangle: RectangleClient, isClose: boolean = true) => {
const rectanglePoints = RectangleClient.getCornerPoints(rectangle);
const len = points.length;
for (let i = 0; i < len; i++) {
if (i === len - 1 && !isClose) continue;
const p1 = points[i];
const p2 = points[(i + 1) % len];
const isHit =
isLineHitLine(p1, p2, rectanglePoints[0], rectanglePoints[1]) ||
isLineHitLine(p1, p2, rectanglePoints[1], rectanglePoints[2]) ||
isLineHitLine(p1, p2, rectanglePoints[2], rectanglePoints[3]) ||
isLineHitLine(p1, p2, rectanglePoints[3], rectanglePoints[0]);
const isHit = isSingleLineHitRectangleEdge(p1, p2, rectangle);
if (isHit || isPointInPolygon(p1, rectanglePoints) || isPointInPolygon(p2, rectanglePoints)) {
return true;
}
}
return false;
};

export const isLineHitRectangleEdge = (points: Point[], rectangle: RectangleClient, isClose: boolean = true) => {
const len = points.length;
for (let i = 0; i < len; i++) {
if (i === len - 1 && !isClose) continue;
const p1 = points[i];
const p2 = points[(i + 1) % len];
const isHit = isSingleLineHitRectangleEdge(p1, p2, rectangle);
if (isHit) {
return true;
}
}
return false;
};

export const isSingleLineHitRectangleEdge = (p1: Point, p2: Point, rectangle: RectangleClient) => {
const rectanglePoints = RectangleClient.getCornerPoints(rectangle);
return (
isLineHitLine(p1, p2, rectanglePoints[0], rectanglePoints[1]) ||
isLineHitLine(p1, p2, rectanglePoints[1], rectanglePoints[2]) ||
isLineHitLine(p1, p2, rectanglePoints[2], rectanglePoints[3]) ||
isLineHitLine(p1, p2, rectanglePoints[3], rectanglePoints[0])
);
};

//https://stackoverflow.com/questions/22521982/check-if-point-is-inside-a-polygon
export const isPointInPolygon = (point: Point, points: Point[]) => {
// ray-casting algorithm based on
Expand Down Expand Up @@ -262,7 +281,7 @@ export const isPointInRoundRectangle = (point: Point, rectangle: RectangleClient
};

// https://gist.github.com/nicholaswmin/c2661eb11cad5671d816
export const catmullRomFitting = function(points: Point[]) {
export const catmullRomFitting = function (points: Point[]) {
const alpha = 0.5;
let p0, p1, p2, p3, bp1, bp2, d1, d2, d3, A, B, N, M;
var d3powA, d2powA, d3pow2A, d2pow2A, d1pow2A, d1powA;
Expand Down Expand Up @@ -423,9 +442,9 @@ export function getCrossingPointsBetweenEllipseAndSegment(
return (
tValues
// Filter to only points that are on the segment.
.filter(t => !segment_only || (t >= 0 && t <= 1))
.filter((t) => !segment_only || (t >= 0 && t <= 1))
// Solve for points.
.map(t => [startPoint[0] + (endPoint[0] - startPoint[0]) * t + cx, startPoint[1] + (endPoint[1] - startPoint[1]) * t + cy])
.map((t) => [startPoint[0] + (endPoint[0] - startPoint[0]) * t + cx, startPoint[1] + (endPoint[1] - startPoint[1]) * t + cy])
);
}

Expand All @@ -439,4 +458,4 @@ export function getCrossingPointsBetweenEllipseAndSegment(
*/
export function getPointBetween(x0: number, y0: number, x1: number, y1: number, d = 0.5) {
return [x0 + (x1 - x0) * d, y0 + (y1 - y0) * d];
}
}
22 changes: 12 additions & 10 deletions packages/core/src/utils/selected-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const getHitElementsBySelection = (
}
depthFirstRecursion<Ancestor>(
board,
node => {
(node) => {
if (!PlaitBoard.isBoard(node) && match(node)) {
let isRectangleHit = false;
try {
Expand All @@ -57,18 +57,19 @@ export const getHitElementsBySelection = (
export const getHitElementsByPoint = (
board: PlaitBoard,
point: Point,
match: (element: PlaitElement) => boolean = () => true
match: (element: PlaitElement) => boolean = () => true,
isStrict = true
): PlaitElement[] => {
let hitElements: PlaitElement[] = [];
depthFirstRecursion<Ancestor>(
board,
node => {
(node) => {
if (PlaitBoard.isBoard(node) || !match(node) || !PlaitElement.hasMounted(node)) {
return;
}
let isHit = false;
try {
isHit = board.isHit(node, point);
isHit = board.isHit(node, point, isStrict);
} catch (error) {
if (isDebug()) {
console.error('isHit', error, 'node', node);
Expand All @@ -88,9 +89,10 @@ export const getHitElementsByPoint = (
export const getHitElementByPoint = (
board: PlaitBoard,
point: Point,
match: (element: PlaitElement) => boolean = () => true
match: (element: PlaitElement) => boolean = () => true,
isStrict = true
): undefined | PlaitElement => {
const pointHitElements = getHitElementsByPoint(board, point, match);
const pointHitElements = getHitElementsByPoint(board, point, match, isStrict);
const hitElement = board.getOneHitElement(pointHitElements);
return hitElement;
};
Expand Down Expand Up @@ -133,15 +135,15 @@ export const removeSelectedElement = (board: PlaitBoard, element: PlaitElement,
if (board.isRecursion(element) && isRemoveChildren) {
depthFirstRecursion(
element,
node => {
(node) => {
targetElements.push(node);
},
node => board.isRecursion(node)
(node) => board.isRecursion(node)
);
} else {
targetElements.push(element);
}
const newSelectedElements = selectedElements.filter(value => !targetElements.includes(value));
const newSelectedElements = selectedElements.filter((value) => !targetElements.includes(value));
cacheSelectedElements(board, newSelectedElements);
}
};
Expand All @@ -157,7 +159,7 @@ export const clearSelectedElement = (board: PlaitBoard) => {

export const isSelectedElement = (board: PlaitBoard, element: PlaitElement) => {
const selectedElements = getSelectedElements(board);
return !!selectedElements.find(value => value === element);
return !!selectedElements.find((value) => value === element);
};

export const temporaryDisableSelection = (board: PlaitOptionsBoard) => {
Expand Down
55 changes: 44 additions & 11 deletions packages/draw/src/engines/uml/provided-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,53 @@ import {
Point,
PointOfRectangle,
RectangleClient,
distanceBetweenPointAndPoint,
getEllipseTangentSlope,
getNearestPointBetweenPointAndEllipse,
getNearestPointBetweenPointAndSegments,
getVectorFromPointAndSlope,
setStrokeLinecap
} from '@plait/core';
import { ShapeEngine } from '../../interfaces';
import { Options } from 'roughjs/bin/core';
import { RectangleEngine } from '../basic-shapes/rectangle';
import { getUnitVectorByPointAndPoint } from '@plait/common';

const percentage = 0.54;

export const getStartPoint = (rectangle: RectangleClient): Point => {
return [rectangle.x, rectangle.y + rectangle.height / 2];
};

export const getEndPoint = (rectangle: RectangleClient): Point => {
return [rectangle.x + rectangle.width * percentage, rectangle.y + rectangle.height / 2];
};

export const arcPercentage = percentage + (1 - percentage) / 2;

export const getArcCenter = (rectangle: RectangleClient): Point => {
return [rectangle.x + arcPercentage * rectangle.width, rectangle.y + rectangle.height / 2];
};

export const ProvidedInterfaceEngine: ShapeEngine = {
draw(board: PlaitBoard, rectangle: RectangleClient, options: Options) {
const rs = PlaitBoard.getRoughSVG(board);
const startPoint = getStartPoint(rectangle);
const endPoint = getEndPoint(rectangle);
const shape = rs.path(
` M${rectangle.x} ${rectangle.y + rectangle.height / 2}
H${rectangle.x + rectangle.width * 0.54}
A${(rectangle.width * 0.46) / 2} ${rectangle.height / 2}, 0, 1, 1 ${rectangle.x + rectangle.width} ${rectangle.y +
rectangle.height / 2}
A${(rectangle.width * 0.46) / 2} ${rectangle.height / 2}, 0, 1, 1 ${rectangle.x + rectangle.width * 0.54} ${rectangle.y +
rectangle.height / 2}
`,
`M${startPoint[0]} ${startPoint[1]}
H${endPoint[0]}
A${(rectangle.width * (1 - percentage)) / 2} ${rectangle.height / 2}, 0, 1, 1 ${rectangle.x + rectangle.width} ${
rectangle.y + rectangle.height / 2
}
A${(rectangle.width * (1 - percentage)) / 2} ${rectangle.height / 2}, 0, 1, 1 ${rectangle.x + rectangle.width * percentage} ${
rectangle.y + rectangle.height / 2
}`,
{
...options,
fillStyle: 'solid'
}
);
setStrokeLinecap(shape, 'round');

return shape;
},
isInsidePoint(rectangle: RectangleClient, point: Point) {
Expand All @@ -44,8 +63,22 @@ export const ProvidedInterfaceEngine: ShapeEngine = {
return RectangleClient.getEdgeCenterPoints(rectangle);
},
getNearestPoint(rectangle: RectangleClient, point: Point) {
const nearestPoint = getNearestPointBetweenPointAndSegments(point, RectangleEngine.getCornerPoints(rectangle));
return nearestPoint;
const startPoint = getStartPoint(rectangle);
const endPoint = getEndPoint(rectangle);
const nearestPointForLine = getNearestPointBetweenPointAndSegments(point, [startPoint, endPoint]);
const distanceForLine = distanceBetweenPointAndPoint(...point, ...nearestPointForLine);
const arcCenter = getArcCenter(rectangle);
const nearestPointForEllipse = getNearestPointBetweenPointAndEllipse(
point,
arcCenter,
(rectangle.width * (1 - percentage)) / 2,
rectangle.height / 2
);
const distanceForEllipse = distanceBetweenPointAndPoint(...point, ...nearestPointForEllipse);
if (distanceForLine < distanceForEllipse) {
return nearestPointForLine;
}
return nearestPointForEllipse;
},
getTangentVectorByConnectionPoint(rectangle: RectangleClient, pointOfRectangle: PointOfRectangle) {
const connectionPoint = RectangleClient.getConnectionPoint(rectangle, pointOfRectangle);
Expand Down
8 changes: 4 additions & 4 deletions packages/draw/src/plugins/with-draw-hotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { PlaitBoard, getHitElementByPoint, getSelectedElements, toHostPoint, toV
import { isVirtualKey, isSpaceHotkey, isDelete } from '@plait/common';
import { GeometryCommonTextKeys, PlaitDrawElement } from '../interfaces';
import { editText } from '../utils/geometry';
import { getHitMultipleGeometryText, isMultipleTextGeometry } from '../utils';
import { getHitMultipleGeometryText, isDrawElementIncludeText, isMultipleTextGeometry } from '../utils';

export const withDrawHotkey = (board: PlaitBoard) => {
const { keyDown, dblClick } = board;
Expand Down Expand Up @@ -31,12 +31,12 @@ export const withDrawHotkey = (board: PlaitBoard) => {
event.preventDefault();
if (!PlaitBoard.isReadonly(board)) {
const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
const hitElement = getHitElementByPoint(board, point);
if (hitElement && PlaitDrawElement.isGeometry(hitElement)) {
const hitElement = getHitElementByPoint(board, point, undefined, false);
if (hitElement && PlaitDrawElement.isGeometry(hitElement) && isDrawElementIncludeText(hitElement)) {
if (isMultipleTextGeometry(hitElement)) {
const hitText =
getHitMultipleGeometryText(hitElement, point) ||
hitElement.texts.find(item => item.id.includes(GeometryCommonTextKeys.content)) ||
hitElement.texts.find((item) => item.id.includes(GeometryCommonTextKeys.content)) ||
hitElement.texts[0];
editText(board, hitElement, hitText);
} else {
Expand Down
6 changes: 3 additions & 3 deletions packages/draw/src/plugins/with-draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,12 @@ export const withDraw = (board: PlaitBoard) => {
return isRectangleHit(element, selection);
};

board.isHit = (element, point) => {
const result = isHitDrawElement(board, element, point);
board.isHit = (element, point, isStrict?: boolean) => {
const result = isHitDrawElement(board, element, point, isStrict);
if (result !== null) {
return result;
}
return isHit(element, point);
return isHit(element, point, isStrict);
};

board.getOneHitElement = elements => {
Expand Down
8 changes: 4 additions & 4 deletions packages/draw/src/plugins/with-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
PlaitElement,
RectangleClient,
Selection,
isPolylineHitRectangle,
isLineHitRectangle,
toViewBoxPoint,
toHostPoint,
getHitElementByPoint,
Expand All @@ -33,12 +33,12 @@ export const withTable = (board: PlaitBoard) => {
return drawElement(context);
};

tableBoard.isHit = (element, point) => {
tableBoard.isHit = (element, point, isStrict?: boolean) => {
if (PlaitDrawElement.isElementByTable(element)) {
const client = RectangleClient.getRectangleByPoints(element.points);
return RectangleClient.isPointInRectangle(client, point);
}
return isHit(element, point);
return isHit(element, point, isStrict);
};

tableBoard.getRectangle = (element: PlaitElement) => {
Expand All @@ -59,7 +59,7 @@ export const withTable = (board: PlaitBoard) => {
tableBoard.isRectangleHit = (element: PlaitElement, selection: Selection) => {
if (PlaitDrawElement.isElementByTable(element)) {
const rangeRectangle = RectangleClient.getRectangleByPoints([selection.anchor, selection.focus]);
return isPolylineHitRectangle(element.points, rangeRectangle);
return isLineHitRectangle(element.points, rangeRectangle);
}
return isRectangleHit(element, selection);
};
Expand Down
Loading
Loading