From fbde68c8497f8c2eacc88f05b0d924d2cc31a207 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 24 Mar 2025 19:22:45 +0100 Subject: [PATCH] [skip ci] First iteration of bringing over previous changes --- packages/common/src/utils.ts | 4 + packages/element/src/binding.ts | 371 +++++++++--------- packages/element/src/elbowArrow.ts | 7 +- packages/element/src/linearElementEditor.ts | 62 ++- packages/element/src/resizeElements.ts | 8 +- packages/element/tests/binding.test.tsx | 13 +- packages/element/tests/resize.test.tsx | 11 +- .../excalidraw/actions/actionFinalize.tsx | 24 +- .../excalidraw/actions/actionProperties.tsx | 2 + packages/excalidraw/change.ts | 4 +- packages/excalidraw/components/App.tsx | 240 +++++++---- .../components/Stats/MultiDimension.tsx | 4 +- .../data/__snapshots__/transform.test.ts.snap | 28 +- packages/excalidraw/data/transform.test.ts | 6 +- .../tests/__snapshots__/history.test.tsx.snap | 256 ++++++------ .../linearElementEditor.test.tsx.snap | 11 - .../tests/__snapshots__/move.test.tsx.snap | 136 ------- packages/excalidraw/tests/history.test.tsx | 6 +- packages/excalidraw/tests/rotate.test.tsx | 2 +- 19 files changed, 599 insertions(+), 596 deletions(-) diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 7fa98eb2da..d6652077f8 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -2,6 +2,7 @@ import { average } from "@excalidraw/math"; import type { ExcalidrawBindableElement, + ExcalidrawElement, FontFamilyValues, FontString, } from "@excalidraw/element/types"; @@ -1201,3 +1202,6 @@ export const escapeDoubleQuotes = (str: string) => { export const castArray = (value: T | T[]): T[] => Array.isArray(value) ? value : [value]; + +export const toLocalPoint = (p: GlobalPoint, element: ExcalidrawElement) => + pointTranslate(p, vector(-element.x, -element.y)); diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 7a67cf0a1f..331300b6f5 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -81,11 +81,10 @@ import type { NonDeletedSceneElementsMap, ExcalidrawTextElement, ExcalidrawArrowElement, - OrderedExcalidrawElement, - ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, FixedPointBinding, + ExcalidrawElbowArrowElement, } from "./types"; export type SuggestedBinding = @@ -108,6 +107,7 @@ export const isBindingEnabled = (appState: AppState): boolean => { return appState.isBindingEnabled; }; +export const INSIDE_BINDING_BAND_PERCENT = 0.1; export const FIXED_BINDING_DISTANCE = 5; export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_OFFSET = 4; @@ -463,26 +463,6 @@ export const maybeBindLinearElement = ( } }; -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, - }; -}; - export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, @@ -493,17 +473,25 @@ export const bindLinearElement = ( return; } + const direction = startOrEnd === "start" ? -1 : 1; + const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; + const adjacentPointIndex = edgePointIndex - direction; + + const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + adjacentPointIndex, + elementsMap, + ); + let binding: PointBinding | FixedPointBinding = { elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - hoveredElement, - ), + focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), + gap: FIXED_BINDING_DISTANCE, }; if (isElbowArrow(linearElement)) { @@ -706,33 +694,6 @@ const getAllElementsAtPositionForBinding = ( return elementsAtPosition; }; -const calculateFocusAndGap = ( - linearElement: NonDeleted, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { focus: number; gap: number } => { - const direction = startOrEnd === "start" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - const adjacentPointIndex = edgePointIndex - direction; - - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - - return { - focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), - gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), - }; -}; - // Supports translating, rotating and scaling `changedElement` with bound // linear elements. // Because scaling involves moving the focus points as well, it is @@ -743,11 +704,9 @@ export const updateBoundElements = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; - changedElements?: Map; }, ) => { - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -781,22 +740,13 @@ export const updateBoundElements = ( endBounds = getElementBounds(endBindingElement, elementsMap); } - const bindings = { - startBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.startBinding, - newSize, - ), - endBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.endBinding, - newSize, - ), - }; - // `linearElement` is being moved/scaled already, just update the binding if (simultaneouslyUpdatedElementIds.has(element.id)) { - mutateElement(element, bindings, true); + mutateElement( + element, + { startBinding: element.startBinding, endBinding: element.endBinding }, + true, + ); return; } @@ -818,7 +768,9 @@ export const updateBoundElements = ( const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + bindingProp === "startBinding" + ? element.startBinding + : element.endBinding, bindableElement, elementsMap, ); @@ -848,10 +800,10 @@ export const updateBoundElements = ( updates, { ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } + ? { startBinding: element.startBinding } : {}), ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } + ? { endBinding: element.endBinding } : {}), }, elementsMap as NonDeletedSceneElementsMap, @@ -885,7 +837,6 @@ export const getHeadingForElbowArrowSnap = ( otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined | null, aabb: Bounds | undefined | null, - elementsMap: ElementsMap, origPoint: GlobalPoint, zoom?: AppState["zoom"], ): Heading => { @@ -895,12 +846,7 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding( - origPoint, - bindableElement, - elementsMap, - zoom, - ); + const distance = getDistanceForBinding(origPoint, bindableElement, zoom); if (!distance) { return vectorToHeading( @@ -920,7 +866,6 @@ export const getHeadingForElbowArrowSnap = ( const getDistanceForBinding = ( point: Readonly, bindableElement: ExcalidrawBindableElement, - elementsMap: ElementsMap, zoom?: AppState["zoom"], ) => { const distance = distanceToBindableElement(bindableElement, point); @@ -935,40 +880,47 @@ const getDistanceForBinding = ( }; export const bindPointToSnapToElementOutline = ( - arrow: ExcalidrawElbowArrowElement, + linearElement: ExcalidrawLinearElement, bindableElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): GlobalPoint => { if (isDevEnv() || isTestEnv()) { - invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); + invariant( + linearElement.points.length > 0, + "Arrow should have at least 1 point", + ); } + const elbowed = isElbowArrow(linearElement); const aabb = aabbForElement(bindableElement); - const localP = - arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; - const globalP = pointFrom( - arrow.x + localP[0], - arrow.y + localP[1], - ); - const edgePoint = isRectanguloidElement(bindableElement) - ? avoidRectangularCorner(bindableElement, globalP) - : globalP; - const elbowed = isElbowArrow(arrow); const center = getCenterForBounds(aabb); - const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2; - const adjacentPoint = pointRotateRads( - pointFrom( - arrow.x + arrow.points[adjacentPointIdx][0], - arrow.y + arrow.points[adjacentPointIdx][1], - ), - center, - arrow.angle ?? 0, + + const pointIdx = startOrEnd === "start" ? 0 : linearElement.points.length - 1; + const p = pointFrom( + linearElement.x + linearElement.points[pointIdx][0], + linearElement.y + linearElement.points[pointIdx][1], ); + const edgePoint = avoidRectangularCorner(bindableElement, p); + + const adjacentPointIdx = + startOrEnd === "start" ? 1 : linearElement.points.length - 2; + const adjacentPoint = + linearElement.points.length === 1 + ? center + : pointRotateRads( + pointFrom( + linearElement.x + linearElement.points[adjacentPointIdx][0], + linearElement.y + linearElement.points[adjacentPointIdx][1], + ), + center, + linearElement.angle ?? 0, + ); let intersection: GlobalPoint | null = null; if (elbowed) { const isHorizontal = headingIsHorizontal( - headingForPointFromElement(bindableElement, aabb, globalP), + headingForPointFromElement(bindableElement, aabb, p), ); const otherPoint = pointFrom( isHorizontal ? center[0] : edgePoint[0], @@ -1033,6 +985,28 @@ export const bindPointToSnapToElementOutline = ( ); } + const isInside = isPointInShape( + edgePoint, + getElementShape( + { + ...bindableElement, + x: + bindableElement.x + + bindableElement.width * INSIDE_BINDING_BAND_PERCENT, + y: + bindableElement.y + + bindableElement.height * INSIDE_BINDING_BAND_PERCENT, + width: bindableElement.width * (1 - INSIDE_BINDING_BAND_PERCENT * 2), + height: bindableElement.height * (1 - INSIDE_BINDING_BAND_PERCENT * 2), + } as ExcalidrawBindableElement, + elementsMap, + ), + ); + + if (!isInside) { + return intersection; + } + return edgePoint; }; @@ -1040,6 +1014,10 @@ export const avoidRectangularCorner = ( element: ExcalidrawBindableElement, p: GlobalPoint, ): GlobalPoint => { + if (!isRectanguloidElement(element)) { + return p; + } + const center = pointFrom( element.x + element.width / 2, element.y + element.height / 2, @@ -1200,6 +1178,45 @@ export const snapToMid = ( return p; }; +export const getOutlineAvoidingPoint = ( + element: NonDeleted, + coords: GlobalPoint, + pointIndex: number, + scene: Scene, + zoom: AppState["zoom"], + fallback?: GlobalPoint, +): GlobalPoint => { + const elementsMap = scene.getNonDeletedElementsMap(); + const hoveredElement = getHoveredElementForBinding( + { x: coords[0], y: coords[1] }, + scene.getNonDeletedElements(), + elementsMap, + zoom, + true, + isElbowArrow(element), + ); + + if (hoveredElement) { + const newPoints = Array.from(element.points); + newPoints[pointIndex] = pointFrom( + coords[0] - element.x, + coords[1] - element.y, + ); + + return bindPointToSnapToElementOutline( + { + ...element, + points: newPoints, + }, + hoveredElement, + pointIndex === 0 ? "start" : "end", + elementsMap, + ); + } + + return fallback ?? coords; +}; + const updateBoundPoint = ( linearElement: NonDeleted, startOrEnd: "startBinding" | "endBinding", @@ -1263,66 +1280,65 @@ const updateBoundPoint = ( let newEdgePoint: GlobalPoint; - // The linear element was not originally pointing inside the bound shape, - // we can point directly at the focus point - if (binding.gap === 0) { + // // The linear element was not originally pointing inside the bound shape, + // // we can point directly at the focus point + // if (binding.gap === 0) { + // newEdgePoint = focusPointAbsolute; + // } else { + // ... + // } + const edgePointAbsolute = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + + const center = pointFrom( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ); + const interceptorLength = + pointDistance(adjacentPoint, edgePointAbsolute) + + pointDistance(adjacentPoint, center) + + Math.max(bindableElement.width, bindableElement.height) * 2; + const intersections = [ + ...intersectElementWithLineSegment( + bindableElement, + lineSegment( + adjacentPoint, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + interceptorLength, + ), + adjacentPoint, + ), + ), + FIXED_BINDING_DISTANCE, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), + ), + // Fallback when arrow doesn't point to the shape + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + pointDistance(adjacentPoint, edgePointAbsolute), + ), + adjacentPoint, + ), + ]; + + if (intersections.length > 1) { + // The adjacent point is outside the shape (+ gap) + newEdgePoint = intersections[0]; + } else if (intersections.length === 1) { + // The adjacent point is inside the shape (+ gap) newEdgePoint = focusPointAbsolute; } else { - const edgePointAbsolute = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - - const center = pointFrom( - bindableElement.x + bindableElement.width / 2, - bindableElement.y + bindableElement.height / 2, - ); - const interceptorLength = - pointDistance(adjacentPoint, edgePointAbsolute) + - pointDistance(adjacentPoint, center) + - Math.max(bindableElement.width, bindableElement.height) * 2; - const intersections = [ - ...intersectElementWithLineSegment( - bindableElement, - lineSegment( - adjacentPoint, - pointFromVector( - vectorScale( - vectorNormalize( - vectorFromPoint(focusPointAbsolute, adjacentPoint), - ), - interceptorLength, - ), - adjacentPoint, - ), - ), - binding.gap, - ).sort( - (g, h) => - pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), - ), - // Fallback when arrow doesn't point to the shape - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), - pointDistance(adjacentPoint, edgePointAbsolute), - ), - adjacentPoint, - ), - ]; - - if (intersections.length > 1) { - // The adjacent point is outside the shape (+ gap) - newEdgePoint = intersections[0]; - } else if (intersections.length === 1) { - // The adjacent point is inside the shape (+ gap) - newEdgePoint = focusPointAbsolute; - } else { - // Shouldn't happend, but just in case - newEdgePoint = edgePointAbsolute; - } + // Shouldn't happend, but just in case + newEdgePoint = edgePointAbsolute; } return LinearElementEditor.pointFromAbsoluteCoords( @@ -1333,7 +1349,7 @@ const updateBoundPoint = ( }; export const calculateFixedPointForElbowArrowBinding = ( - linearElement: NonDeleted, + linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, @@ -1348,6 +1364,7 @@ export const calculateFixedPointForElbowArrowBinding = ( linearElement, hoveredElement, startOrEnd, + elementsMap, ); const globalMidPoint = pointFrom( bounds[0] + (bounds[2] - bounds[0]) / 2, @@ -1369,28 +1386,6 @@ export const calculateFixedPointForElbowArrowBinding = ( }; }; -const maybeCalculateNewGapWhenScaling = ( - changedElement: ExcalidrawBindableElement, - currentBinding: PointBinding | null | undefined, - newSize: { width: number; height: number } | undefined, -): PointBinding | null | undefined => { - if (currentBinding == null || newSize == null) { - return currentBinding; - } - const { width: newWidth, height: newHeight } = newSize; - const { width, height } = changedElement; - const newGap = Math.max( - 1, - Math.min( - maxBindingGap(changedElement, newWidth, newHeight), - currentBinding.gap * - (newWidth < newHeight ? newWidth / width : newHeight / height), - ), - ); - - return { ...currentBinding, gap: newGap }; -}; - const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index a70e265bc6..71552cce82 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -1254,6 +1254,7 @@ const getElbowArrowData = ( "start", arrow.startBinding?.fixedPoint, origStartGlobalPoint, + elementsMap, hoveredStartElement, options?.isDragging, ); @@ -1267,6 +1268,7 @@ const getElbowArrowData = ( "end", arrow.endBinding?.fixedPoint, origEndGlobalPoint, + elementsMap, hoveredEndElement, options?.isDragging, ); @@ -2212,6 +2214,7 @@ const getGlobalPoint = ( startOrEnd: "start" | "end", fixedPointRatio: [number, number] | undefined | null, initialPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap, element?: ExcalidrawBindableElement | null, isDragging?: boolean, ): GlobalPoint => { @@ -2221,6 +2224,7 @@ const getGlobalPoint = ( arrow, element, startOrEnd, + elementsMap, ); return snapToMid(element, snapPoint); @@ -2240,7 +2244,7 @@ const getGlobalPoint = ( distanceToBindableElement(element, fixedGlobalPoint) - FIXED_BINDING_DISTANCE, ) > 0.01 - ? bindPointToSnapToElementOutline(arrow, element, startOrEnd) + ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap) : fixedGlobalPoint; } @@ -2268,7 +2272,6 @@ const getBindPointHeading = ( number, ], ), - elementsMap, origPoint, ); diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 8a9117bf88..4637d31b4b 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -42,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import { bindOrUnbindLinearElement, getHoveredElementForBinding, + getOutlineAvoidingPoint, isBindingEnabled, } from "./binding"; import { @@ -252,27 +253,28 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, - scene: Scene, ): LinearElementEditor | null { if (!linearElementEditor) { return null; } const { elementId } = linearElementEditor; - const elementsMap = scene.getNonDeletedElementsMap(); + const elementsMap = app.scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return null; } + const elbowed = isElbowArrow(element); + if ( - isElbowArrow(element) && + elbowed && !linearElementEditor.pointerDownState.lastClickedIsEndPoint && linearElementEditor.pointerDownState.lastClickedPoint !== 0 ) { return null; } - const selectedPointsIndices = isElbowArrow(element) + const selectedPointsIndices = elbowed ? [ !!linearElementEditor.selectedPointsIndices?.includes(0) ? 0 @@ -282,7 +284,7 @@ export class LinearElementEditor { : undefined, ].filter((idx): idx is number => idx !== undefined) : linearElementEditor.selectedPointsIndices; - const lastClickedPoint = isElbowArrow(element) + const lastClickedPoint = elbowed ? linearElementEditor.pointerDownState.lastClickedPoint > 0 ? element.points.length - 1 : 0 @@ -334,19 +336,43 @@ export class LinearElementEditor { LinearElementEditor.movePoints( element, selectedPointsIndices.map((pointIndex) => { - const newPointPosition: LocalPoint = - pointIndex === lastClickedPoint - ? LinearElementEditor.createPointAt( - element, - elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - ) - : pointFrom( - element.points[pointIndex][0] + deltaX, - element.points[pointIndex][1] + deltaY, - ); + let newPointPosition = pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ); + + // Check if point dragging is happening + if (pointIndex === lastClickedPoint) { + let globalNewPointPosition = pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ); + + if ( + pointIndex === 0 || + pointIndex === element.points.length - 1 + ) { + globalNewPointPosition = getOutlineAvoidingPoint( + element, + pointFrom( + element.x + element.points[pointIndex][0] + deltaX, + element.y + element.points[pointIndex][1] + deltaY, + ), + pointIndex, + app.scene, + app.state.zoom, + ); + } + + newPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + globalNewPointPosition[0], + globalNewPointPosition[1], + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + } + return { index: pointIndex, point: newPointPosition, diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index 3ff405603a..d8528d568b 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -969,10 +969,7 @@ export const resizeSingleElement = ( mutateElement(latestElement, updates, shouldInformMutation); - updateBoundElements(latestElement, elementsMap as SceneElementsMap, { - // TODO: confirm with MARK if this actually makes sense - newSize: { width: nextWidth, height: nextHeight }, - }); + updateBoundElements(latestElement, elementsMap as SceneElementsMap); if (boundTextElement && boundTextFont != null) { mutateElement(boundTextElement, { @@ -1525,7 +1522,7 @@ export const resizeMultipleElements = ( element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { - const { width, height, angle } = update; + const { angle } = update; mutateElement(element, update, false, { // needed for the fixed binding point udpate to take effect @@ -1534,7 +1531,6 @@ export const resizeMultipleElements = ( updateBoundElements(element, elementsMap as SceneElementsMap, { simultaneouslyUpdated: elementsToUpdate, - newSize: { width, height }, }); const boundTextElement = getBoundTextElement(element, elementsMap); diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index f57d7793ae..ff3c8d10ad 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -190,7 +190,18 @@ describe("element binding", () => { // Sever connection expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.withModifierKeys({ shift: true }, () => { + // We have to move a significant distance to get out of the binding zone + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + }); expect(arrow.endBinding).toBe(null); Keyboard.keyPress(KEYS.ARROW_RIGHT); expect(arrow.endBinding).toBe(null); diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index f3804e2a22..a0e244efb3 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -195,7 +195,7 @@ describe("generic element", () => { UI.resize(rectangle, "w", [50, 0]); expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0); }); it("resizes with a label", async () => { @@ -826,8 +826,9 @@ describe("image element", () => { UI.resize(image, "nw", [50, 20]); expect(arrow.endBinding?.elementId).toEqual(image.id); - expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( - 30 + imageWidth * scale, + + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo( + 30 + imageWidth * scale + 1, 0, ); }); @@ -1033,11 +1034,11 @@ describe("multiple selection", () => { expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(143, 0); + expect(leftBoundArrow.width).toBeCloseTo(146, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.startBinding).toBeNull(); - expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); + expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5); expect(leftBoundArrow.endBinding?.elementId).toBe( leftArrowBinding.elementId, ); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9849616562..e0180ecd72 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,4 +1,4 @@ -import { pointFrom } from "@excalidraw/math"; +import { type GlobalPoint, pointFrom } from "@excalidraw/math"; import { maybeBindLinearElement, @@ -91,10 +91,26 @@ export const actionFinalize = register({ multiPointElement.type !== "freedraw" && appState.lastPointerDownWith !== "touch" ) { - const { points, lastCommittedPoint } = multiPointElement; + const { x: rx, y: ry, points, lastCommittedPoint } = multiPointElement; + const lastGlobalPoint = pointFrom( + rx + points[points.length - 1][0], + ry + points[points.length - 1][1], + ); + const hoveredElementForBinding = getHoveredElementForBinding( + { + x: lastGlobalPoint[0], + y: lastGlobalPoint[1], + }, + elements, + elementsMap, + app.state.zoom, + true, + isElbowArrow(multiPointElement), + ); if ( - !lastCommittedPoint || - points[points.length - 1] !== lastCommittedPoint + !hoveredElementForBinding && + (!lastCommittedPoint || + points[points.length - 1] !== lastCommittedPoint) ) { mutateElement(multiPointElement, { points: multiPointElement.points.slice(0, -1), diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 5a309b6775..d3a01acf3c 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1655,6 +1655,7 @@ export const actionChangeArrowType = register({ newElement, startHoveredElement, "start", + elementsMap, ) : startGlobalPoint; const finalEndPoint = endHoveredElement @@ -1662,6 +1663,7 @@ export const actionChangeArrowType = register({ newElement, endHoveredElement, "end", + elementsMap, ) : endGlobalPoint; diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts index 28eaf994fc..5b99d3a8ed 100644 --- a/packages/excalidraw/change.ts +++ b/packages/excalidraw/change.ts @@ -1508,9 +1508,7 @@ export class ElementsChange implements Change { ) { for (const element of changed.values()) { if (!element.isDeleted && isBindableElement(element)) { - updateBoundElements(element, elements, { - changedElements: changed, - }); + updateBoundElements(element, elements); } } } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 276cde0274..cdf0757f3c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -16,6 +16,7 @@ import { vectorSubtract, vectorDot, vectorNormalize, + lineSegment, } from "@excalidraw/math"; import { isPointInShape } from "@excalidraw/utils/collision"; import { getSelectionBoxShape } from "@excalidraw/utils/shape"; @@ -302,7 +303,7 @@ import { import { isNonDeletedElement } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawBindableElement, @@ -5997,9 +5998,19 @@ class App extends React.Component { { points: [ ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, + toLocalPoint( + getOutlineAvoidingPoint( + multiElement, + pointFrom(scenePointerX, scenePointerY), + multiElement.points.length - 1, + this.scene, + this.state.zoom, + pointFrom( + multiElement.x + lastCommittedX + dxFromLastCommitted, + multiElement.y + lastCommittedY + dyFromLastCommitted, + ), + ), + multiElement, ), ], }, @@ -7751,18 +7762,34 @@ class App extends React.Component { } const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const lastGlobalPoint = pointFrom( + rx + multiElement.points[multiElement.points.length - 1][0], + ry + multiElement.points[multiElement.points.length - 1][1], + ); + const hoveredElementForBinding = getHoveredElementForBinding( + { + x: lastGlobalPoint[0], + y: lastGlobalPoint[1], + }, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + this.state.zoom, + true, + isElbowArrow(multiElement), + ); // clicking inside commit zone → finalize arrow if ( - multiElement.points.length > 1 && - lastCommittedPoint && - pointDistance( - pointFrom( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - ), - lastCommittedPoint, - ) < LINE_CONFIRM_THRESHOLD + hoveredElementForBinding || + (multiElement.points.length > 1 && + lastCommittedPoint && + pointDistance( + pointFrom( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + ), + lastCommittedPoint, + ) < LINE_CONFIRM_THRESHOLD) ) { this.actionManager.executeAction(actionFinalize); return; @@ -7806,53 +7833,92 @@ class App extends React.Component { ? [currentItemStartArrowhead, currentItemEndArrowhead] : [null, null]; - const element = - elementType === "arrow" - ? newArrowElement({ - type: elementType, - x: gridX, - y: gridY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - roundness: - this.state.currentItemArrowType === ARROW_TYPE.round - ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } - : // note, roundness doesn't have any effect for elbow arrows, - // but it's best to set it to null as well - null, - startArrowhead, - endArrowhead, - locked: false, - frameId: topLayerFrame ? topLayerFrame.id : null, - elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, - fixedSegments: - this.state.currentItemArrowType === ARROW_TYPE.elbow - ? [] - : null, - }) - : newLinearElement({ - type: elementType, - x: gridX, - y: gridY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - roundness: - this.state.currentItemRoundness === "round" - ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } - : null, - locked: false, - frameId: topLayerFrame ? topLayerFrame.id : null, - }); + let element: NonDeleted; + if (elementType === "arrow") { + const arrow: Mutable> = + newArrowElement({ + type: "arrow", + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + roundness: + this.state.currentItemArrowType === ARROW_TYPE.round + ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } + : // note, roundness doesn't have any effect for elbow arrows, + // but it's best to set it to null as well + null, + startArrowhead, + endArrowhead, + locked: false, + frameId: topLayerFrame ? topLayerFrame.id : null, + elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, + fixedSegments: + this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null, + }); + + const hoveredElement = getHoveredElementForBinding( + { x: gridX, y: gridY }, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + this.state.zoom, + true, + this.state.currentItemArrowType === ARROW_TYPE.elbow, + ); + + if (hoveredElement) { + [arrow.x, arrow.y] = + intersectElementWithLineSegment( + hoveredElement, + lineSegment( + pointFrom(gridX, gridY), + pointFrom( + gridX, + hoveredElement.y + hoveredElement.height / 2, + ), + ), + 2 * FIXED_BINDING_DISTANCE, + )[0] ?? + intersectElementWithLineSegment( + hoveredElement, + lineSegment( + pointFrom(gridX, gridY), + pointFrom( + hoveredElement.x + hoveredElement.width / 2, + gridY, + ), + ), + 2 * FIXED_BINDING_DISTANCE, + )[0] ?? + pointFrom(gridX, gridY); + } + + element = arrow; + } else { + element = newLinearElement({ + type: elementType, + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + roundness: + this.state.currentItemRoundness === "round" + ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } + : null, + locked: false, + frameId: topLayerFrame ? topLayerFrame.id : null, + }); + } this.setState((prevState) => { const nextSelectedElementIds = { ...prevState.selectedElementIds, @@ -8167,12 +8233,6 @@ class App extends React.Component { this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y); } - const [gridX, gridY] = getGridPoint( - pointerCoords.x, - pointerCoords.y, - event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), - ); - // for arrows/lines, don't start dragging until a given threshold // to ensure we don't create a 2-point arrow by mistake when // user clicks mouse in a way that it moves a tiny bit (thus @@ -8273,7 +8333,6 @@ class App extends React.Component { ); }, linearElementEditor, - this.scene, ); if (newLinearElementEditor) { pointerDownState.lastCoords.x = pointerCoords.x; @@ -8660,6 +8719,11 @@ class App extends React.Component { } else if (isLinearElement(newElement)) { pointerDownState.drag.hasOccurred = true; const points = newElement.points; + const [gridX, gridY] = getGridPoint( + pointerCoords.x, + pointerCoords.y, + event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), + ); let dx = gridX - newElement.x; let dy = gridY - newElement.y; @@ -8676,7 +8740,23 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, pointFrom(dx, dy)], + points: [ + ...points, + toLocalPoint( + getOutlineAvoidingPoint( + newElement, + pointFrom(pointerCoords.x, pointerCoords.y), + newElement.points.length - 1, + this.scene, + this.state.zoom, + pointFrom( + newElement.x + dx, + newElement.y + dy, + ), + ), + newElement, + ), + ], }, false, ); @@ -8687,7 +8767,23 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points.slice(0, -1), pointFrom(dx, dy)], + points: [ + ...points.slice(0, -1), + toLocalPoint( + getOutlineAvoidingPoint( + newElement, + pointFrom(pointerCoords.x, pointerCoords.y), + newElement.points.length - 1, + this.scene, + this.state.zoom, + pointFrom( + newElement.x + dx, + newElement.y + dy, + ), + ), + newElement, + ), + ], }, false, { isDragging: true }, @@ -10725,12 +10821,6 @@ class App extends React.Component { updateBoundElements( croppingElement, this.scene.getNonDeletedElementsMap(), - { - newSize: { - width: croppingElement.width, - height: croppingElement.height, - }, - }, ); this.setState({ diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index b482611afa..6dd941e153 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -87,9 +87,7 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, elementsMap, { - newSize: { width: updates.width, height: updates.height }, - }); + updateBoundElements(latestElement, elementsMap); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { mutateElement( diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 70f8daa313..00331bd11f 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -89,7 +89,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endBinding": { "elementId": "ellipse-1", "focus": -0.007519379844961235, - "gap": 11.562288374879595, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -119,7 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startBinding": { "elementId": "id49", "focus": -0.0813953488372095, - "gap": 1, + "gap": 5, }, "strokeColor": "#1864ab", "strokeStyle": "solid", @@ -145,7 +145,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endBinding": { "elementId": "ellipse-1", "focus": 0.10666666666666667, - "gap": 3.8343264684446097, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startBinding": { "elementId": "diamond-1", "focus": 0, - "gap": 4.545343408287929, + "gap": 5, }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "endBinding": { "elementId": "text-2", "focus": 0, - "gap": 14, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -365,7 +365,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startBinding": { "elementId": "text-1", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -437,7 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "endBinding": { "elementId": "id42", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -467,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startBinding": { "elementId": "id41", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -613,7 +613,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "endBinding": { "elementId": "id46", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -643,7 +643,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startBinding": { "elementId": "id45", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "endBinding": { "elementId": "Alice", "focus": -0, - "gap": 5.299874999999986, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -1507,7 +1507,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "startBinding": { "elementId": "Bob", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "endBinding": { "elementId": "B", "focus": 0, - "gap": 14, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -1566,7 +1566,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "startBinding": { "elementId": "Bob", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0b0718e8e3..75c8b40643 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -433,7 +433,7 @@ describe("Test Transform", () => { startBinding: { elementId: rectangle.id, focus: 0, - gap: 1, + gap: FIXED_BINDING_DISTANCE, }, endBinding: { elementId: ellipse.id, @@ -518,7 +518,7 @@ describe("Test Transform", () => { startBinding: { elementId: text2.id, focus: 0, - gap: 1, + gap: FIXED_BINDING_DISTANCE, }, endBinding: { elementId: text3.id, @@ -781,7 +781,7 @@ describe("Test Transform", () => { expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", focus: -0, - gap: 14, + gap: FIXED_BINDING_DISTANCE, }); expect(rect.boundElements).toStrictEqual([ { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 9ffb97128a..2e18882ef3 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -198,7 +198,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "102.35417", + "height": "99.58947", "id": "id172", "index": "a2", "isDeleted": false, @@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "101.77517", - "102.35417", + "99.58947", + "99.58947", ], ], "roughness": 1, @@ -228,8 +228,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 40, - "width": "101.77517", - "x": "0.70711", + "width": "99.58947", + "x": 0, "y": 0, } `; @@ -295,47 +295,47 @@ History { "deleted": { "endBinding": { "elementId": "id171", - "focus": "0.00990", - "gap": 1, + "focus": "0.01099", + "gap": 5, }, - "height": "0.98586", + "height": "0.96335", "points": [ [ 0, 0, ], [ - "98.58579", - "-0.98586", + "92.92893", + "-0.96335", ], ], "startBinding": { "elementId": "id170", - "focus": "0.02970", - "gap": 1, + "focus": "0.03005", + "gap": 5, }, }, "inserted": { "endBinding": { "elementId": "id171", - "focus": "-0.02000", - "gap": 1, + "focus": "-0.02041", + "gap": 5, }, - "height": "0.00000", + "height": "0.03665", "points": [ [ 0, 0, ], [ - "98.58579", - "0.00000", + "92.92893", + "0.03665", ], ], "startBinding": { "elementId": "id170", - "focus": "0.02000", - "gap": 1, + "focus": "0.01884", + "gap": 5, }, }, }, @@ -390,43 +390,47 @@ History { "focus": 0, "gap": 1, }, - "height": "102.35417", + "height": "99.58947", "points": [ [ 0, 0, ], [ - "101.77517", - "102.35417", + "99.58947", + "99.58947", ], ], "startBinding": null, + "width": "99.58947", + "x": 0, "y": 0, }, "inserted": { "endBinding": { "elementId": "id171", - "focus": "0.00990", - "gap": 1, + "focus": "0.01099", + "gap": 5, }, - "height": "0.98586", + "height": "0.96335", "points": [ [ 0, 0, ], [ - "98.58579", - "-0.98586", + "92.92893", + "-0.96335", ], ], "startBinding": { "elementId": "id170", - "focus": "0.02970", - "gap": 1, + "focus": "0.03005", + "gap": 5, }, - "y": "0.99364", + "width": "92.92893", + "x": "3.53553", + "y": "0.96335", }, }, "id175" => Delta { @@ -566,7 +570,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -580,7 +584,7 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -804,7 +808,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 100, + "96.46447", 0, ], ], @@ -820,8 +824,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 30, - "width": 0, - "x": "149.29289", + "width": "96.46447", + "x": 150, "y": 0, } `; @@ -854,7 +858,7 @@ History { 0, ], [ - 0, + "0.00000", 0, ], ], @@ -866,7 +870,7 @@ History { 0, ], [ - 100, + "92.92893", 0, ], ], @@ -921,17 +925,19 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], "startBinding": null, + "width": "96.46447", + "x": 150, }, "inserted": { "endBinding": { "elementId": "id166", "focus": -0, - "gap": 1, + "gap": 5, }, "points": [ [ @@ -939,15 +945,17 @@ History { 0, ], [ - 0, + "0.00000", 0, ], ], "startBinding": { "elementId": "id165", "focus": 0, - "gap": 1, + "gap": 5, }, + "width": "0.00000", + "x": "146.46447", }, }, }, @@ -1074,7 +1082,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -1088,7 +1096,7 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -1241,7 +1249,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.30038", + "height": "1.71911", "id": "id178", "index": "Zz", "isDeleted": false, @@ -1255,8 +1263,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.58579", - "1.30038", + "92.92893", + "1.71911", ], ], "roughness": 1, @@ -1279,8 +1287,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -1613,7 +1621,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.30038", + "height": "1.71911", "id": "id181", "index": "a0", "isDeleted": false, @@ -1627,8 +1635,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.58579", - "1.30038", + "92.92893", + "1.71911", ], ], "roughness": 1, @@ -1651,8 +1659,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -1771,7 +1779,7 @@ History { "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "11.27227", + "height": "12.86717", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1784,8 +1792,8 @@ History { 0, ], [ - "98.58579", - "11.27227", + "92.92893", + "12.86717", ], ], "roughness": 1, @@ -1806,8 +1814,8 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, }, "inserted": { @@ -2321,12 +2329,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endBinding": { "elementId": "id185", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "374.05754", + "height": "369.21589", "id": "id186", "index": "a2", "isDeleted": false, @@ -2340,8 +2348,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "502.78936", - "-374.05754", + "496.84035", + "-369.21589", ], ], "roughness": 1, @@ -2352,7 +2360,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startBinding": { "elementId": "id184", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -2360,9 +2368,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": "502.78936", - "x": "-0.83465", - "y": "-36.58211", + "width": "496.84035", + "x": "2.18463", + "y": "-38.80748", } `; @@ -2481,7 +2489,7 @@ History { "endBinding": { "elementId": "id185", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -2499,7 +2507,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -2511,13 +2519,13 @@ History { "startBinding": { "elementId": "id184", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -15161,7 +15169,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id58", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -15180,7 +15188,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.58579", + "92.92893", 0, ], ], @@ -15192,7 +15200,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id56", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -15200,8 +15208,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -15242,7 +15250,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -15255,7 +15263,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -15532,7 +15540,7 @@ History { "endBinding": { "elementId": "id58", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -15550,7 +15558,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -15562,13 +15570,13 @@ History { "startBinding": { "elementId": "id56", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -15859,7 +15867,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id52", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -15878,7 +15886,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.58579", + "92.92893", 0, ], ], @@ -15890,7 +15898,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id50", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -15898,8 +15906,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -16152,7 +16160,7 @@ History { "endBinding": { "elementId": "id52", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -16170,7 +16178,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -16182,13 +16190,13 @@ History { "startBinding": { "elementId": "id50", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -16479,7 +16487,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id64", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -16498,7 +16506,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.58579", + "92.92893", 0, ], ], @@ -16510,7 +16518,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id62", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -16518,8 +16526,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -16772,7 +16780,7 @@ History { "endBinding": { "elementId": "id64", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -16790,7 +16798,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -16802,13 +16810,13 @@ History { "startBinding": { "elementId": "id62", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -17097,7 +17105,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id70", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -17116,7 +17124,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.58579", + "92.92893", 0, ], ], @@ -17128,7 +17136,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id68", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -17136,8 +17144,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -17193,14 +17201,14 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], "startBinding": { "elementId": "id68", "focus": 0, - "gap": 1, + "gap": 5, }, }, "inserted": { @@ -17210,7 +17218,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -17460,7 +17468,7 @@ History { "endBinding": { "elementId": "id70", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -17478,7 +17486,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -17490,13 +17498,13 @@ History { "startBinding": { "elementId": "id68", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, @@ -17811,7 +17819,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endBinding": { "elementId": "id77", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -17830,7 +17838,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.58579", + "92.92893", 0, ], ], @@ -17842,7 +17850,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startBinding": { "elementId": "id75", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -17850,8 +17858,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 11, - "width": "98.58579", - "x": "0.70711", + "width": "92.92893", + "x": "3.53553", "y": 0, } `; @@ -17913,7 +17921,7 @@ History { "endBinding": { "elementId": "id77", "focus": -0, - "gap": 1, + "gap": 5, }, "points": [ [ @@ -17921,14 +17929,14 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], "startBinding": { "elementId": "id75", "focus": 0, - "gap": 1, + "gap": 5, }, }, "inserted": { @@ -17939,7 +17947,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -18189,7 +18197,7 @@ History { "endBinding": { "elementId": "id77", "focus": -0, - "gap": 1, + "gap": 5, }, "fillStyle": "solid", "frameId": null, @@ -18207,7 +18215,7 @@ History { 0, ], [ - 100, + "96.46447", 0, ], ], @@ -18219,13 +18227,13 @@ History { "startBinding": { "elementId": "id75", "focus": 0, - "gap": 1, + "gap": 5, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": 100, + "width": "96.46447", "x": 0, "y": 0, }, diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 00857987cc..a4a60e128d 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -44,14 +44,3 @@ exports[`Test Linear Elements > Test bound text element > should resize and posi "Online whiteboard collaboration made easy" `; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = ` -"Online whiteboard -collaboration made easy" -`; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = ` -"Online whiteboard -collaboration made -easy" -`; diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 4b863d4e78..b20fee5042 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -101,139 +101,3 @@ exports[`move element > rectangle 5`] = ` "y": 40, } `; - -exports[`move element > rectangles with binding arrow 5`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id2", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": "id0", - "index": "a0", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": { - "type": 3, - }, - "seed": 1278240551, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 4, - "versionNonce": 1723083209, - "width": 100, - "x": 0, - "y": 0, -} -`; - -exports[`move element > rectangles with binding arrow 6`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id2", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 300, - "id": "id1", - "index": "a1", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": { - "type": 3, - }, - "seed": 1150084233, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 7, - "versionNonce": 745419401, - "width": 300, - "x": 201, - "y": 2, -} -`; - -exports[`move element > rectangles with binding arrow 7`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id1", - "focus": "-0.46667", - "gap": 10, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": "87.29887", - "id": "id2", - "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - "86.85786", - "87.29887", - ], - ], - "roughness": 1, - "roundness": { - "type": 2, - }, - "seed": 1604849351, - "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": "-0.60000", - "gap": 10, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 11, - "versionNonce": 1051383431, - "width": "86.85786", - "x": "107.07107", - "y": "47.07107", -} -`; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 8dd65c7a5f..f3ab4d9705 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -52,6 +52,8 @@ import * as StaticScene from "../renderer/staticScene"; import { Snapshot, CaptureUpdateAction } from "../store"; import { AppStateChange, ElementsChange } from "../change"; +import { FIXED_BINDING_DISTANCE } from "../element/binding.js"; + import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { @@ -4779,12 +4781,12 @@ describe("history", () => { startBinding: expect.objectContaining({ elementId: rect1.id, focus: 0, - gap: 1, + gap: FIXED_BINDING_DISTANCE, }), endBinding: expect.objectContaining({ elementId: rect2.id, focus: -0, - gap: 1, + gap: FIXED_BINDING_DISTANCE, }), isDeleted: true, }), diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 9687b08f25..fb0ff22e0c 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async () expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.x).toBeCloseTo(-80); expect(arrow.y).toBeCloseTo(50); - expect(arrow.width).toBeCloseTo(116.7, 1); + expect(arrow.width).toBeCloseTo(119.6, 1); expect(arrow.height).toBeCloseTo(0); });