From a70417f23f5985e3ed2a678c3cd25bffb23b1cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 11 Feb 2026 11:06:27 +0100 Subject: [PATCH] feat(editor): visualize binding midpoints + support for simple arrows (#10611) * feat: Force exact center focus point When the projected point is close to center snap it to the exact center. Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * fix: Snap to center around side mid point. Signed-off-by: Mark Tolmacs * Trigger CI * fix: Midpoint outline focus point Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * fix: Dragging existing arrow reset focus point on outline Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * feat: Midpoint indicator Signed-off-by: Mark Tolmacs * fix: Rotated mid points Signed-off-by: Mark Tolmacs * fix: No hole Signed-off-by: Mark Tolmacs * feat: Cache hits and scene lookups Signed-off-by: Mark Tolmacs * chore: Remove debug Signed-off-by: Mark Tolmacs * fix: Consider hit threshold and inside override too Signed-off-by: Mark Tolmacs * fix: Increase outline midpoint sticky distance Signed-off-by: Mark Tolmacs * fix: Don't show midpoint indicator when no snapping is possible Signed-off-by: Mark Tolmacs * feat: Indicate lock-in Signed-off-by: Mark Tolmacs * chore: Remove Map caching Signed-off-by: Mark Tolmacs * fix: incorrect threshold Signed-off-by: Mark Tolmacs * fix: threshold setting Signed-off-by: Mark Tolmacs * fix: Hit caching Signed-off-by: Mark Tolmacs * fix: Simple arrow mid point selection inconsistency Signed-off-by: Mark Tolmacs * fix: cache override Signed-off-by: Mark Tolmacs * fix: Precise know dragging with midpoint refactor Signed-off-by: Mark Tolmacs * fear: Frame support Signed-off-by: Mark Tolmacs * fix: Crossing arrow won't trigger mid point Signed-off-by: Mark Tolmacs * fix: Arrow creation point highlight Signed-off-by: Mark Tolmacs * fix: Restore types & tests Signed-off-by: Mark Tolmacs * chore: Restore restore.ts Signed-off-by: Mark Tolmacs * fix: restore.ts Signed-off-by: Mark Tolmacs * fix: Elbow arrows reliably highlight center point Signed-off-by: Mark Tolmacs * fix: Highlight point ordering Signed-off-by: Mark Tolmacs * feat: Bind with focus point across shape Signed-off-by: Mark Tolmacs * fix: Lint * fix: Midpoint and binding alignment Signed-off-by: Mark Tolmacs * chore: Indicator color Signed-off-by: Mark Tolmacs * chore: More knob tuning Signed-off-by: Mark Tolmacs * fix: Radius Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * simplify point indicators --------- Signed-off-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/binding.ts | 44 ++- packages/element/src/linearElementEditor.ts | 65 +++- packages/element/src/utils.ts | 144 ++++++-- packages/element/tests/resize.test.tsx | 4 +- .../excalidraw/actions/actionFinalize.tsx | 1 - packages/excalidraw/components/App.tsx | 54 ++- .../components/Stats/stats.test.tsx | 1 - packages/excalidraw/data/restore.ts | 8 +- .../excalidraw/renderer/interactiveScene.ts | 331 +++++++++++++++--- .../tests/__snapshots__/history.test.tsx.snap | 236 ++++++------- .../tests/__snapshots__/move.test.tsx.snap | 10 +- packages/excalidraw/tests/history.test.tsx | 43 +-- packages/excalidraw/tests/move.test.tsx | 15 +- packages/excalidraw/tests/rotate.test.tsx | 4 +- packages/excalidraw/types.ts | 7 +- 15 files changed, 699 insertions(+), 268 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index d3c4003288..e7bc98c04f 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -800,6 +800,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( hit, startDragged ? "start" : "end", elementsMap, + appState.zoom, ) || globalPoint, } : { mode: null }; @@ -833,6 +834,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( otherBindableElement, startDragged ? "end" : "start", elementsMap, + appState.zoom, ) || otherEndpoint, } : { mode: undefined } @@ -1389,14 +1391,16 @@ export const bindPointToSnapToElementOutline = ( headingForPointFromElement(bindableElement, aabb, point), ); const snapPoint = snapToMid( - arrowElement, bindableElement, elementsMap, edgePoint, + 0.05, + arrowElement, ); + const resolved = snapPoint || point; const otherPoint = pointFrom( - isHorizontal ? bindableCenter[0] : snapPoint[0], - !isHorizontal ? bindableCenter[1] : snapPoint[1], + isHorizontal ? bindableCenter[0] : resolved[0], + !isHorizontal ? bindableCenter[1] : resolved[1], ); const intersector = customIntersector ?? @@ -1404,7 +1408,7 @@ export const bindPointToSnapToElementOutline = ( otherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), + vectorNormalize(vectorFromPoint(resolved, otherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), otherPoint, @@ -1419,14 +1423,14 @@ export const bindPointToSnapToElementOutline = ( if (!intersection) { const anotherPoint = pointFrom( - !isHorizontal ? bindableCenter[0] : snapPoint[0], - isHorizontal ? bindableCenter[1] : snapPoint[1], + !isHorizontal ? bindableCenter[0] : resolved[0], + isHorizontal ? bindableCenter[1] : resolved[1], ); const anotherIntersector = lineSegment( anotherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), + vectorNormalize(vectorFromPoint(resolved, anotherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), anotherPoint, @@ -1573,18 +1577,18 @@ export const avoidRectangularCorner = ( return p; }; -const snapToMid = ( - arrowElement: ExcalidrawArrowElement, +export const snapToMid = ( bindTarget: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, tolerance: number = 0.05, -): GlobalPoint => { + arrowElement?: ExcalidrawArrowElement, +): GlobalPoint | undefined => { const { x, y, width, height, angle } = bindTarget; const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const nonRotated = pointRotateRads(p, center, -angle as Radians); - const bindingGap = getBindingGap(bindTarget, arrowElement); + const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0; // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -1593,7 +1597,7 @@ const snapToMid = ( // Too close to the center makes it hard to resolve direction precisely if (pointDistance(center, nonRotated) < bindingGap) { - return p; + return undefined; } if ( @@ -1602,8 +1606,8 @@ const snapToMid = ( nonRotated[1] < center[1] + verticalThreshold ) { // LEFT - return pointRotateRads( - pointFrom(x - bindingGap, center[1]), + return pointRotateRads( + pointFrom(x - bindingGap, center[1]), center, angle, ); @@ -1613,7 +1617,11 @@ const snapToMid = ( nonRotated[0] < center[0] + horizontalThreshold ) { // TOP - return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle); + return pointRotateRads( + pointFrom(center[0], y - bindingGap), + center, + angle, + ); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1621,7 +1629,7 @@ const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + bindingGap, center[1]), + pointFrom(x + width + bindingGap, center[1]), center, angle, ); @@ -1632,7 +1640,7 @@ const snapToMid = ( ) { // DOWN return pointRotateRads( - pointFrom(center[0], y + height + bindingGap), + pointFrom(center[0], y + height + bindingGap), center, angle, ); @@ -1681,7 +1689,7 @@ const snapToMid = ( } } - return p; + return undefined; }; const compareElementArea = ( diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 125ef05025..1664a578e2 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -26,6 +26,7 @@ import { import { deconstructLinearOrFreeDrawElement, + getSnapOutlineMidPoint, isPathALoop, moveArrowAboveBindable, projectFixedPointOntoDiagonal, @@ -48,6 +49,7 @@ import { calculateFixedPointForNonElbowArrowBinding, getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, + snapToMid, updateBoundPoint, } from "./binding"; import { @@ -355,6 +357,7 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event), event.altKey, + linearElementEditor, ); LinearElementEditor.movePoints(element, app.scene, positions, { @@ -408,13 +411,14 @@ export class LinearElementEditor { altFocusPoint: !linearElementEditor.initialState.altFocusPoint && startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id + updates?.suggestedBinding?.element.id !== startBindingElement.id ? projectFixedPointOntoDiagonal( element, pointFrom(element.x, element.y), startBindingElement, "start", elementsMap, + app.state.zoom, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -532,6 +536,7 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event) && singlePointDragged, event.altKey, + linearElementEditor, ); LinearElementEditor.movePoints(element, app.scene, positions, { @@ -607,11 +612,11 @@ export class LinearElementEditor { const altFocusPointBindableElement = endIsSelected && // The "other" end (i.e. "end") is dragged startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap + updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap ? startBindingElement : startIsSelected && // The "other" end (i.e. "start") is dragged endBindingElement && - updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap + updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap ? endBindingElement : null; @@ -631,6 +636,7 @@ export class LinearElementEditor { altFocusPointBindableElement, "start", elementsMap, + app.state.zoom, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -2080,6 +2086,7 @@ const pointDraggingUpdates = ( app: AppClassProperties, angleLocked: boolean, altKey: boolean, + linearElementEditor: LinearElementEditor, ): { positions: PointsPositionUpdates; updates?: PointMoveOtherUpdates; @@ -2127,13 +2134,27 @@ const pointDraggingUpdates = ( ); if (isElbowArrow(element)) { + const suggestedBindingElement = startIsDragged + ? start.element + : endIsDragged + ? end.element + : null; + return { positions: naiveDraggingPoints, updates: { - suggestedBinding: startIsDragged - ? start.element - : endIsDragged - ? end.element + suggestedBinding: suggestedBindingElement + ? { + element: suggestedBindingElement, + midPoint: snapToMid( + suggestedBindingElement, + elementsMap, + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + ), + } : null, }, }; @@ -2226,7 +2247,20 @@ const pointDraggingUpdates = ( (updates.startBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = start.element; + updates.suggestedBinding = start.element + ? { + element: start.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + start.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (startIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; @@ -2252,7 +2286,20 @@ const pointDraggingUpdates = ( (updates.endBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = end.element; + updates.suggestedBinding = end.element + ? { + element: end.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + end.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (endIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index c8e6889864..ee341b310a 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -7,6 +7,7 @@ import { } from "@excalidraw/common"; import { + bezierEquation, curve, curveCatmullRomCubicApproxPoints, curveOffsetPoints, @@ -27,19 +28,25 @@ import { import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; -import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; +import type { + AppState, + NormalizedZoomValue, + Zoom, +} from "@excalidraw/excalidraw/types"; import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; -import { isPointInElement } from "./collision"; +import { hitElementItself, isPointInElement } from "./collision"; import { LinearElementEditor } from "./linearElementEditor"; import { isRectangularElement } from "./typeChecks"; +import { maxBindingDistance_simple } from "./binding"; import type { ElementsMap, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -329,24 +336,10 @@ export function deconstructRectanguloidElement( return shape; } -/** - * Get the **unrotated** building components of a diamond element - * in the form of line segments and curves as a tuple, in this order. - * - * @param element The element to deconstruct - * @param offset An optional offset - * @returns Tuple of line **unrotated** segments (0) and curves (1) - */ -export function deconstructDiamondElement( +export function getDiamondBaseCorners( element: ExcalidrawDiamondElement, offset: number = 0, -): [LineSegment[], Curve[]] { - const cachedShape = getElementShapesCacheEntry(element, offset); - - if (cachedShape) { - return cachedShape; - } - +): Curve[] { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = getDiamondPoints(element); const verticalRadius = element.roundness @@ -363,7 +356,7 @@ export function deconstructDiamondElement( pointFrom(element.x + leftX, element.y + leftY), ]; - const baseCorners = [ + return [ curve( pointFrom( right[0] - verticalRadius, @@ -413,6 +406,27 @@ export function deconstructDiamondElement( ), ), // TOP ]; +} + +/** + * Get the **unrotated** building components of a diamond element + * in the form of line segments and curves as a tuple, in this order. + * + * @param element The element to deconstruct + * @param offset An optional offset + * @returns Tuple of line **unrotated** segments (0) and curves (1) + */ +export function deconstructDiamondElement( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const cachedShape = getElementShapesCacheEntry(element, offset); + + if (cachedShape) { + return cachedShape; + } + + const baseCorners = getDiamondBaseCorners(element, offset); const corners = baseCorners.map( (corner) => @@ -570,18 +584,94 @@ const getDiagonalsForBindableElement = ( return [diagonalOne, diagonalTwo]; }; +export const getSnapOutlineMidPoint = ( + point: GlobalPoint, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + zoom: AppState["zoom"], +) => { + const center = elementCenterPoint(element, elementsMap); + const sideMidpoints = + element.type === "diamond" + ? getDiamondBaseCorners(element).map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + + return pointFrom(rotatedPoint[0], rotatedPoint[1]); + }) + : [ + // RIGHT midpoint + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + center, + element.angle, + ), + // BOTTOM midpoint + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + center, + element.angle, + ), + // LEFT midpoint + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + center, + element.angle, + ), + // TOP midpoint + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + center, + element.angle, + ), + ]; + const candidate = sideMidpoints.find( + (midpoint) => + pointDistance(point, midpoint) <= + maxBindingDistance_simple(zoom) + element.strokeWidth / 2 && + !hitElementItself({ + point, + element, + threshold: 0, + elementsMap, + overrideShouldTestInside: true, + }), + ); + + return candidate; +}; + export const projectFixedPointOntoDiagonal = ( arrow: ExcalidrawArrowElement, point: GlobalPoint, - element: ExcalidrawElement, + element: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + zoom: AppState["zoom"], ): GlobalPoint | null => { invariant(arrow.points.length >= 2, "Arrow must have at least two points"); if (arrow.width < 3 && arrow.height < 3) { return null; } + const sideMidPoint = getSnapOutlineMidPoint( + point, + element, + elementsMap, + zoom, + ); + if (sideMidPoint) { + return sideMidPoint; + } + + // Do the projection onto the diagonals (or center lines + // for non-rectangular shapes) const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( element, elementsMap, @@ -603,18 +693,22 @@ export const projectFixedPointOntoDiagonal = ( ), a, ); - const intersector = lineSegment(point, b); + const intersector = lineSegment(b, a); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const d1 = p1 && pointDistance(a, p1); const d2 = p2 && pointDistance(a, p2); - let p = null; + let projection = null; if (d1 != null && d2 != null) { - p = d1 < d2 ? p1 : p2; + projection = d1 < d2 ? p1 : p2; } else { - p = p1 || p2 || null; + projection = p1 || p2 || null; } - return p && isPointInElement(p, element, elementsMap) ? p : null; + if (projection && isPointInElement(projection, element, elementsMap)) { + return projection; + } + + return null; }; diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index 470cc1fa96..15e52dfa1b 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -1350,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(63.4035); - expect(boundArrow.points[1][1]).toBeCloseTo(-84.538); + expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561); + expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index e0f59a5655..9e529621a7 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -108,7 +108,6 @@ export const actionFinalize = register({ return map; }, new Map()) ?? new Map(); - bindOrUnbindBindingElement( element, draggedPoints, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d3b12720ca..fc8a400055 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -249,6 +249,7 @@ import { maxBindingDistance_simple, convertToExcalidrawElements, type ExcalidrawElementSkeleton, + getSnapOutlineMidPoint, handleFocusPointDrag, handleFocusPointHover, handleFocusPointPointerDown, @@ -6400,15 +6401,28 @@ class App extends React.Component { // and point const { newElement } = this.state; if (!newElement && isBindingEnabled(this.state)) { + const globalPoint = pointFrom( + scenePointerX, + scenePointerY, + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const hoveredElement = getHoveredElementForBinding( - pointFrom(scenePointerX, scenePointerY), + globalPoint, this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + elementsMap, maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.setState({ - suggestedBinding: hoveredElement, + suggestedBinding: { + element: hoveredElement, + midPoint: getSnapOutlineMidPoint( + globalPoint, + hoveredElement, + elementsMap, + this.state.zoom, + ), + }, }); } else if (this.state.suggestedBinding) { this.setState({ @@ -6591,16 +6605,19 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), maxBindingDistance_simple(this.state.zoom), ); - if ( - hit && - !isPointInElement( - pointFrom(scenePointerX, scenePointerY), - hit, - this.scene.getNonDeletedElementsMap(), - ) - ) { + const scenePointer = pointFrom(scenePointerX, scenePointerY); + const elementsMap = this.scene.getNonDeletedElementsMap(); + if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { this.setState({ - suggestedBinding: hit, + suggestedBinding: { + element: hit, + midPoint: getSnapOutlineMidPoint( + scenePointer, + hit, + elementsMap, + this.state.zoom, + ), + }, }); } } @@ -8762,7 +8779,18 @@ class App extends React.Component { bindMode: "orbit", newElement: element, startBoundElement: boundElement, - suggestedBinding: boundElement || null, + suggestedBinding: + boundElement && isBindingElement(element) + ? { + element: boundElement, + midPoint: getSnapOutlineMidPoint( + point, + boundElement, + elementsMap, + this.state.zoom, + ), + } + : null, selectedElementIds: nextSelectedElementIds, selectedLinearElement: linearElementEditor, }; diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 9ea376580b..a43a5fb600 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -135,7 +135,6 @@ describe("binding with linear elements", () => { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("184")); expect(linear.startBinding).not.toBe(null); }); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index b78fa07f84..5c81c657f3 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -82,7 +82,12 @@ import { getNormalizedZoom, } from "../scene"; -import type { AppState, BinaryFiles, LibraryItem } from "../types"; +import type { + AppState, + BinaryFiles, + LibraryItem, + NormalizedZoomValue, +} from "../types"; import type { ImportedDataState, LegacyAppState } from "./types"; type RestoredAppState = Omit< @@ -212,6 +217,7 @@ const repairBinding = ( boundElement, startOrEnd, elementsMap, + { value: 1 as NormalizedZoomValue }, ) || p; const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( element, diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 0c55dfaac4..85b4a9a369 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -5,6 +5,8 @@ import { type GlobalPoint, type LocalPoint, type Radians, + bezierEquation, + pointRotateRads, } from "@excalidraw/math"; import { @@ -21,11 +23,13 @@ import { deconstructDiamondElement, deconstructRectanguloidElement, elementCenterPoint, + getDiamondBaseCorners, FOCUS_POINT_SIZE, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, + hitElementItself, isArrowElement, isBindableElement, isElbowArrow, @@ -35,6 +39,12 @@ import { isLineElement, isTextElement, LinearElementEditor, + headingForPoint, + compareHeading, + HEADING_RIGHT, + HEADING_DOWN, + HEADING_LEFT, + HEADING_UP, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -88,8 +98,11 @@ import { strokeRectWithRotation_simple, } from "./helpers"; -import type { AppClassProperties, InteractiveCanvasAppState } from "../types"; - +import type { + AppState, + AppClassProperties, + InteractiveCanvasAppState, +} from "../types"; import type { InteractiveCanvasRenderConfig, InteractiveSceneRenderConfig, @@ -209,11 +222,14 @@ const renderSingleLinearPoint = ( const renderBindingHighlightForBindableElement_simple = ( context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: NonNullable, elementsMap: ElementsMap, appState: InteractiveCanvasAppState, + pointerCoords: GlobalPoint | null, ) => { - const enclosingFrame = element.frameId && elementsMap.get(element.frameId); + const enclosingFrame = + suggestedBinding.element.frameId && + elementsMap.get(suggestedBinding.element.frameId); if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { context.translate(enclosingFrame.x, enclosingFrame.y); @@ -236,12 +252,12 @@ const renderBindingHighlightForBindableElement_simple = ( context.translate(-enclosingFrame.x, -enclosingFrame.y); } - switch (element.type) { + switch (suggestedBinding.element.type) { case "magicframe": case "frame": context.save(); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = @@ -254,14 +270,19 @@ const renderBindingHighlightForBindableElement_simple = ( context.roundRect( 0, 0, - element.width, - element.height, + suggestedBinding.element.width, + suggestedBinding.element.height, FRAME_STYLE.radius / appState.zoom.value, ); context.stroke(); context.closePath(); } else { - context.strokeRect(0, 0, element.width, element.height); + context.strokeRect( + 0, + 0, + suggestedBinding.element.width, + suggestedBinding.element.height, + ); } context.restore(); @@ -269,30 +290,30 @@ const renderBindingHighlightForBindableElement_simple = ( default: context.save(); - const center = elementCenterPoint(element, elementsMap); + const center = elementCenterPoint(suggestedBinding.element, elementsMap); context.translate(center[0], center[1]); - context.rotate(element.angle as Radians); + context.rotate(suggestedBinding.element.angle as Radians); context.translate(-center[0], -center[1]); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = - clamp(1.75, element.strokeWidth, 4) / + clamp(1.75, suggestedBinding.element.strokeWidth, 4) / Math.max(0.25, appState.zoom.value); context.strokeStyle = appState.theme === THEME.DARK ? `rgba(3, 93, 161, 1)` : `rgba(106, 189, 252, 1)`; - switch (element.type) { + switch (suggestedBinding.element.type) { case "ellipse": context.beginPath(); context.ellipse( - element.width / 2, - element.height / 2, - element.width / 2, - element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, 0, 0, 2 * Math.PI, @@ -302,18 +323,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; case "diamond": { - const [segments, curves] = deconstructDiamondElement(element); + const [segments, curves] = deconstructDiamondElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -322,14 +345,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -338,18 +364,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; default: { - const [segments, curves] = deconstructRectanguloidElement(element); + const [segments, curves] = deconstructRectanguloidElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -358,14 +386,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -378,6 +409,133 @@ const renderBindingHighlightForBindableElement_simple = ( break; } + + if ( + isFrameLikeElement(suggestedBinding.element) || + isBindableElement(suggestedBinding.element) + ) { + // Draw midpoint indicators + const linearElement = appState.selectedLinearElement; + const arrow = + linearElement?.elementId && + LinearElementEditor.getElement(linearElement?.elementId, elementsMap); + const insideBindable = + pointerCoords && + arrow && + hitElementItself({ + point: pointerCoords, + element: suggestedBinding.element, + elementsMap, + threshold: 0, + overrideShouldTestInside: true, + }); + + if (!insideBindable || isElbowArrow(arrow)) { + context.save(); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); + + const midpointRadius = 5 / appState.zoom.value; + const center = elementCenterPoint(suggestedBinding.element, elementsMap); + + let midpoints: LocalPoint[]; + if (suggestedBinding.element.type === "diamond") { + const center = elementCenterPoint( + suggestedBinding.element, + elementsMap, + ); + midpoints = getDiamondBaseCorners(suggestedBinding.element).map( + (curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads( + point, + center, + suggestedBinding.element.angle, + ); + + return pointFrom( + rotatedPoint[0] - suggestedBinding.element.x, + rotatedPoint[1] - suggestedBinding.element.y, + ); + }, + ); + } else { + const basePoints = [ + { + x: suggestedBinding.element.width, + y: suggestedBinding.element.height / 2, + }, // RIGHT + { + x: suggestedBinding.element.width / 2, + y: suggestedBinding.element.height, + }, // BOTTOM + { x: 0, y: suggestedBinding.element.height / 2 }, // LEFT + { x: suggestedBinding.element.width / 2, y: 0 }, // TOP + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + suggestedBinding.element.x, + point.y + suggestedBinding.element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + suggestedBinding.element.angle, + ); + return pointFrom( + rotatedPoint[0] - suggestedBinding.element.x, + rotatedPoint[1] - suggestedBinding.element.y, + ); + }); + } + const highlightedPoint = + suggestedBinding.midPoint && + pointFrom( + suggestedBinding.midPoint[0] - suggestedBinding.element.x, + suggestedBinding.midPoint[1] - suggestedBinding.element.y, + ); + + const target = [HEADING_RIGHT, HEADING_DOWN, HEADING_LEFT, HEADING_UP]; + midpoints.forEach((midpoint, idx) => { + const isHighlighted = + highlightedPoint && + compareHeading( + headingForPoint( + pointRotateRads( + pointFrom( + highlightedPoint[0] + suggestedBinding.element.x, + highlightedPoint[1] + suggestedBinding.element.y, + ), + center, + suggestedBinding.element.angle as Radians, + ), + center, + ), + target[idx], + ); + + if (!isHighlighted) { + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(0, 0, 0, 0.5)` + : `rgba(65, 65, 65, 0.4)`; + context.beginPath(); + context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); + context.fill(); + } else { + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + context.beginPath(); + context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); + context.fill(); + } + }); + + context.restore(); + } + } }; const renderBindingHighlightForBindableElement_complex = ( @@ -631,6 +789,78 @@ const renderBindingHighlightForBindableElement_complex = ( context.fill(); context.restore(); + + // Draw midpoint indicators + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + const midpointRadius = 5 / appState.zoom.value; + const cutoutPadding = 5 / appState.zoom.value; + const cutoutRadius = midpointRadius + cutoutPadding; + + let midpoints; + if (element.type === "diamond") { + const [, curves] = deconstructDiamondElement(element); + const center = elementCenterPoint(element, allElementsMap); + + midpoints = curves.map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } else { + const center = elementCenterPoint(element, allElementsMap); + const basePoints = [ + { x: element.width / 2, y: 0 }, // TOP + { x: element.width, y: element.height / 2 }, // RIGHT + { x: element.width / 2, y: element.height }, // BOTTOM + { x: 0, y: element.height / 2 }, // LEFT + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + element.x, + point.y + element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + element.angle, + ); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } + + // Clear cutouts around midpoints + midpoints.forEach((midpoint) => { + context.clearRect( + midpoint.x - cutoutRadius, + midpoint.y - cutoutRadius, + cutoutRadius * 2, + cutoutRadius * 2, + ); + }); + + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity})` + : `rgba(106, 189, 252, ${opacity})`; + + midpoints.forEach((midpoint) => { + context.beginPath(); + context.arc(midpoint.x, midpoint.y, midpointRadius, 0, 2 * Math.PI); + context.fill(); + }); + + context.restore(); } return { @@ -641,17 +871,21 @@ const renderBindingHighlightForBindableElement_complex = ( const renderBindingHighlightForBindableElement = ( app: AppClassProperties, context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: AppState["suggestedBinding"], allElementsMap: NonDeletedSceneElementsMap, appState: InteractiveCanvasAppState, deltaTime: number, state?: { runtime: number }, ) => { + if (suggestedBinding === null) { + return; + } + if (getFeatureFlag("COMPLEX_BINDINGS")) { return renderBindingHighlightForBindableElement_complex( app, context, - element, + suggestedBinding.element, allElementsMap, appState, deltaTime, @@ -661,11 +895,18 @@ const renderBindingHighlightForBindableElement = ( context.save(); context.translate(appState.scrollX, appState.scrollY); + const pointerCoords = app.lastPointerMoveCoords + ? pointFrom( + app.lastPointerMoveCoords.x, + app.lastPointerMoveCoords.y, + ) + : null; renderBindingHighlightForBindableElement_simple( context, - element, + suggestedBinding, allElementsMap, appState, + pointerCoords, ); context.restore(); }; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 6b8851c281..1dd0f92cea 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -227,7 +227,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 34, + "version": 33, "width": "94.00000", "x": 0, "y": 0, @@ -334,7 +334,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "height": "74.35962", + "height": "105.58873", "points": [ [ 0, @@ -342,50 +342,50 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 88, - "74.35962", + "105.58873", ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 33, + "version": 32, "width": 88, }, "inserted": { "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.39746", - "0.60254", + 0, + "0.50010", ], "mode": "orbit", }, - "height": "1.66245", + "height": "0.00000", "points": [ [ 0, 0, ], [ - 88, - "-1.66245", + "88.00000", + "0.00000", ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 30, - "width": 88, + "version": 29, + "width": "88.00000", }, }, }, @@ -440,13 +440,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 34, + "version": 33, "width": "94.00000", "x": 0, "y": 0, }, "inserted": { - "height": "74.35962", + "height": "105.58873", "points": [ [ 0, @@ -454,21 +454,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 88, - "74.35962", + "105.58873", ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 33, + "version": 32, "width": 88, "x": 6, - "y": "46.67801", + "y": "7.09000", }, }, }, @@ -854,7 +854,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 26, + "version": 30, "width": 100, "x": 150, "y": 0, @@ -904,60 +904,60 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": { "deleted": { "endBinding": null, - "height": "5.28000", + "height": "0.00293", "points": [ [ 0, 0, ], [ - -44, - "-5.28000", + "-44.00000", + "-0.00293", ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 25, - "width": 44, - "y": "5.28000", + "version": 29, + "width": "44.00000", + "y": "0.00293", }, "inserted": { "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.41067", - "0.58933", + 0, + "0.50010", ], "mode": "orbit", }, - "height": "9.80848", + "height": 0, "points": [ [ 0, 0, ], [ - "47.06697", - "9.80848", + "6.00000", + 0, ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 24, - "width": "47.06697", - "y": "-0.87545", + "version": 27, + "width": "6.00000", + "y": "0.01000", }, }, }, @@ -1004,35 +1004,35 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 26, + "version": 30, "width": 100, "x": 150, "y": 0, }, "inserted": { - "height": "5.28000", + "height": "0.00293", "points": [ [ 0, 0, ], [ - -44, - "-5.28000", + "-44.00000", + "-0.00293", ], ], "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.63636", - "0.63636", + 1, + "0.50010", ], "mode": "orbit", }, - "version": 25, - "width": 44, + "version": 29, + "width": "44.00000", "x": 144, - "y": "5.28000", + "y": "0.00293", }, }, }, @@ -2390,7 +2390,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "398.76619", + "height": "439.13521", "id": "id4", "index": "a2", "isDeleted": false, @@ -2413,7 +2413,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 488, - "-398.76619", + "-439.13521", ], ], "roughness": 1, @@ -2434,16 +2434,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 13, "width": 488, "x": 6, - "y": "-4.89286", + "y": "-5.38920", } `; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `11`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `12`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] redo stack 1`] = `[]`; @@ -2558,7 +2558,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endBinding": { "elementId": "id1", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "398.76619", + "height": "439.13521", "index": "a2", "isDeleted": false, "link": null, @@ -2579,7 +2579,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 488, - "-398.76619", + "-439.13521", ], ], "roughness": 1, @@ -2599,14 +2599,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 13, "width": 488, "x": 6, - "y": "-4.89286", + "y": "-5.38920", }, "inserted": { "isDeleted": true, - "version": 9, + "version": 10, }, }, }, @@ -16375,7 +16375,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -16383,7 +16383,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "id": "id13", "index": "a3", "isDeleted": false, @@ -16398,7 +16398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -16409,7 +16409,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -16422,7 +16422,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", } `; @@ -16458,7 +16458,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -16467,7 +16467,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -16478,7 +16478,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -16487,7 +16487,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -16793,7 +16793,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -16801,7 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00611", + "height": "0.00936", "index": "a3", "isDeleted": false, "link": null, @@ -16814,7 +16814,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ 88, - "0.00611", + "0.00936", ], ], "roughness": 1, @@ -16825,7 +16825,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -17126,7 +17126,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -17134,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "id": "id13", "index": "a3", "isDeleted": false, @@ -17149,7 +17149,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -17160,7 +17160,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -17173,7 +17173,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", } `; @@ -17434,7 +17434,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -17442,7 +17442,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "index": "a3", "isDeleted": false, "link": null, @@ -17455,7 +17455,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -17466,7 +17466,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -17478,7 +17478,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", }, "inserted": { "isDeleted": true, @@ -17775,7 +17775,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -17783,7 +17783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "id": "id13", "index": "a3", "isDeleted": false, @@ -17798,7 +17798,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -17809,7 +17809,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -17822,7 +17822,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", } `; @@ -18083,7 +18083,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -18091,7 +18091,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "index": "a3", "isDeleted": false, "link": null, @@ -18104,7 +18104,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -18115,7 +18115,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -18127,7 +18127,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", }, "inserted": { "isDeleted": true, @@ -18422,7 +18422,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -18430,7 +18430,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "id": "id13", "index": "a3", "isDeleted": false, @@ -18445,7 +18445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -18456,7 +18456,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -18469,7 +18469,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 12, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", } `; @@ -18522,7 +18522,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -18530,7 +18530,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -18541,7 +18541,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -18816,7 +18816,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -18824,7 +18824,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00611", + "height": "0.00936", "index": "a3", "isDeleted": false, "link": null, @@ -18837,7 +18837,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ 88, - "0.00611", + "0.00936", ], ], "roughness": 1, @@ -18848,7 +18848,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -19177,7 +19177,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -19185,7 +19185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00092", + "height": "0.00004", "id": "id13", "index": "a3", "isDeleted": false, @@ -19200,7 +19200,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00092", + "0.00004", ], ], "roughness": 1, @@ -19211,7 +19211,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -19224,7 +19224,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 13, "width": "88.00000", "x": 6, - "y": "0.00849", + "y": "0.00996", } `; @@ -19288,7 +19288,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -19296,7 +19296,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -19567,7 +19567,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id2", "fixedPoint": [ - "0.50010", + 0, "0.50010", ], "mode": "orbit", @@ -19575,7 +19575,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00611", + "height": "0.00936", "index": "a3", "isDeleted": false, "link": null, @@ -19588,7 +19588,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ 88, - "0.00611", + "0.00936", ], ], "roughness": 1, @@ -19599,7 +19599,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index ed4531a9e7..5432a2d249 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -182,14 +182,14 @@ exports[`move element > rectangles with binding arrow 7`] = ` "elementId": "id3", "fixedPoint": [ "-0.02000", - "0.44667", + "0.47904", ], "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "90.00000", + "height": "90.02554", "id": "id6", "index": "a2", "isDeleted": false, @@ -204,7 +204,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` ], [ 89, - "90.00000", + "90.02554", ], ], "roughness": 1, @@ -217,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "elementId": "id0", "fixedPoint": [ "1.06000", - "0.46000", + "0.55687", ], "mode": "orbit", }, @@ -230,6 +230,6 @@ exports[`move element > rectangles with binding arrow 7`] = ` "versionNonce": 271613161, "width": 89, "x": 106, - "y": "46.00000", + "y": "55.68677", } `; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 0afaced0cb..96e948958e 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1590,7 +1590,7 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), mode: "orbit", }); expect(arrow.endBinding).toEqual({ @@ -1613,7 +1613,7 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), mode: "orbit", }); expect(arrow.endBinding).toEqual({ @@ -1636,7 +1636,7 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), mode: "orbit", }); expect(arrow.endBinding).toEqual({ @@ -1667,7 +1667,7 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), mode: "orbit", }); expect(arrow.endBinding).toEqual({ @@ -1690,7 +1690,7 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), mode: "orbit", }); expect(arrow.endBinding).toEqual({ @@ -4510,20 +4510,20 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 10); - mouse.moveTo(0, 10); + mouse.moveTo(0, 3); + mouse.moveTo(0, 3); mouse.up(); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 10); - mouse.moveTo(100, 10); + mouse.moveTo(100, 3); + mouse.moveTo(100, 3); mouse.up(); expect( (h.elements[2] as ExcalidrawElbowArrowElement).startBinding ?.fixedPoint, - ).not.toEqual([1, 0.5001]); + ).toEqual([1, 0.5001]); expect( (h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode, ).toBe("orbit"); @@ -4623,12 +4623,12 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: [0.6363636363636364, 0.6363636363636364], + fixedPoint: [1, 0.5001], mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: [0.4106696643494564, 0.5893303356505437], + fixedPoint: [0, 0.5001], mode: "orbit", }), }), @@ -4666,13 +4666,13 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 10); - mouse.upAt(0, 10); + mouse.moveTo(0, 3); + mouse.upAt(0, 3); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 10); - mouse.upAt(100, 10); + mouse.moveTo(100, 3); + mouse.upAt(100, 3); expect(h.elements).toEqual( expect.arrayContaining([ @@ -4766,13 +4766,13 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: [0.6363636363636364, 0.6363636363636364], + fixedPoint: [1, 0.5001], mode: "orbit", }), // rebound with previous rectangle endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: [0.39746300211416496, 0.6025369978858351], + fixedPoint: [0, 0.5001], mode: "orbit", }), }), @@ -5031,8 +5031,9 @@ describe("history", () => { // bind arrow to rect1 and rect2 UI.clickTool("arrow"); mouse.down(0, 0); - mouse.moveTo(50, 0); - mouse.up(50, 0); + mouse.moveTo(25, 0); + mouse.moveTo(47, 0); + mouse.up(47, 0); const arrowId = h.elements[2].id; @@ -5055,7 +5056,7 @@ describe("history", () => { }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([0, 0.5001]), }), isDeleted: true, }), diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 05e2951231..f03e7744f1 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -78,7 +78,7 @@ describe("move element", () => { // create elements const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); - const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 }); + const arrow = UI.createElement("arrow", { x: 105, y: 50, size: 88 }); act(() => { // bind line to two rectangles @@ -110,7 +110,10 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints( + [[106.00000000000001, 55.6867741935484]], + 0, + ); expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0); renderInteractiveScene.mockClear(); @@ -129,11 +132,11 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([201, 2]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( - [[79, 124.1678]], - 2, + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints( + [[106, 55.6867741935484]], + 0, ); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 25597363d6..91651c9804 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -80,6 +80,6 @@ test("unselected bound arrows update when rotating their target elements", async expect(textArrow.x).toEqual(360); expect(textArrow.y).toEqual(300); expect(textArrow.points[0]).toEqual([0, 0]); - expect(textArrow.points[1][0]).toBeCloseTo(-98.87, 0); - expect(textArrow.points[1][1]).toBeCloseTo(-123.65, 0); + expect(textArrow.points[1][0]).toBeCloseTo(-95.4635969899922, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-126.8785027399889, 0); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index eeb8aa8056..6ce4ae9267 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -47,6 +47,7 @@ import type { DurableIncrement, EphemeralIncrement, } from "@excalidraw/element"; +import type { GlobalPoint } from "@excalidraw/math"; import type { Action } from "./actions/types"; import type { Spreadsheet } from "./charts"; @@ -301,7 +302,10 @@ export interface AppState { selectionElement: NonDeletedExcalidrawElement | null; isBindingEnabled: boolean; startBoundElement: NonDeleted | null; - suggestedBinding: NonDeleted | null; + suggestedBinding: { + element: NonDeleted; + midPoint?: GlobalPoint; + } | null; frameToHighlight: NonDeleted | null; frameRendering: { enabled: boolean; @@ -759,6 +763,7 @@ export type AppClassProperties = { updateEditorAtom: App["updateEditorAtom"]; onPointerDownEmitter: App["onPointerDownEmitter"]; + lastPointerMoveCoords: App["lastPointerMoveCoords"]; bindModeHandler: App["bindModeHandler"]; };