diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 2ea05510b..e6cae2528 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,8 +27,6 @@ import { PRECISION, } from "@excalidraw/math"; -import { isPointOnShape } from "@excalidraw/utils/collision"; - import type { LocalPoint, Radians } from "@excalidraw/math"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -41,7 +39,7 @@ import { doBoundsIntersect, } from "./bounds"; import { intersectElementWithLineSegment } from "./collision"; -import { distanceToBindableElement } from "./distance"; +import { distanceToElement } from "./distance"; import { headingForPointFromElement, headingIsHorizontal, @@ -63,7 +61,7 @@ import { isTextElement, } from "./typeChecks"; -import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; +import { aabbForElement } from "./shapes"; import { updateElbowArrowPoints } from "./elbowArrow"; import type { Scene } from "./Scene"; @@ -704,7 +702,7 @@ const calculateFocusAndGap = ( return { focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), - gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), + gap: Math.max(1, distanceToElement(hoveredElement, edgePoint)), }; }; @@ -898,7 +896,7 @@ const getDistanceForBinding = ( bindableElement: ExcalidrawBindableElement, zoom?: AppState["zoom"], ) => { - const distance = distanceToBindableElement(bindableElement, point); + const distance = distanceToElement(bindableElement, point); const bindDistance = maxBindingGap( bindableElement, bindableElement.width, @@ -1548,14 +1546,22 @@ export const bindingBorderTest = ( zoom?: AppState["zoom"], fullShape?: boolean, ): boolean => { + const p = pointFrom(x, y); const threshold = maxBindingGap(element, element.width, element.height, zoom); - - const shape = getElementShape(element, elementsMap); - return ( - isPointOnShape(pointFrom(x, y), shape, threshold) || - (fullShape === true && - pointInsideBounds(pointFrom(x, y), aabbForElement(element))) + const shouldTestInside = + // disable fullshape snapping for frame elements so we + // can bind to frame children + (fullShape || !isBindingFallthroughEnabled(element)) && + !isFrameLikeElement(element); + const intersections = intersectElementWithLineSegment( + element, + lineSegment(elementCenterPoint(element), p), ); + const distance = distanceToElement(element, p); + + return shouldTestInside + ? intersections.length === 0 || distance <= threshold + : intersections.length > 0 && distance <= threshold; }; export const maxBindingGap = ( diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 07b17bfde..a48a4c889 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -16,24 +16,18 @@ import { } from "@excalidraw/math/ellipse"; import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; -import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape"; -import type { - GlobalPoint, - LineSegment, - LocalPoint, - Polygon, - Radians, -} from "@excalidraw/math"; +import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; -import { getBoundTextShape, isPathALoop } from "./shapes"; +import { isPathALoop } from "./shapes"; import { getElementBounds } from "./bounds"; import { hasBoundTextElement, isIframeLikeElement, isImageElement, + isLinearElement, isTextElement, } from "./typeChecks"; import { @@ -41,12 +35,15 @@ import { deconstructRectanguloidElement, } from "./utils"; +import { getBoundTextElement } from "./textElement"; + +import { LinearElementEditor } from "./linearElementEditor"; + import type { ElementsMap, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, - ExcalidrawRectangleElement, ExcalidrawRectanguloidElement, } from "./types"; @@ -72,45 +69,40 @@ export const shouldTestInside = (element: ExcalidrawElement) => { return isDraggableFromInside || isImageElement(element); }; -export type HitTestArgs = { - x: number; - y: number; +export type HitTestArgs = { + point: GlobalPoint; element: ExcalidrawElement; - shape: GeometricShape; threshold?: number; frameNameBound?: FrameNameBounds | null; }; -export const hitElementItself = ({ - x, - y, +export const hitElementItself = ({ + point, element, - shape, threshold = 10, frameNameBound = null, -}: HitTestArgs) => { +}: 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); + isPointInShape(point, element) || + isPointOnShape(point, element, threshold) + : isPointOnShape(point, element, threshold); // hit test against a frame's name if (!hit && frameNameBound) { - hit = isPointInShape(pointFrom(x, y), { - type: "polygon", - data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) - .data as Polygon, - }); + const x1 = frameNameBound.x - threshold; + const y1 = frameNameBound.y - threshold; + const x2 = frameNameBound.x + frameNameBound.width + threshold; + const y2 = frameNameBound.y + frameNameBound.height + threshold; + hit = isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); } return hit; }; export const hitElementBoundingBox = ( - x: number, - y: number, + point: GlobalPoint, element: ExcalidrawElement, elementsMap: ElementsMap, tolerance = 0, @@ -120,37 +112,45 @@ export const hitElementBoundingBox = ( y1 -= tolerance; x2 += tolerance; y2 += tolerance; - return isPointWithinBounds( - pointFrom(x1, y1), - pointFrom(x, y), - pointFrom(x2, y2), - ); + return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2)); }; -export const hitElementBoundingBoxOnly = < - Point extends GlobalPoint | LocalPoint, ->( - hitArgs: HitTestArgs, +export const hitElementBoundingBoxOnly = ( + hitArgs: HitTestArgs, elementsMap: ElementsMap, ) => { return ( !hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box) - !hitElementBoundText( - hitArgs.x, - hitArgs.y, - getBoundTextShape(hitArgs.element, elementsMap), - ) && - hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) + !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) && + hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap) ); }; -export const hitElementBoundText = ( - x: number, - y: number, - textShape: GeometricShape | null, +export const hitElementBoundText = ( + point: GlobalPoint, + element: ExcalidrawElement, + elementsMap: ElementsMap, ): boolean => { - return !!textShape && isPointInShape(pointFrom(x, y), textShape); + const boundTextElementCandidate = getBoundTextElement(element, elementsMap); + + if (!boundTextElementCandidate) { + return false; + } + const boundTextElement = isLinearElement(element) + ? { + ...boundTextElementCandidate, + // 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, + boundTextElementCandidate, + elementsMap, + ), + } + : boundTextElementCandidate; + + return isPointInShape(point, boundTextElement); }; /** diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index d261faf7d..88ce640e1 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -1,6 +1,8 @@ import { curvePointDistance, distanceToLineSegment, + lineSegment, + pointFrom, pointRotateRads, } from "@excalidraw/math"; @@ -16,17 +18,18 @@ import { } from "./utils"; import type { - ExcalidrawBindableElement, ExcalidrawDiamondElement, + ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawRectanguloidElement, } from "./types"; -export const distanceToBindableElement = ( - element: ExcalidrawBindableElement, +export const distanceToElement = ( + element: ExcalidrawElement, p: GlobalPoint, ): number => { switch (element.type) { + case "selection": case "rectangle": case "image": case "text": @@ -39,6 +42,23 @@ export const distanceToBindableElement = ( return distanceToDiamondElement(element, p); case "ellipse": return distanceToEllipseElement(element, p); + case "line": + case "arrow": + case "freedraw": + return element.points.reduce((acc, point, idx) => { + if (idx === 0) { + return acc; + } + const prevPoint = element.points[idx - 1]; + const segment = lineSegment( + pointFrom( + element.x + prevPoint[0], + element.y + prevPoint[1], + ), + pointFrom(element.x + point[0], element.y + point[1]), + ); + return Math.min(acc, distanceToLineSegment(p, segment)); + }, Infinity); } }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 95a2aa8ef..990f4e570 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -32,7 +32,7 @@ import { snapToMid, getHoveredElementForBinding, } from "./binding"; -import { distanceToBindableElement } from "./distance"; +import { distanceToElement } from "./distance"; import { compareHeading, flipHeading, @@ -2234,8 +2234,7 @@ const getGlobalPoint = ( // NOTE: Resize scales the binding position point too, so we need to update it return Math.abs( - distanceToBindableElement(element, fixedGlobalPoint) - - FIXED_BINDING_DISTANCE, + distanceToElement(element, fixedGlobalPoint) - FIXED_BINDING_DISTANCE, ) > 0.01 ? bindPointToSnapToElementOutline(arrow, element, startOrEnd) : fixedGlobalPoint; @@ -2257,7 +2256,7 @@ const getBindPointHeading = ( hoveredElement && aabbForElement( hoveredElement, - Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ + Array(4).fill(distanceToElement(hoveredElement, p)) as [ number, number, number, diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index d7a105900..6e0dfb0ff 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement = | ExcalidrawFreeDrawElement | ExcalidrawIframeLikeElement | ExcalidrawFrameLikeElement - | ExcalidrawEmbeddableElement; + | ExcalidrawEmbeddableElement + | ExcalidrawSelectionElement; /** * ExcalidrawElement should be JSON serializable and (eventually) contain diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b0c43359b..b8fc4cb65 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -18,7 +18,6 @@ import { vectorNormalize, } from "@excalidraw/math"; import { isPointInShape } from "@excalidraw/utils/collision"; -import { getSelectionBoxShape } from "@excalidraw/utils/shape"; import { COLOR_PALETTE, @@ -170,12 +169,7 @@ import { isInvisiblySmallElement, } from "@excalidraw/element"; -import { - getBoundTextShape, - getCornerRadius, - getElementShape, - isPathALoop, -} from "@excalidraw/element"; +import { getCornerRadius, isPathALoop } from "@excalidraw/element"; import { createSrcDoc, @@ -5141,13 +5135,8 @@ class App extends React.Component { // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. return hitElementItself({ - x, - y, + point: pointFrom(x, y), element: elementWithHighestZIndex, - shape: getElementShape( - elementWithHighestZIndex, - this.scene.getNonDeletedElementsMap(), - ), // when overlapping, we would like to be more precise // this also avoids the need to update past tests threshold: this.getElementHitThreshold() / 2, @@ -5229,34 +5218,26 @@ class App extends React.Component { this.state.selectedElementIds[element.id] && shouldShowBoundingBox([element], this.state) ) { - const selectionShape = getSelectionBoxShape( - element, - this.scene.getNonDeletedElementsMap(), - isImageElement(element) ? 0 : this.getElementHitThreshold(), - ); - // if hitting the bounding box, return early // but if not, we should check for other cases as well (e.g. frame name) - if (isPointInShape(pointFrom(x, y), selectionShape)) { + if (isPointInShape(pointFrom(x, y), element)) { return true; } } // take bound text element into consideration for hit collision as well const hitBoundTextOfElement = hitElementBoundText( - x, - y, - getBoundTextShape(element, this.scene.getNonDeletedElementsMap()), + pointFrom(x, y), + element, + this.scene.getNonDeletedElementsMap(), ); if (hitBoundTextOfElement) { return true; } return hitElementItself({ - x, - y, + point: pointFrom(x, y), element, - shape: getElementShape(element, this.scene.getNonDeletedElementsMap()), threshold: this.getElementHitThreshold(), frameNameBound: isFrameLikeElement(element) ? this.frameNameBoundsCache.get(element) @@ -5285,13 +5266,8 @@ class App extends React.Component { if ( isArrowElement(elements[index]) && hitElementItself({ - x, - y, + point: pointFrom(x, y), element: elements[index], - shape: getElementShape( - elements[index], - this.scene.getNonDeletedElementsMap(), - ), threshold: this.getElementHitThreshold(), }) ) { @@ -5637,13 +5613,8 @@ class App extends React.Component { hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || hitElementItself({ - x: sceneX, - y: sceneY, + point: pointFrom(sceneX, sceneY), element: container, - shape: getElementShape( - container, - this.scene.getNonDeletedElementsMap(), - ), threshold: this.getElementHitThreshold(), }) ) { @@ -6316,13 +6287,8 @@ class App extends React.Component { let segmentMidPointHoveredCoords = null; if ( hitElementItself({ - x: scenePointerX, - y: scenePointerY, + point: pointFrom(scenePointerX, scenePointerY), element, - shape: getElementShape( - element, - this.scene.getNonDeletedElementsMap(), - ), }) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( @@ -9696,13 +9662,11 @@ class App extends React.Component { ((hitElement && hitElementBoundingBoxOnly( { - x: pointerDownState.origin.x, - y: pointerDownState.origin.y, - element: hitElement, - shape: getElementShape( - hitElement, - this.scene.getNonDeletedElementsMap(), + point: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, ), + element: hitElement, threshold: this.getElementHitThreshold(), frameNameBound: isFrameLikeElement(hitElement) ? this.frameNameBoundsCache.get(hitElement) diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index 292659bdb..5e380e4e6 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -463,7 +463,7 @@ const shouldHideLinkPopup = ( const threshold = 15 / appState.zoom.value; // hitbox to prevent hiding when hovered in element bounding box - if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { + if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) { return false; } const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index 7d39b7ff7..e89ecdc55 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -92,7 +92,7 @@ export const isPointHittingLink = ( if ( !isMobile && appState.viewModeEnabled && - hitElementBoundingBox(x, y, element, elementsMap) + hitElementBoundingBox(pointFrom(x, y), element, elementsMap) ) { return true; } diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index 5e6c4e517..7b822f0a9 100644 --- a/packages/excalidraw/eraser/index.ts +++ b/packages/excalidraw/eraser/index.ts @@ -8,7 +8,6 @@ import { import { getElementsInGroup } from "@excalidraw/element"; -import { getElementShape } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element"; import { isPointInShape } from "@excalidraw/utils/collision"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; @@ -208,15 +207,8 @@ const eraserTest = ( elementsMap: ElementsMap, app: App, ): boolean => { - let shape = shapesCache.get(element.id); - - if (!shape) { - shape = getElementShape(element, elementsMap); - shapesCache.set(element.id, shape); - } - const lastPoint = pathSegments[pathSegments.length - 1][1]; - if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) { + if (shouldTestInside(element) && isPointInShape(lastPoint, element)) { return true; } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 991f8c3f8..0867318c8 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -187,16 +187,10 @@ const renderBindingHighlightForBindableElement = ( elementsMap: ElementsMap, zoom: InteractiveCanvasAppState["zoom"], ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - - context.strokeStyle = "rgba(0,0,0,.05)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - // To ensure the binding highlight doesn't overlap the element itself const padding = maxBindingGap(element, element.width, element.height, zoom); + context.fillStyle = "rgba(0,0,0,.05)"; + switch (element.type) { case "rectangle": case "text": @@ -211,9 +205,12 @@ const renderBindingHighlightForBindableElement = ( drawHighlightForDiamondWithRotation(context, padding, element); break; case "ellipse": - context.lineWidth = - maxBindingGap(element, element.width, element.height, zoom) - - FIXED_BINDING_DISTANCE; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgba(0,0,0,.05)"; + context.lineWidth = padding - FIXED_BINDING_DISTANCE; strokeEllipseWithRotation( context, diff --git a/packages/utils/src/collision.ts b/packages/utils/src/collision.ts index b7c155f66..0ffd1b1ec 100644 --- a/packages/utils/src/collision.ts +++ b/packages/utils/src/collision.ts @@ -3,69 +3,45 @@ import { pointFrom, polygonIncludesPoint, pointOnLineSegment, - pointOnPolygon, - polygonFromPoints, type GlobalPoint, type LocalPoint, type Polygon, } from "@excalidraw/math"; +import { intersectElementWithLineSegment } from "@excalidraw/element/collision"; + +import { elementCenterPoint } from "@excalidraw/common"; + +import { distanceToElement } from "@excalidraw/element/distance"; + +import type { ExcalidrawElement } from "@excalidraw/element/types"; + import type { Curve } from "@excalidraw/math"; -import { pointInEllipse, pointOnEllipse } from "./shape"; - -import type { Polycurve, Polyline, GeometricShape } from "./shape"; +import type { Polyline } from "./shape"; // check if the given point is considered on the given shape's border -export const isPointOnShape = ( - point: Point, - shape: GeometricShape, - tolerance = 0, +export const isPointOnShape = ( + point: GlobalPoint, + element: ExcalidrawElement, + tolerance = 1, ) => { - // get the distance from the given point to the given element - // check if the distance is within the given epsilon range - switch (shape.type) { - case "polygon": - return pointOnPolygon(point, shape.data, tolerance); - case "ellipse": - return pointOnEllipse(point, shape.data, tolerance); - case "line": - return pointOnLineSegment(point, shape.data, tolerance); - case "polyline": - return pointOnPolyline(point, shape.data, tolerance); - case "curve": - return pointOnCurve(point, shape.data, tolerance); - case "polycurve": - return pointOnPolycurve(point, shape.data, tolerance); - default: - throw Error(`shape ${shape} is not implemented`); - } + const distance = distanceToElement(element, point); + + return distance <= tolerance; }; // check if the given point is considered inside the element's border -export const isPointInShape = ( - point: Point, - shape: GeometricShape, +export const isPointInShape = ( + point: GlobalPoint, + element: ExcalidrawElement, ) => { - switch (shape.type) { - case "polygon": - return polygonIncludesPoint(point, shape.data); - case "line": - return false; - case "curve": - return false; - case "ellipse": - return pointInEllipse(point, shape.data); - case "polyline": { - const polygon = polygonFromPoints(shape.data.flat()); - return polygonIncludesPoint(point, polygon); - } - case "polycurve": { - return false; - } - default: - throw Error(`shape ${shape} is not implemented`); - } + const intersections = intersectElementWithLineSegment( + element, + lineSegment(elementCenterPoint(element), point), + ); + + return intersections.length === 0; }; // check if the given element is in the given bounds @@ -76,14 +52,6 @@ export const isPointInBounds = ( return polygonIncludesPoint(point, bounds); }; -const pointOnPolycurve = ( - point: Point, - polycurve: Polycurve, - tolerance: number, -) => { - return polycurve.some((curve) => pointOnCurve(point, curve, tolerance)); -}; - const cubicBezierEquation = ( curve: Curve, ) => {