From 6baf9a93c5158840b28fd29cd193feda798facc5 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Apr 2025 15:45:13 +0200 Subject: [PATCH 01/12] Quarter snap points for diamonds --- packages/element/src/binding.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ee5d037a8..2236746c8 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1171,6 +1171,48 @@ export const snapToMid = ( center, angle, ); + } else if (element.type === "diamond") { + const sqrtFixedDistance = Math.sqrt(FIXED_BINDING_DISTANCE); + const topLeft = pointFrom( + x + width / 4 - sqrtFixedDistance, + y + height / 4 - sqrtFixedDistance, + ); + const topRight = pointFrom( + x + (3 * width) / 4 + sqrtFixedDistance, + y + height / 4 - sqrtFixedDistance, + ); + const bottomLeft = pointFrom( + x + width / 4 - sqrtFixedDistance, + y + (3 * height) / 4 + sqrtFixedDistance, + ); + const bottomRight = pointFrom( + x + (3 * width) / 4 + sqrtFixedDistance, + y + (3 * height) / 4 + sqrtFixedDistance, + ); + if ( + pointDistance(topLeft, p) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topLeft, center, angle); + } + if ( + pointDistance(topRight, p) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topRight, center, angle); + } + if ( + pointDistance(bottomLeft, p) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomLeft, center, angle); + } + if ( + pointDistance(bottomRight, p) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomRight, center, angle); + } } return p; From 92ca773f85148ed6d9554ad15ac2ce59432dc986 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 15 Apr 2025 15:08:29 +0200 Subject: [PATCH 02/12] Fix diamond intersection Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 2236746c8..cf7bde471 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -44,7 +44,6 @@ import { intersectElementWithLineSegment } from "./collision"; import { distanceToBindableElement } from "./distance"; import { headingForPointFromElement, - headingIsHorizontal, vectorToHeading, type Heading, } from "./heading"; @@ -947,23 +946,16 @@ export const bindPointToSnapToElementOutline = ( let intersection: GlobalPoint | null = null; if (elbowed) { - const isHorizontal = headingIsHorizontal( - headingForPointFromElement(bindableElement, aabb, globalP), - ); - const otherPoint = pointFrom( - isHorizontal ? center[0] : edgePoint[0], - !isHorizontal ? center[1] : edgePoint[1], - ); intersection = intersectElementWithLineSegment( bindableElement, lineSegment( - otherPoint, + center, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, otherPoint)), + vectorNormalize(vectorFromPoint(edgePoint, center)), Math.max(bindableElement.width, bindableElement.height) * 2, ), - otherPoint, + center, ), ), )[0]; @@ -1190,25 +1182,25 @@ export const snapToMid = ( y + (3 * height) / 4 + sqrtFixedDistance, ); if ( - pointDistance(topLeft, p) < + pointDistance(topLeft, nonRotated) < Math.max(horizontalThrehsold, verticalThrehsold) ) { return pointRotateRads(topLeft, center, angle); } if ( - pointDistance(topRight, p) < + pointDistance(topRight, nonRotated) < Math.max(horizontalThrehsold, verticalThrehsold) ) { return pointRotateRads(topRight, center, angle); } if ( - pointDistance(bottomLeft, p) < + pointDistance(bottomLeft, nonRotated) < Math.max(horizontalThrehsold, verticalThrehsold) ) { return pointRotateRads(bottomLeft, center, angle); } if ( - pointDistance(bottomRight, p) < + pointDistance(bottomRight, nonRotated) < Math.max(horizontalThrehsold, verticalThrehsold) ) { return pointRotateRads(bottomRight, center, angle); From fd808be30980cc149a47e8ae37f13a7443192fed Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 15 Apr 2025 15:51:52 +0200 Subject: [PATCH 03/12] Added a test case Signed-off-by: Mark Tolmacs --- packages/element/tests/elbowArrow.test.tsx | 89 +++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index 25f64072e..b5ff4663a 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -405,11 +405,98 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toEqual([ + expect(duplicatedArrow.points).toCloselyEqualPoints([ [0, 0], [0, 100], [90, 100], [90, 200], ]); }); + + it("elbow arrow snap at diamond quarter point too", async () => { + UI.createElement("diamond", { + x: -50, + y: -50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(43, 99); + mouse.click(); + mouse.moveTo(27, 25); + mouse.click(); + + let arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.endBinding).not.toBe(null); + expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( + 29.0355, + ); + expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( + 29.0355, + ); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(43, 99); + mouse.click(); + mouse.moveTo(-23, 25); + mouse.click(); + + arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; + + expect(arrow.endBinding).not.toBe(null); + expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( + -28.5559, + ); + expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( + 28.5559, + ); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(43, 99); + mouse.click(); + mouse.moveTo(-27, -25); + mouse.click(); + + arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; + + expect(arrow.endBinding).not.toBe(null); + expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( + -28.0355, + ); + expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( + -28.0355, + ); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(43, 99); + mouse.click(); + mouse.moveTo(23, -25); + mouse.click(); + + arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; + + expect(arrow.endBinding).not.toBe(null); + expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( + 28.5559, + ); + expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( + -28.5559, + ); + }); }); From 7af00ca9b750293b6fb257078d3c18d9dc06ed2d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 15 Apr 2025 16:02:47 +0200 Subject: [PATCH 04/12] Adapt tests Signed-off-by: Mark Tolmacs --- packages/element/tests/elbowArrow.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index b5ff4663a..01794345e 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -199,7 +199,7 @@ describe("elbow arrow routing", () => { points: [pointFrom(0, 0), pointFrom(90, 200)], }); - expect(arrow.points).toEqual([ + expect(arrow.points).toCloselyEqualPoints([ [0, 0], [45, 0], [45, 200], @@ -253,7 +253,7 @@ describe("elbow arrow ui", () => { expect(arrow.type).toBe("arrow"); expect(arrow.elbowed).toBe(true); - expect(arrow.points).toEqual([ + expect(arrow.points).toCloselyEqualPoints([ [0, 0], [45, 0], [45, 200], @@ -351,7 +351,7 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toEqual([ + expect(duplicatedArrow.points).toCloselyEqualPoints([ [0, 0], [45, 0], [45, 200], From b63cd86cd61f32c5df043a5b4bea9d3da9538c6c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 15 Apr 2025 17:15:53 +0200 Subject: [PATCH 05/12] Fix target point well inside shape issues Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index cf7bde471..927f55ae0 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,7 +27,9 @@ import { PRECISION, } from "@excalidraw/math"; -import { isPointOnShape } from "@excalidraw/utils/collision"; +import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; + +import { getEllipseShape, getPolygonShape } from "@excalidraw/utils/shape"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -909,8 +911,14 @@ const getDistanceForBinding = ( bindableElement.height, zoom, ); + const isInside = isPointInShape( + point, + bindableElement.type === "ellipse" + ? getEllipseShape(bindableElement) + : getPolygonShape(bindableElement), + ); - return distance > bindDistance ? null : distance; + return distance > bindDistance && !isInside ? null : distance; }; export const bindPointToSnapToElementOutline = ( From 64fa1c69485abee3111c38748f3cc87629710299 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Apr 2025 17:29:08 +0200 Subject: [PATCH 06/12] One set of actionFinalize --- packages/element/src/sizeHelpers.ts | 10 +++- .../excalidraw/actions/actionFinalize.tsx | 59 ++++++++----------- packages/excalidraw/components/App.tsx | 1 + 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/packages/element/src/sizeHelpers.ts b/packages/element/src/sizeHelpers.ts index bd3d3fb0c..02f6ea923 100644 --- a/packages/element/src/sizeHelpers.ts +++ b/packages/element/src/sizeHelpers.ts @@ -3,10 +3,12 @@ import { viewportCoordsToSceneCoords, } from "@excalidraw/common"; +import { pointsEqual } from "@excalidraw/math"; + import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import { getCommonBounds, getElementBounds } from "./bounds"; -import { isFreeDrawElement, isLinearElement } from "./typeChecks"; +import { isElbowArrow, isFreeDrawElement, isLinearElement } from "./typeChecks"; import type { ElementsMap, ExcalidrawElement } from "./types"; @@ -16,6 +18,12 @@ import type { ElementsMap, ExcalidrawElement } from "./types"; export const isInvisiblySmallElement = ( element: ExcalidrawElement, ): boolean => { + if (isElbowArrow(element)) { + return ( + element.points.length < 2 || + pointsEqual(element.points[0], element.points[element.points.length - 1]) + ); + } if (isLinearElement(element) || isFreeDrawElement(element)) { return element.points.length < 2; } diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 22638ee91..7ab677f02 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -7,6 +7,7 @@ import { import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { + isArrowElement, isBindingElement, isLinearElement, } from "@excalidraw/element/typeChecks"; @@ -62,6 +63,7 @@ export const actionFinalize = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } + } else if (isArrowElement(appState.newElement)) { } let newElements = elements; @@ -82,48 +84,46 @@ export const actionFinalize = register({ focusContainer(); } - const multiPointElement = appState.multiElement + const element = appState.multiElement ? appState.multiElement : appState.newElement?.type === "freedraw" ? appState.newElement + : isBindingElement(appState.newElement) + ? appState.newElement : null; - if (multiPointElement) { + if (element) { // pen and mouse have hover if ( - multiPointElement.type !== "freedraw" && + appState.multiElement && + element.type !== "freedraw" && appState.lastPointerDownWith !== "touch" ) { - const { points, lastCommittedPoint } = multiPointElement; + const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint ) { - scene.mutateElement(multiPointElement, { - points: multiPointElement.points.slice(0, -1), + scene.mutateElement(element, { + points: element.points.slice(0, -1), }); } } - if (isInvisiblySmallElement(multiPointElement)) { + if (isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter( - (el) => el.id !== multiPointElement.id, - ); + newElements = newElements.filter((el) => el.id !== element.id); } // If the multi point line closes the loop, // set the last point to first point. // This ensures that loop remains closed at different scales. - const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); - if ( - multiPointElement.type === "line" || - multiPointElement.type === "freedraw" - ) { + const isLoop = isPathALoop(element.points, appState.zoom.value); + if (element.type === "line" || element.type === "freedraw") { if (isLoop) { - const linePoints = multiPointElement.points; + const linePoints = element.points; const firstPoint = linePoints[0]; - scene.mutateElement(multiPointElement, { + scene.mutateElement(element, { points: linePoints.map((p, index) => index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1]) @@ -133,24 +133,20 @@ export const actionFinalize = register({ } } - if ( - isBindingElement(multiPointElement) && - !isLoop && - multiPointElement.points.length > 1 - ) { + if (isBindingElement(element) && !isLoop && element.points.length > 1) { const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - multiPointElement, + element, -1, arrayToMap(elements), ); - maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); + maybeBindLinearElement(element, appState, { x, y }, scene); } } if ( (!appState.activeTool.locked && appState.activeTool.type !== "freedraw") || - !multiPointElement + !element ) { resetCursor(interactiveCanvas); } @@ -177,7 +173,7 @@ export const actionFinalize = register({ activeTool: (appState.activeTool.locked || appState.activeTool.type === "freedraw") && - multiPointElement + element ? appState.activeTool : activeTool, activeEmbeddable: null, @@ -188,21 +184,18 @@ export const actionFinalize = register({ startBoundElement: null, suggestedBindings: [], selectedElementIds: - multiPointElement && + element && !appState.activeTool.locked && appState.activeTool.type !== "freedraw" ? { ...appState.selectedElementIds, - [multiPointElement.id]: true, + [element.id]: true, } : appState.selectedElementIds, // To select the linear element when user has finished mutipoint editing selectedLinearElement: - multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor( - multiPointElement, - arrayToMap(newElements), - ) + element && isLinearElement(element) + ? new LinearElementEditor(element, arrayToMap(newElements)) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ddb071981..7c7d2ba49 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9022,6 +9022,7 @@ class App extends React.Component { linearElementEditor; const element = this.scene.getElement(linearElementEditor.elementId); if (isBindingElement(element)) { + this.actionManager.executeAction(actionFinalize); bindOrUnbindLinearElement( element, startBindingElement, From e51e72c676492a984d0becd7053bd3dea7a70444 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Apr 2025 19:11:11 +0200 Subject: [PATCH 07/12] Another half of actionFinalize on arrow endpoint drag --- .../excalidraw/actions/actionFinalize.tsx | 31 ++++++++++++++----- packages/math/src/point.ts | 3 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 7ab677f02..63751d1f6 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -17,6 +17,12 @@ import { isPathALoop } from "@excalidraw/element/shapes"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "@excalidraw/element/types"; + import { t } from "../i18n"; import { resetCursor } from "../cursor"; import { done } from "../components/icons"; @@ -84,13 +90,22 @@ export const actionFinalize = register({ focusContainer(); } - const element = appState.multiElement - ? appState.multiElement - : appState.newElement?.type === "freedraw" - ? appState.newElement - : isBindingElement(appState.newElement) - ? appState.newElement - : null; + let element: NonDeleted | null = null; + if (appState.multiElement) { + element = appState.multiElement; + } else if ( + appState.newElement?.type === "freedraw" || + isBindingElement(appState.newElement) + ) { + element = appState.newElement; + } else if (Object.keys(appState.selectedElementIds).length === 1) { + const candidate = elementsMap.get( + Object.keys(appState.selectedElementIds)[0], + ) as NonDeleted | undefined; + if (candidate) { + element = candidate; + } + } if (element) { // pen and mouse have hover @@ -112,7 +127,7 @@ export const actionFinalize = register({ if (isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter((el) => el.id !== element.id); + newElements = newElements.filter((el) => el.id !== element!.id); } // If the multi point line closes the loop, diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index b6054a10a..1e80d28d7 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -91,9 +91,10 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint { export function pointsEqual( a: Point, b: Point, + threshold: number = PRECISION, ): boolean { const abs = Math.abs; - return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION; + return abs(a[0] - b[0]) < threshold && abs(a[1] - b[1]) < threshold; } /** From 3054be4c20aec51159e9577f329fce55c37a2931 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Apr 2025 19:55:50 +0200 Subject: [PATCH 08/12] Binding gap correctly represent the area where the arrow can bind --- packages/excalidraw/renderer/interactiveScene.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 69c6a8196..bfcac96b6 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -17,7 +17,6 @@ import { import { BINDING_HIGHLIGHT_OFFSET, - BINDING_HIGHLIGHT_THICKNESS, maxBindingGap, } from "@excalidraw/element/binding"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; @@ -261,9 +260,9 @@ const renderBindingHighlightForBindableElement = ( const height = y2 - y1; context.strokeStyle = "rgba(0,0,0,.05)"; - // When zooming out, make line width greater for visibility - const zoomValue = zoom.value < 1 ? zoom.value : 1; - context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue; + context.lineWidth = + maxBindingGap(element, element.width, element.height, zoom) - + BINDING_HIGHLIGHT_OFFSET; // To ensure the binding highlight doesn't overlap the element itself const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET; From 43561b66310f6290b7f81ed1e9acd77ef6dda0d4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Apr 2025 20:57:53 +0200 Subject: [PATCH 09/12] Refactor how element border tests work --- packages/element/src/binding.ts | 102 ++++++++++-------- packages/element/src/elbowArrow.ts | 13 +-- .../excalidraw/actions/actionFinalize.tsx | 7 +- 3 files changed, 68 insertions(+), 54 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 927f55ae0..8420b5df5 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,7 +27,7 @@ import { PRECISION, } from "@excalidraw/math"; -import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; +import { isPointInShape } from "@excalidraw/utils/collision"; import { getEllipseShape, getPolygonShape } from "@excalidraw/utils/shape"; @@ -64,7 +64,7 @@ import { isTextElement, } from "./typeChecks"; -import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; +import { aabbForElement } from "./shapes"; import { updateElbowArrowPoints } from "./elbowArrow"; import type Scene from "./Scene"; @@ -108,7 +108,7 @@ export const isBindingEnabled = (appState: AppState): boolean => { }; export const FIXED_BINDING_DISTANCE = 5; -export const BINDING_HIGHLIGHT_THICKNESS = 10; +const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_OFFSET = 4; const getNonDeletedElements = ( @@ -442,19 +442,15 @@ const normalizePointBinding = ( binding: { focus: number; gap: number }, hoveredElement: ExcalidrawBindableElement, ) => { - let gap = binding.gap; const maxGap = maxBindingGap( hoveredElement, hoveredElement.width, hoveredElement.height, ); - if (gap > maxGap) { - gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET; - } return { ...binding, - gap, + gap: Math.min(binding.gap, maxGap), }; }; @@ -566,21 +562,25 @@ export const getHoveredElementForBinding = ( let cullRest = false; const candidateElements = getAllElementsAtPositionForBinding( elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - (fullShape || - !isBindingFallthroughEnabled( - element as ExcalidrawBindableElement, - )) && - // disable fullshape snapping for frame elements so we - // can bind to frame children - !isFrameLikeElement(element), - ), + (element) => { + const result = + isBindableElement(element, false) && + bindingBorderTest( + element, + pointerCoords, + elementsMap, + zoom, + (fullShape || + !isBindingFallthroughEnabled( + element as ExcalidrawBindableElement, + )) && + // disable fullshape snapping for frame elements so we + // can bind to frame children + !isFrameLikeElement(element), + ); + + return result; + }, ).filter((element) => { if (cullRest) { return false; @@ -888,7 +888,12 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding(origPoint, bindableElement, zoom); + const distance = getDistanceForBinding( + origPoint, + bindableElement, + true, + zoom, + ); if (!distance) { return vectorToHeading( @@ -899,9 +904,10 @@ export const getHeadingForElbowArrowSnap = ( return headingForPointFromElement(bindableElement, aabb, p); }; -const getDistanceForBinding = ( +export const getDistanceForBinding = ( point: Readonly, bindableElement: ExcalidrawBindableElement, + fullShape: boolean, zoom?: AppState["zoom"], ) => { const distance = distanceToBindableElement(bindableElement, point); @@ -911,12 +917,14 @@ const getDistanceForBinding = ( bindableElement.height, zoom, ); - const isInside = isPointInShape( - point, - bindableElement.type === "ellipse" - ? getEllipseShape(bindableElement) - : getPolygonShape(bindableElement), - ); + const isInside = fullShape + ? isPointInShape( + point, + bindableElement.type === "ellipse" + ? getEllipseShape(bindableElement) + : getPolygonShape(bindableElement), + ) + : false; return distance > bindDistance && !isInside ? null : distance; }; @@ -1172,22 +1180,22 @@ export const snapToMid = ( angle, ); } else if (element.type === "diamond") { - const sqrtFixedDistance = Math.sqrt(FIXED_BINDING_DISTANCE); + const distance = FIXED_BINDING_DISTANCE - 1; const topLeft = pointFrom( - x + width / 4 - sqrtFixedDistance, - y + height / 4 - sqrtFixedDistance, + x + width / 4 - distance, + y + height / 4 - distance, ); const topRight = pointFrom( - x + (3 * width) / 4 + sqrtFixedDistance, - y + height / 4 - sqrtFixedDistance, + x + (3 * width) / 4 + distance, + y + height / 4 - distance, ); const bottomLeft = pointFrom( - x + width / 4 - sqrtFixedDistance, - y + (3 * height) / 4 + sqrtFixedDistance, + x + width / 4 - distance, + y + (3 * height) / 4 + distance, ); const bottomRight = pointFrom( - x + (3 * width) / 4 + sqrtFixedDistance, - y + (3 * height) / 4 + sqrtFixedDistance, + x + (3 * width) / 4 + distance, + y + (3 * height) / 4 + distance, ); if ( pointDistance(topLeft, nonRotated) < @@ -1553,14 +1561,14 @@ export const bindingBorderTest = ( zoom?: AppState["zoom"], fullShape?: boolean, ): boolean => { - 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 distance = getDistanceForBinding( + pointFrom(x, y), + element, + !!fullShape, + zoom, ); + + return !!distance; }; export const maxBindingGap = ( diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 95a2aa8ef..22ae42713 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -31,6 +31,7 @@ import { getGlobalFixedPointForBindableElement, snapToMid, getHoveredElementForBinding, + getDistanceForBinding, } from "./binding"; import { distanceToBindableElement } from "./distance"; import { @@ -1255,6 +1256,7 @@ const getElbowArrowData = ( origStartGlobalPoint, hoveredStartElement, options?.isDragging, + options?.zoom, ); const endGlobalPoint = getGlobalPoint( { @@ -1268,6 +1270,7 @@ const getElbowArrowData = ( origEndGlobalPoint, hoveredEndElement, options?.isDragging, + options?.zoom, ); const startHeading = getBindPointHeading( startGlobalPoint, @@ -2211,16 +2214,14 @@ const getGlobalPoint = ( initialPoint: GlobalPoint, element?: ExcalidrawBindableElement | null, isDragging?: boolean, + zoom?: AppState["zoom"], ): GlobalPoint => { if (isDragging) { - if (element) { - const snapPoint = bindPointToSnapToElementOutline( - arrow, + if (element && getDistanceForBinding(initialPoint, element, true, zoom)) { + return snapToMid( element, - startOrEnd, + bindPointToSnapToElementOutline(arrow, element, startOrEnd), ); - - return snapToMid(element, snapPoint); } return initialPoint; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 63751d1f6..9e3f69107 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -148,7 +148,12 @@ export const actionFinalize = register({ } } - if (isBindingElement(element) && !isLoop && element.points.length > 1) { + if ( + isBindingElement(element) && + !isLoop && + element.points.length > 1 && + !appState.selectedElementIds[element.id] + ) { const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, -1, From f6203daac52798b94b993c469d0db396b2f37dd9 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 1 May 2025 09:44:02 +0200 Subject: [PATCH 10/12] Restore master Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 138 ++++++------------ packages/element/src/elbowArrow.ts | 13 +- packages/element/src/sizeHelpers.ts | 10 +- packages/element/tests/elbowArrow.test.tsx | 95 +----------- .../excalidraw/actions/actionFinalize.tsx | 81 +++++----- packages/excalidraw/components/App.tsx | 1 - .../excalidraw/renderer/interactiveScene.ts | 7 +- 7 files changed, 93 insertions(+), 252 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 8420b5df5..ee5d037a8 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,9 +27,7 @@ import { PRECISION, } from "@excalidraw/math"; -import { isPointInShape } from "@excalidraw/utils/collision"; - -import { getEllipseShape, getPolygonShape } from "@excalidraw/utils/shape"; +import { isPointOnShape } from "@excalidraw/utils/collision"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -46,6 +44,7 @@ import { intersectElementWithLineSegment } from "./collision"; import { distanceToBindableElement } from "./distance"; import { headingForPointFromElement, + headingIsHorizontal, vectorToHeading, type Heading, } from "./heading"; @@ -64,7 +63,7 @@ import { isTextElement, } from "./typeChecks"; -import { aabbForElement } from "./shapes"; +import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { updateElbowArrowPoints } from "./elbowArrow"; import type Scene from "./Scene"; @@ -108,7 +107,7 @@ export const isBindingEnabled = (appState: AppState): boolean => { }; export const FIXED_BINDING_DISTANCE = 5; -const BINDING_HIGHLIGHT_THICKNESS = 10; +export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_OFFSET = 4; const getNonDeletedElements = ( @@ -442,15 +441,19 @@ const normalizePointBinding = ( binding: { focus: number; gap: number }, hoveredElement: ExcalidrawBindableElement, ) => { + let gap = binding.gap; const maxGap = maxBindingGap( hoveredElement, hoveredElement.width, hoveredElement.height, ); + if (gap > maxGap) { + gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET; + } return { ...binding, - gap: Math.min(binding.gap, maxGap), + gap, }; }; @@ -562,25 +565,21 @@ export const getHoveredElementForBinding = ( let cullRest = false; const candidateElements = getAllElementsAtPositionForBinding( elements, - (element) => { - const result = - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - (fullShape || - !isBindingFallthroughEnabled( - element as ExcalidrawBindableElement, - )) && - // disable fullshape snapping for frame elements so we - // can bind to frame children - !isFrameLikeElement(element), - ); - - return result; - }, + (element) => + isBindableElement(element, false) && + bindingBorderTest( + element, + pointerCoords, + elementsMap, + zoom, + (fullShape || + !isBindingFallthroughEnabled( + element as ExcalidrawBindableElement, + )) && + // disable fullshape snapping for frame elements so we + // can bind to frame children + !isFrameLikeElement(element), + ), ).filter((element) => { if (cullRest) { return false; @@ -888,12 +887,7 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding( - origPoint, - bindableElement, - true, - zoom, - ); + const distance = getDistanceForBinding(origPoint, bindableElement, zoom); if (!distance) { return vectorToHeading( @@ -904,10 +898,9 @@ export const getHeadingForElbowArrowSnap = ( return headingForPointFromElement(bindableElement, aabb, p); }; -export const getDistanceForBinding = ( +const getDistanceForBinding = ( point: Readonly, bindableElement: ExcalidrawBindableElement, - fullShape: boolean, zoom?: AppState["zoom"], ) => { const distance = distanceToBindableElement(bindableElement, point); @@ -917,16 +910,8 @@ export const getDistanceForBinding = ( bindableElement.height, zoom, ); - const isInside = fullShape - ? isPointInShape( - point, - bindableElement.type === "ellipse" - ? getEllipseShape(bindableElement) - : getPolygonShape(bindableElement), - ) - : false; - return distance > bindDistance && !isInside ? null : distance; + return distance > bindDistance ? null : distance; }; export const bindPointToSnapToElementOutline = ( @@ -962,16 +947,23 @@ export const bindPointToSnapToElementOutline = ( let intersection: GlobalPoint | null = null; if (elbowed) { + const isHorizontal = headingIsHorizontal( + headingForPointFromElement(bindableElement, aabb, globalP), + ); + const otherPoint = pointFrom( + isHorizontal ? center[0] : edgePoint[0], + !isHorizontal ? center[1] : edgePoint[1], + ); intersection = intersectElementWithLineSegment( bindableElement, lineSegment( - center, + otherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, center)), + vectorNormalize(vectorFromPoint(edgePoint, otherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), - center, + otherPoint, ), ), )[0]; @@ -1179,48 +1171,6 @@ export const snapToMid = ( center, angle, ); - } else if (element.type === "diamond") { - const distance = FIXED_BINDING_DISTANCE - 1; - const topLeft = pointFrom( - x + width / 4 - distance, - y + height / 4 - distance, - ); - const topRight = pointFrom( - x + (3 * width) / 4 + distance, - y + height / 4 - distance, - ); - const bottomLeft = pointFrom( - x + width / 4 - distance, - y + (3 * height) / 4 + distance, - ); - const bottomRight = pointFrom( - x + (3 * width) / 4 + distance, - y + (3 * height) / 4 + distance, - ); - if ( - pointDistance(topLeft, nonRotated) < - Math.max(horizontalThrehsold, verticalThrehsold) - ) { - return pointRotateRads(topLeft, center, angle); - } - if ( - pointDistance(topRight, nonRotated) < - Math.max(horizontalThrehsold, verticalThrehsold) - ) { - return pointRotateRads(topRight, center, angle); - } - if ( - pointDistance(bottomLeft, nonRotated) < - Math.max(horizontalThrehsold, verticalThrehsold) - ) { - return pointRotateRads(bottomLeft, center, angle); - } - if ( - pointDistance(bottomRight, nonRotated) < - Math.max(horizontalThrehsold, verticalThrehsold) - ) { - return pointRotateRads(bottomRight, center, angle); - } } return p; @@ -1561,14 +1511,14 @@ export const bindingBorderTest = ( zoom?: AppState["zoom"], fullShape?: boolean, ): boolean => { - const distance = getDistanceForBinding( - pointFrom(x, y), - element, - !!fullShape, - zoom, - ); + const threshold = maxBindingGap(element, element.width, element.height, zoom); - return !!distance; + const shape = getElementShape(element, elementsMap); + return ( + isPointOnShape(pointFrom(x, y), shape, threshold) || + (fullShape === true && + pointInsideBounds(pointFrom(x, y), aabbForElement(element))) + ); }; export const maxBindingGap = ( diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 22ae42713..95a2aa8ef 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -31,7 +31,6 @@ import { getGlobalFixedPointForBindableElement, snapToMid, getHoveredElementForBinding, - getDistanceForBinding, } from "./binding"; import { distanceToBindableElement } from "./distance"; import { @@ -1256,7 +1255,6 @@ const getElbowArrowData = ( origStartGlobalPoint, hoveredStartElement, options?.isDragging, - options?.zoom, ); const endGlobalPoint = getGlobalPoint( { @@ -1270,7 +1268,6 @@ const getElbowArrowData = ( origEndGlobalPoint, hoveredEndElement, options?.isDragging, - options?.zoom, ); const startHeading = getBindPointHeading( startGlobalPoint, @@ -2214,14 +2211,16 @@ const getGlobalPoint = ( initialPoint: GlobalPoint, element?: ExcalidrawBindableElement | null, isDragging?: boolean, - zoom?: AppState["zoom"], ): GlobalPoint => { if (isDragging) { - if (element && getDistanceForBinding(initialPoint, element, true, zoom)) { - return snapToMid( + if (element) { + const snapPoint = bindPointToSnapToElementOutline( + arrow, element, - bindPointToSnapToElementOutline(arrow, element, startOrEnd), + startOrEnd, ); + + return snapToMid(element, snapPoint); } return initialPoint; diff --git a/packages/element/src/sizeHelpers.ts b/packages/element/src/sizeHelpers.ts index 02f6ea923..bd3d3fb0c 100644 --- a/packages/element/src/sizeHelpers.ts +++ b/packages/element/src/sizeHelpers.ts @@ -3,12 +3,10 @@ import { viewportCoordsToSceneCoords, } from "@excalidraw/common"; -import { pointsEqual } from "@excalidraw/math"; - import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import { getCommonBounds, getElementBounds } from "./bounds"; -import { isElbowArrow, isFreeDrawElement, isLinearElement } from "./typeChecks"; +import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import type { ElementsMap, ExcalidrawElement } from "./types"; @@ -18,12 +16,6 @@ import type { ElementsMap, ExcalidrawElement } from "./types"; export const isInvisiblySmallElement = ( element: ExcalidrawElement, ): boolean => { - if (isElbowArrow(element)) { - return ( - element.points.length < 2 || - pointsEqual(element.points[0], element.points[element.points.length - 1]) - ); - } if (isLinearElement(element) || isFreeDrawElement(element)) { return element.points.length < 2; } diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index 01794345e..25f64072e 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -199,7 +199,7 @@ describe("elbow arrow routing", () => { points: [pointFrom(0, 0), pointFrom(90, 200)], }); - expect(arrow.points).toCloselyEqualPoints([ + expect(arrow.points).toEqual([ [0, 0], [45, 0], [45, 200], @@ -253,7 +253,7 @@ describe("elbow arrow ui", () => { expect(arrow.type).toBe("arrow"); expect(arrow.elbowed).toBe(true); - expect(arrow.points).toCloselyEqualPoints([ + expect(arrow.points).toEqual([ [0, 0], [45, 0], [45, 200], @@ -351,7 +351,7 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toCloselyEqualPoints([ + expect(duplicatedArrow.points).toEqual([ [0, 0], [45, 0], [45, 200], @@ -405,98 +405,11 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toCloselyEqualPoints([ + expect(duplicatedArrow.points).toEqual([ [0, 0], [0, 100], [90, 100], [90, 200], ]); }); - - it("elbow arrow snap at diamond quarter point too", async () => { - UI.createElement("diamond", { - x: -50, - y: -50, - width: 100, - height: 100, - }); - - UI.clickTool("arrow"); - UI.clickOnTestId("elbow-arrow"); - - mouse.reset(); - mouse.moveTo(43, 99); - mouse.click(); - mouse.moveTo(27, 25); - mouse.click(); - - let arrow = h.scene.getSelectedElements( - h.state, - )[0] as ExcalidrawArrowElement; - - expect(arrow.endBinding).not.toBe(null); - expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( - 29.0355, - ); - expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( - 29.0355, - ); - - UI.clickTool("arrow"); - UI.clickOnTestId("elbow-arrow"); - - mouse.reset(); - mouse.moveTo(43, 99); - mouse.click(); - mouse.moveTo(-23, 25); - mouse.click(); - - arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; - - expect(arrow.endBinding).not.toBe(null); - expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( - -28.5559, - ); - expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( - 28.5559, - ); - - UI.clickTool("arrow"); - UI.clickOnTestId("elbow-arrow"); - - mouse.reset(); - mouse.moveTo(43, 99); - mouse.click(); - mouse.moveTo(-27, -25); - mouse.click(); - - arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; - - expect(arrow.endBinding).not.toBe(null); - expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( - -28.0355, - ); - expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( - -28.0355, - ); - - UI.clickTool("arrow"); - UI.clickOnTestId("elbow-arrow"); - - mouse.reset(); - mouse.moveTo(43, 99); - mouse.click(); - mouse.moveTo(23, -25); - mouse.click(); - - arrow = h.scene.getSelectedElements(h.state)[0] as ExcalidrawArrowElement; - - expect(arrow.endBinding).not.toBe(null); - expect(arrow.x + arrow.points[arrow.points.length - 1][0]).toBeCloseTo( - 28.5559, - ); - expect(arrow.y + arrow.points[arrow.points.length - 1][1]).toBeCloseTo( - -28.5559, - ); - }); }); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9e3f69107..22638ee91 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -7,7 +7,6 @@ import { import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { - isArrowElement, isBindingElement, isLinearElement, } from "@excalidraw/element/typeChecks"; @@ -17,12 +16,6 @@ import { isPathALoop } from "@excalidraw/element/shapes"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; -import type { - ExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, -} from "@excalidraw/element/types"; - import { t } from "../i18n"; import { resetCursor } from "../cursor"; import { done } from "../components/icons"; @@ -69,7 +62,6 @@ export const actionFinalize = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } - } else if (isArrowElement(appState.newElement)) { } let newElements = elements; @@ -90,55 +82,48 @@ export const actionFinalize = register({ focusContainer(); } - let element: NonDeleted | null = null; - if (appState.multiElement) { - element = appState.multiElement; - } else if ( - appState.newElement?.type === "freedraw" || - isBindingElement(appState.newElement) - ) { - element = appState.newElement; - } else if (Object.keys(appState.selectedElementIds).length === 1) { - const candidate = elementsMap.get( - Object.keys(appState.selectedElementIds)[0], - ) as NonDeleted | undefined; - if (candidate) { - element = candidate; - } - } + const multiPointElement = appState.multiElement + ? appState.multiElement + : appState.newElement?.type === "freedraw" + ? appState.newElement + : null; - if (element) { + if (multiPointElement) { // pen and mouse have hover if ( - appState.multiElement && - element.type !== "freedraw" && + multiPointElement.type !== "freedraw" && appState.lastPointerDownWith !== "touch" ) { - const { points, lastCommittedPoint } = element; + const { points, lastCommittedPoint } = multiPointElement; if ( !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint ) { - scene.mutateElement(element, { - points: element.points.slice(0, -1), + scene.mutateElement(multiPointElement, { + points: multiPointElement.points.slice(0, -1), }); } } - if (isInvisiblySmallElement(element)) { + if (isInvisiblySmallElement(multiPointElement)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter((el) => el.id !== element!.id); + newElements = newElements.filter( + (el) => el.id !== multiPointElement.id, + ); } // If the multi point line closes the loop, // set the last point to first point. // This ensures that loop remains closed at different scales. - const isLoop = isPathALoop(element.points, appState.zoom.value); - if (element.type === "line" || element.type === "freedraw") { + const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); + if ( + multiPointElement.type === "line" || + multiPointElement.type === "freedraw" + ) { if (isLoop) { - const linePoints = element.points; + const linePoints = multiPointElement.points; const firstPoint = linePoints[0]; - scene.mutateElement(element, { + scene.mutateElement(multiPointElement, { points: linePoints.map((p, index) => index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1]) @@ -149,24 +134,23 @@ export const actionFinalize = register({ } if ( - isBindingElement(element) && + isBindingElement(multiPointElement) && !isLoop && - element.points.length > 1 && - !appState.selectedElementIds[element.id] + multiPointElement.points.length > 1 ) { const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, + multiPointElement, -1, arrayToMap(elements), ); - maybeBindLinearElement(element, appState, { x, y }, scene); + maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); } } if ( (!appState.activeTool.locked && appState.activeTool.type !== "freedraw") || - !element + !multiPointElement ) { resetCursor(interactiveCanvas); } @@ -193,7 +177,7 @@ export const actionFinalize = register({ activeTool: (appState.activeTool.locked || appState.activeTool.type === "freedraw") && - element + multiPointElement ? appState.activeTool : activeTool, activeEmbeddable: null, @@ -204,18 +188,21 @@ export const actionFinalize = register({ startBoundElement: null, suggestedBindings: [], selectedElementIds: - element && + multiPointElement && !appState.activeTool.locked && appState.activeTool.type !== "freedraw" ? { ...appState.selectedElementIds, - [element.id]: true, + [multiPointElement.id]: true, } : appState.selectedElementIds, // To select the linear element when user has finished mutipoint editing selectedLinearElement: - element && isLinearElement(element) - ? new LinearElementEditor(element, arrayToMap(newElements)) + multiPointElement && isLinearElement(multiPointElement) + ? new LinearElementEditor( + multiPointElement, + arrayToMap(newElements), + ) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7c7d2ba49..ddb071981 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9022,7 +9022,6 @@ class App extends React.Component { linearElementEditor; const element = this.scene.getElement(linearElementEditor.elementId); if (isBindingElement(element)) { - this.actionManager.executeAction(actionFinalize); bindOrUnbindLinearElement( element, startBindingElement, diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index bfcac96b6..69c6a8196 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -17,6 +17,7 @@ import { import { BINDING_HIGHLIGHT_OFFSET, + BINDING_HIGHLIGHT_THICKNESS, maxBindingGap, } from "@excalidraw/element/binding"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; @@ -260,9 +261,9 @@ const renderBindingHighlightForBindableElement = ( const height = y2 - y1; context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = - maxBindingGap(element, element.width, element.height, zoom) - - BINDING_HIGHLIGHT_OFFSET; + // When zooming out, make line width greater for visibility + const zoomValue = zoom.value < 1 ? zoom.value : 1; + context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue; // To ensure the binding highlight doesn't overlap the element itself const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET; From 8d5de7854e69890ac9ca57760e45194db1a58d09 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 1 May 2025 09:46:07 +0200 Subject: [PATCH 11/12] Snap to quarter points on diamonds restored Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ee5d037a8..d44123b71 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1171,6 +1171,48 @@ export const snapToMid = ( center, angle, ); + } else if (element.type === "diamond") { + const distance = FIXED_BINDING_DISTANCE - 1; + const topLeft = pointFrom( + x + width / 4 - distance, + y + height / 4 - distance, + ); + const topRight = pointFrom( + x + (3 * width) / 4 + distance, + y + height / 4 - distance, + ); + const bottomLeft = pointFrom( + x + width / 4 - distance, + y + (3 * height) / 4 + distance, + ); + const bottomRight = pointFrom( + x + (3 * width) / 4 + distance, + y + (3 * height) / 4 + distance, + ); + if ( + pointDistance(topLeft, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topLeft, center, angle); + } + if ( + pointDistance(topRight, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topRight, center, angle); + } + if ( + pointDistance(bottomLeft, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomLeft, center, angle); + } + if ( + pointDistance(bottomRight, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomRight, center, angle); + } } return p; From 948f4bdb353369b1895366590308c23e806c337e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 1 May 2025 09:48:00 +0200 Subject: [PATCH 12/12] Restore point.ts Signed-off-by: Mark Tolmacs --- packages/math/src/point.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index 1e80d28d7..b6054a10a 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -91,10 +91,9 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint { export function pointsEqual( a: Point, b: Point, - threshold: number = PRECISION, ): boolean { const abs = Math.abs; - return abs(a[0] - b[0]) < threshold && abs(a[1] - b[1]) < threshold; + return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION; } /**