diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d2069a17f..3b6cf93ee 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -233,7 +233,7 @@ import { findShapeByKey, getBoundTextShape, getCornerRadius, - getElementShape, + getElementShapes, isPathALoop, } from "../shapes"; import { getSelectionBoxShape } from "../../utils/geometry/shape"; @@ -5009,7 +5009,7 @@ class App extends React.Component { x, y, element: elementWithHighestZIndex, - shape: getElementShape( + shapes: getElementShapes( elementWithHighestZIndex, this.scene.getNonDeletedElementsMap(), ), @@ -5121,7 +5121,7 @@ class App extends React.Component { x, y, element, - shape: getElementShape(element, this.scene.getNonDeletedElementsMap()), + shapes: getElementShapes(element, this.scene.getNonDeletedElementsMap()), threshold: this.getElementHitThreshold(), frameNameBound: isFrameLikeElement(element) ? this.frameNameBoundsCache.get(element) @@ -5153,7 +5153,7 @@ class App extends React.Component { x, y, element: elements[index], - shape: getElementShape( + shapes: getElementShapes( elements[index], this.scene.getNonDeletedElementsMap(), ), @@ -5437,7 +5437,7 @@ class App extends React.Component { x: sceneX, y: sceneY, element: container, - shape: getElementShape( + shapes: getElementShapes( container, this.scene.getNonDeletedElementsMap(), ), @@ -6211,7 +6211,7 @@ class App extends React.Component { x: scenePointerX, y: scenePointerY, element, - shape: getElementShape( + shapes: getElementShapes( element, this.scene.getNonDeletedElementsMap(), ), @@ -9344,7 +9344,7 @@ class App extends React.Component { x: pointerDownState.origin.x, y: pointerDownState.origin.y, element: hitElement, - shape: getElementShape( + shapes: getElementShapes( hitElement, this.scene.getNonDeletedElementsMap(), ), diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 9c1b2447c..b76e856be 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -52,7 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; +import { aabbForElement, getElementShapes, pointInsideBounds } from "../shapes"; import { compareHeading, HEADING_DOWN, @@ -1406,9 +1406,9 @@ export const bindingBorderTest = ( ): boolean => { const threshold = maxBindingGap(element, element.width, element.height, zoom); - const shape = getElementShape(element, elementsMap); + const shapes = getElementShapes(element, elementsMap); return ( - isPointOnShape(pointFrom(x, y), shape, threshold) || + shapes.some((shape) => isPointOnShape(pointFrom(x, y), shape, threshold)) || (fullShape === true && pointInsideBounds(pointFrom(x, y), aabbForElement(element))) ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index a1593d2f6..c2750faea 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -45,7 +45,7 @@ export type HitTestArgs = { x: number; y: number; element: ExcalidrawElement; - shape: GeometricShape; + shapes: GeometricShape[]; threshold?: number; frameNameBound?: FrameNameBounds | null; }; @@ -54,16 +54,18 @@ export const hitElementItself = ({ x, y, element, - shape, + shapes, threshold = 10, frameNameBound = null, }: HitTestArgs) => { - let hit = shouldTestInside(element) - ? // Since `inShape` tests STRICTLY againt the insides of a shape - // we would need `onShape` as well to include the "borders" - isPointInShape(pointFrom(x, y), shape) || - isPointOnShape(pointFrom(x, y), shape, threshold) - : isPointOnShape(pointFrom(x, y), shape, threshold); + const testInside = shouldTestInside(element); + + let hit = shapes.some((shape) => + testInside || shape.isClosed + ? isPointInShape(pointFrom(x, y), shape) || + isPointOnShape(pointFrom(x, y), shape, threshold) + : isPointOnShape(pointFrom(x, y), shape, threshold), + ); // hit test against a frame's name if (!hit && frameNameBound) { diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index 3f1855c63..601d6f59c 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -7,6 +7,8 @@ import { pointsEqual, type GlobalPoint, type LocalPoint, + polygonFromPoints, + pointAdd, } from "../math"; import { getClosedCurveShape, @@ -141,10 +143,10 @@ export const findShapeByKey = (key: string) => { * get the pure geometric shape of an excalidraw element * which is then used for hit detection */ -export const getElementShape = ( +export const getElementShapes = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape => { +): GeometricShape[] => { switch (element.type) { case "rectangle": case "diamond": @@ -155,40 +157,96 @@ export const getElementShape = ( case "iframe": case "text": case "selection": - return getPolygonShape(element); + return [getPolygonShape(element)]; case "arrow": case "line": { - const roughShape = - ShapeCache.get(element)?.[0] ?? - ShapeCache.generateElementShape(element, null)[0]; + const [curve, ...arrowheads] = + ShapeCache.get(element) ?? + ShapeCache.generateElementShape(element, null); const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + const center = pointFrom(cx, cy); + const startingPoint = pointFrom(element.x, element.y); - return shouldTestInside(element) - ? getClosedCurveShape( + if (shouldTestInside(element)) { + return [ + getClosedCurveShape( element, - roughShape, - pointFrom(element.x, element.y), + curve, + startingPoint, element.angle, - pointFrom(cx, cy), - ) - : getCurveShape( - roughShape, - pointFrom(element.x, element.y), - element.angle, - pointFrom(cx, cy), + center, + ), + ]; + } + + // otherwise return the curve shape (and also the shape of its arrowheads) + const arrowheadShapes: GeometricShape[] = []; + + for (const arrowhead of arrowheads) { + if (arrowhead.shape === "polygon") { + const ops = arrowhead.sets[0].ops; + + const otherPoints = ops.slice(1); + const arrowheadShape: GeometricShape = { + type: "polygon", + data: polygonFromPoints( + otherPoints.map((otherPoint) => + pointAdd( + pointFrom(otherPoint.data[0], otherPoint.data[1]), + pointFrom(element.x, element.y), + ), + ), + ), + isClosed: true, + }; + + arrowheadShapes.push(arrowheadShape); + } + + if (arrowhead.shape === "circle") { + // TODO: close curve into polygon / ellipse + arrowheadShapes.push({ + ...getCurveShape( + arrowhead, + element.angle, + center, + startingPoint, + ), + isClosed: true, + }); + } + + if (arrowhead.shape === "line") { + arrowheadShapes.push( + getCurveShape( + arrowhead, + element.angle, + center, + startingPoint, + ), ); + } + } + + return [ + getCurveShape( + curve, + element.angle, + pointFrom(cx, cy), + startingPoint, + ), + ...arrowheadShapes, + ]; } case "ellipse": - return getEllipseShape(element); + return [getEllipseShape(element)]; case "freedraw": { const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - return getFreedrawShape( - element, - pointFrom(cx, cy), - shouldTestInside(element), - ); + return [ + getFreedrawShape(element, pointFrom(cx, cy), shouldTestInside(element)), + ]; } } }; @@ -201,21 +259,23 @@ export const getBoundTextShape = ( if (boundTextElement) { if (element.type === "arrow") { - return getElementShape( - { - ...boundTextElement, - // arrow's bound text accurate position is not stored in the element's property - // but rather calculated and returned from the following static method - ...LinearElementEditor.getBoundTextElementPosition( - element, - boundTextElement, - elementsMap, - ), - }, - elementsMap, + return ( + getElementShapes( + { + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ), + }, + elementsMap, + )[0] ?? null ); } - return getElementShape(boundTextElement, elementsMap); + return getElementShapes(boundTextElement, elementsMap)[0] ?? null; } return null; diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 4670b23ab..3a6749523 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -73,7 +73,7 @@ export type Ellipse = { halfHeight: number; }; -export type GeometricShape = +export type GeometricShape = ( | { type: "line"; data: LineSegment; @@ -97,7 +97,10 @@ export type GeometricShape = | { type: "polycurve"; data: Polycurve; - }; + } +) & { + isClosed?: boolean; +}; type RectangularElement = | ExcalidrawRectangleElement @@ -203,9 +206,9 @@ export const getCurvePathOps = (shape: Drawable): Op[] => { // linear export const getCurveShape = ( roughShape: Drawable, - startingPoint: Point = pointFrom(0, 0), angleInRadian: Radians, center: Point, + startingPoint: Point = pointFrom(0, 0), ): GeometricShape => { const transform = (p: Point): Point => pointRotateRads(