From dc7025e33e1918f2d6baac96b09aae3e26c4bb2f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 18 Jun 2025 19:21:00 +0200 Subject: [PATCH] Fixed point binding for simple arrows Tests added Fix binding Remove unneeded params Unfinished simple arrow avoidance Fix newly created jumping arrow when gets outside Do not apply the jumping logic to elbow arrows for new elements Existing arrows now jump out Type updates to support fixed binding for simple arrows Fix crash for elbow arrws in mutateElement() Refactored simple arrow creation Updating tests No confirm threshold when inside biding range Fix multi-point arrow grid off Make elbow arrows respect grids Unbind arrow if bound and moved at shaft of arrow key Fix binding test Fix drag unbind when the bound element is in the selection Do not move mid point for simple arrows bound on both ends Add test for mobing mid points for simple arrows when bound on the same element on both ends Fix linear editor bug when both midpoint and endpoint is moved Fix all point multipoint arrow highlight and binding Arrow dragging gets a little drag to avoid accidental unbinding Fixed point binding for simple arrows when the arrow doesn't point to the element Fix binding disabled use-case triggering arrow editor Timed binding mode change for simple arrows Apply fixes Remove code to unbind on drag Update simple arrow fixed point when arrow is dragged or moved by arrow keys Binding highlight fixes Change bind mode timeout logic Fix tests Add Alt bindMode switch No dragging of arrows when bound, similar to elbow Fix timeout not taking effect immediately Bumop z-index for arrows when dragged Signed-off-by: Mark Tolmacs Only transparent bindables allow binding fallthrough Signed-off-by: Mark Tolmacs Fix lint Signed-off-by: Mark Tolmacs Fix point click array creation interaction with fixed point binding Signed-off-by: Mark Tolmacs Restrict new behavior to arrows only Signed-off-by: Mark Tolmacs Allow binding inside images Signed-off-by: Mark Tolmacs Fix already existing fixed binding retention Signed-off-by: Mark Tolmacs Refactor and implement fixed point binding for unfilled elements Restore drag Removed point binding Binding code refactor Added centered focus point Binding & focus point debug Add invariants to check binding integrity in elements Binding fixes Small refactors Completely rewritten binding Include point updates after binding update Fix point updates when endpoint dragged and opposite endpoint orbits centered focus point only for new arrows Make z-index arrow reorder on bind Turn off inside binding mode after leaving a shape Remove invariants from debug feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements test: added test file for distribute (#9754) z-index update Bind mode on precise binding Fix binding to inside element Fix initial arrow not following cursor (white dot) Fix elbow arrow Fix z-index so it works on hover Fix fixed angle orbiting Move point click arrow creation over to common strategy Signed-off-by: Mark Tolmacs Add binding strategy for drag arrow creation Fix elbow arrow Fix point handles Snap to center Fix transparent shape binding Internal arrow creation fix Fix point binding Fix selection bug Fix new arrow focus point Images now always bind inside Flashing arrow creation on binding band Add watchState debug method to window.h Fix debug canvas crash Remove non-needed bind mode Fix restore No keyboard movement when bound Add actionFinalize when arrow in edit mode Add drag to the Stats panel when bound arrow is moved Further simplify curve tracking Add typing to action register() Signed-off-by: Mark Tolmacs Fix point at finalize Signed-off-by: Mark Tolmacs Fix type errors Signed-off-by: Mark Tolmacs New arrow binding rules Signed-off-by: Mark Tolmacs Fix cyclical dep Signed-off-by: Mark Tolmacs Fix jiggly arrows Fix jiggly arrow x2 Long inside-other binding Click-click binding Fix arrows Performance [PERF] Replace in-place Jacobian derivation with analytical version Different approach to inside binding Signed-off-by: Mark Tolmacs Fixes Fix inconsistent arrow start jump out Change how images are bound to on new arrow creation Lower timeout Small insurance fix Fix curve test Signed-off-by: Mark Tolmacs No center focus point 90% inside center binding Fixing tests fix: Elbow arrow fixes fix: More arrow fixes Do not trigger arrow binding for linear elements fix: Linear elements fix: Refactor actionFinalize for linear Binding tests updated fix: Jump when cursor not moved fix: history tests Fix history snapshot Fix undo issue fix(eraser): Remove binding from the other element fix(tests): Update tests chore: Attempt filtering new set state Fix excessive history recording Signed-off-by: Mark Tolmacs --- excalidraw-app/App.tsx | 2 +- excalidraw-app/components/DebugCanvas.tsx | 187 +- packages/common/src/constants.ts | 2 + packages/common/src/index.ts | 1 + packages/common/src/utils.ts | 5 +- packages/{utils => common}/src/visualdebug.ts | 2 + packages/element/src/binding.ts | 2034 ++++++++--------- packages/element/src/collision.ts | 156 +- packages/element/src/dragElements.ts | 46 +- packages/element/src/elbowArrow.ts | 16 +- packages/element/src/flowchart.ts | 12 +- packages/element/src/linearElementEditor.ts | 459 ++-- packages/element/src/mutateElement.ts | 7 +- packages/element/src/resizeElements.ts | 43 +- packages/element/src/typeChecks.ts | 13 +- packages/element/src/types.ts | 37 +- packages/element/src/zindex.ts | 28 +- packages/element/tests/binding.test.tsx | 1105 +++++---- packages/element/tests/duplicate.test.tsx | 21 +- packages/element/tests/elbowArrow.test.tsx | 19 +- .../tests/linearElementEditor.test.tsx | 18 +- packages/element/tests/resize.test.tsx | 256 ++- packages/excalidraw/actions/actionCanvas.tsx | 9 +- .../excalidraw/actions/actionClipboard.tsx | 8 +- .../actions/actionDeleteSelected.tsx | 22 +- packages/excalidraw/actions/actionExport.tsx | 18 +- .../excalidraw/actions/actionFinalize.tsx | 188 +- .../excalidraw/actions/actionFlip.test.tsx | 22 +- packages/excalidraw/actions/actionFlip.ts | 21 +- .../excalidraw/actions/actionNavigate.tsx | 11 +- .../excalidraw/actions/actionProperties.tsx | 229 +- packages/excalidraw/actions/register.ts | 7 +- packages/excalidraw/actions/types.ts | 8 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 915 ++++++-- .../CommandPalette/CommandPalette.tsx | 2 +- .../components/CommandPalette/types.ts | 3 +- .../components/ConvertElementTypePopup.tsx | 2 +- .../excalidraw/components/Stats/Angle.tsx | 5 +- .../components/Stats/MultiDimension.tsx | 4 +- .../components/Stats/MultiPosition.tsx | 8 + .../excalidraw/components/Stats/Position.tsx | 3 + .../excalidraw/components/Stats/index.tsx | 6 +- .../components/Stats/stats.test.tsx | 24 +- packages/excalidraw/components/Stats/utils.ts | 27 +- .../components/canvases/InteractiveCanvas.tsx | 1 + .../data/__snapshots__/transform.test.ts.snap | 98 +- packages/excalidraw/data/restore.ts | 31 +- packages/excalidraw/data/transform.test.ts | 10 +- packages/excalidraw/data/transform.ts | 8 +- packages/excalidraw/global.d.ts | 5 +- .../excalidraw/renderer/interactiveScene.ts | 20 +- .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../tests/__snapshots__/history.test.tsx.snap | 1532 +++++++++---- .../tests/__snapshots__/move.test.tsx.snap | 35 +- .../multiPointCreate.test.tsx.snap | 8 +- .../regressionTests.test.tsx.snap | 212 +- packages/excalidraw/tests/history.test.tsx | 317 +-- packages/excalidraw/tests/library.test.tsx | 3 +- packages/excalidraw/tests/move.test.tsx | 33 +- .../excalidraw/tests/regressionTests.test.tsx | 1 - packages/excalidraw/tests/rotate.test.tsx | 12 +- packages/excalidraw/tests/selection.test.tsx | 7 +- packages/excalidraw/types.ts | 2 + packages/math/src/curve.ts | 175 +- packages/math/tests/curve.test.ts | 6 +- packages/utils/src/test-utils.ts | 6 +- .../tests/__snapshots__/export.test.ts.snap | 1 + 68 files changed, 5258 insertions(+), 3295 deletions(-) rename packages/{utils => common}/src/visualdebug.ts (99%) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index b972e6e5b..67536cc4d 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -662,8 +662,8 @@ const ExcalidrawWrapper = () => { debugRenderer( debugCanvasRef.current, appState, + elements, window.devicePixelRatio, - () => forceRefresh((prev) => !prev), ); } }; diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 71e3885b1..327078351 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -8,9 +8,15 @@ import { getNormalizedCanvasDimensions, } from "@excalidraw/excalidraw/renderer/helpers"; import { type AppState } from "@excalidraw/excalidraw/types"; -import { throttleRAF } from "@excalidraw/common"; +import { arrayToMap, invariant, throttleRAF } from "@excalidraw/common"; import { useCallback } from "react"; +import { + getGlobalFixedPointForBindableElement, + isArrowElement, + isBindableElement, +} from "@excalidraw/element"; + import { isLineSegment, type GlobalPoint, @@ -21,8 +27,14 @@ import { isCurve } from "@excalidraw/math/curve"; import React from "react"; import type { Curve } from "@excalidraw/math"; - -import type { DebugElement } from "@excalidraw/utils/visualdebug"; +import type { DebugElement } from "@excalidraw/common"; +import type { + ElementsMap, + ExcalidrawArrowElement, + ExcalidrawBindableElement, + FixedPointBinding, + OrderedExcalidrawElement, +} from "@excalidraw/element/types"; import { STORAGE_KEYS } from "../app_constants"; @@ -75,6 +87,168 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { context.save(); }; +const _renderBinding = ( + context: CanvasRenderingContext2D, + binding: FixedPointBinding, + elementsMap: ElementsMap, + zoom: number, + width: number, + height: number, + color: string, +) => { + if (!binding.fixedPoint) { + console.warn("Binding must have a fixedPoint"); + return; + } + + const bindable = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, + ); + + context.save(); + context.strokeStyle = color; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x * zoom, y * zoom); + context.bezierCurveTo( + x * zoom - width, + y * zoom - height, + x * zoom - width, + y * zoom + height, + x * zoom, + y * zoom, + ); + context.stroke(); + context.restore(); +}; + +const _renderBindableBinding = ( + binding: FixedPointBinding, + context: CanvasRenderingContext2D, + elementsMap: ElementsMap, + zoom: number, + width: number, + height: number, + color: string, +) => { + const bindable = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + if (!binding.fixedPoint) { + console.warn("Binding must have a fixedPoint"); + return; + } + + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, + ); + + context.save(); + context.strokeStyle = color; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x * zoom, y * zoom); + context.bezierCurveTo( + x * zoom + width, + y * zoom + height, + x * zoom + width, + y * zoom - height, + x * zoom, + y * zoom, + ); + context.stroke(); + context.restore(); +}; + +const renderBindings = ( + context: CanvasRenderingContext2D, + elements: readonly OrderedExcalidrawElement[], + zoom: number, +) => { + const elementsMap = arrayToMap(elements); + const dim = 16; + elements.forEach((element) => { + if (element.isDeleted) { + return; + } + + if (isArrowElement(element)) { + if (element.startBinding) { + invariant( + elementsMap + .get(element.startBinding.elementId) + ?.boundElements?.find((e) => e.id === element.id), + "Missing record in boundElements for arrow", + ); + + _renderBinding( + context, + element.startBinding, + elementsMap, + zoom, + dim, + dim, + "red", + ); + } + + if (element.endBinding) { + _renderBinding( + context, + element.endBinding, + elementsMap, + zoom, + dim, + dim, + "red", + ); + } + } + + if (isBindableElement(element) && element.boundElements?.length) { + element.boundElements.forEach((boundElement) => { + if (boundElement.type !== "arrow") { + return; + } + + const arrow = elementsMap.get( + boundElement.id, + ) as ExcalidrawArrowElement; + + if (arrow && arrow.startBinding?.elementId === element.id) { + _renderBindableBinding( + arrow.startBinding, + context, + elementsMap, + zoom, + dim, + dim, + "green", + ); + } + if (arrow && arrow.endBinding?.elementId === element.id) { + _renderBindableBinding( + arrow.endBinding, + context, + elementsMap, + zoom, + dim, + dim, + "green", + ); + } + }); + } + }); +}; + const render = ( frame: DebugElement[], context: CanvasRenderingContext2D, @@ -107,8 +281,8 @@ const render = ( const _debugRenderer = ( canvas: HTMLCanvasElement, appState: AppState, + elements: readonly OrderedExcalidrawElement[], scale: number, - refresh: () => void, ) => { const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( canvas, @@ -131,6 +305,7 @@ const _debugRenderer = ( ); renderOrigin(context, appState.zoom.value); + renderBindings(context, elements, appState.zoom.value); if ( window.visualDebug?.currentFrame && @@ -182,10 +357,10 @@ export const debugRenderer = throttleRAF( ( canvas: HTMLCanvasElement, appState: AppState, + elements: readonly OrderedExcalidrawElement[], scale: number, - refresh: () => void, ) => { - _debugRenderer(canvas, appState, scale, refresh); + _debugRenderer(canvas, appState, elements, scale); }, { trailing: true }, ); diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index aef2fda9f..dcec53190 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -524,3 +524,5 @@ export enum UserIdleState { export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const DOUBLE_TAP_POSITION_THRESHOLD = 35; + +export const BIND_MODE_TIMEOUT = 800; // ms diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 79f243f4f..9e28ce413 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -10,3 +10,4 @@ export * from "./random"; export * from "./url"; export * from "./utils"; export * from "./emitter"; +export * from "./visualdebug"; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 105496065..35638fc23 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,4 +1,5 @@ import { average } from "@excalidraw/math"; +import { isImageElement } from "@excalidraw/element"; import type { ExcalidrawBindableElement, @@ -566,8 +567,8 @@ export const isTransparent = (color: string) => { ); }; -export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) => - el.fillStyle !== "solid" || isTransparent(el.backgroundColor); +export const isAlwaysInsideBinding = (element: ExcalidrawBindableElement) => + isImageElement(element); export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] diff --git a/packages/utils/src/visualdebug.ts b/packages/common/src/visualdebug.ts similarity index 99% rename from packages/utils/src/visualdebug.ts rename to packages/common/src/visualdebug.ts index 961fa919f..9cdbbd7e9 100644 --- a/packages/utils/src/visualdebug.ts +++ b/packages/common/src/visualdebug.ts @@ -63,6 +63,8 @@ export const debugDrawLine = ( ); }; +export const testDebug = () => {}; + export const debugDrawPoint = ( p: GlobalPoint, opts?: { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 9d97801f2..117fd2220 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,11 +1,9 @@ import { KEYS, arrayToMap, - isBindingFallthroughEnabled, - tupleToCoors, invariant, - isDevEnv, - isTestEnv, + isAlwaysInsideBinding, + tupleToCoors, } from "@excalidraw/common"; import { @@ -20,13 +18,10 @@ import { pointFromVector, vectorScale, vectorNormalize, - vectorCross, - pointsEqual, - lineSegmentIntersectionPoints, PRECISION, } from "@excalidraw/math"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -37,7 +32,14 @@ import { getCenterForBounds, getElementBounds, } from "./bounds"; -import { intersectElementWithLineSegment } from "./collision"; +import { + bindingBorderTest, + getHoveredElementForBinding, + getHoveredElementForBindingAndIfItsPrecise, + hitElementItself, + intersectElementWithLineSegment, + maxBindingDistanceFromOutline, +} from "./collision"; import { distanceToElement } from "./distance"; import { headingForPointFromElement, @@ -53,9 +55,6 @@ import { isBindableElement, isBoundToContainer, isElbowArrow, - isFixedPointBinding, - isFrameLikeElement, - isLinearElement, isRectanguloidElement, isTextElement, } from "./typeChecks"; @@ -71,8 +70,6 @@ import type { ExcalidrawBindableElement, ExcalidrawElement, NonDeleted, - ExcalidrawLinearElement, - PointBinding, NonDeletedExcalidrawElement, ElementsMap, NonDeletedSceneElementsMap, @@ -82,6 +79,8 @@ import type { FixedPoint, FixedPointBinding, PointsPositionUpdates, + Ordered, + BindMode, } from "./types"; export type SuggestedBinding = @@ -89,11 +88,34 @@ export type SuggestedBinding = | SuggestedPointBinding; export type SuggestedPointBinding = [ - NonDeleted, + NonDeleted, "start" | "end" | "both", NonDeleted, ]; +export type BindingStrategy = + // Create a new binding with this mode + | { + mode: BindMode; + element: NonDeleted; + focusPoint: GlobalPoint; + } + // Break the binding + | { + mode: null; + element?: undefined; + focusPoint?: undefined; + } + // Keep the existing binding + | { + mode: undefined; + element?: undefined; + focusPoint?: undefined; + }; + +export const FIXED_BINDING_DISTANCE = 5; +export const BINDING_HIGHLIGHT_THICKNESS = 10; + export const shouldEnableBindingForPointerEvent = ( event: React.PointerEvent, ) => { @@ -104,125 +126,92 @@ export const isBindingEnabled = (appState: AppState): boolean => { return appState.isBindingEnabled; }; -export const FIXED_BINDING_DISTANCE = 5; -export const BINDING_HIGHLIGHT_THICKNESS = 10; - -const getNonDeletedElements = ( +export const bindOrUnbindBindingElement = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, scene: Scene, - ids: readonly ExcalidrawElement["id"][], -): NonDeleted[] => { - const result: NonDeleted[] = []; - ids.forEach((id) => { - const element = scene.getNonDeletedElement(id); - if (element != null) { - result.push(element); + appState: AppState, + opts?: { + newArrow: boolean; + }, +) => { + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + arrow, + draggingPoints, + scene.getNonDeletedElementsMap(), + scene.getNonDeletedElements(), + appState, + { + ...opts, + }, + ); + + bindOrUnbindBindingElementEdge(arrow, start, "start", scene); + bindOrUnbindBindingElementEdge(arrow, end, "end", scene); + if (!isElbowArrow(arrow) && (start.focusPoint || end.focusPoint)) { + // If the strategy dictates a focus point override, then + // update the arrow points to point to the focus point. + const updates: PointsPositionUpdates = new Map(); + + if (start.focusPoint) { + updates.set(0, { + point: + updateBoundPoint( + arrow, + "startBinding", + arrow.startBinding, + start.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[0], + }); } - }); - return result; + + if (end.focusPoint) { + updates.set(arrow.points.length - 1, { + point: + updateBoundPoint( + arrow, + "endBinding", + arrow.endBinding, + end.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[arrow.points.length - 1], + }); + } + + LinearElementEditor.movePoints(arrow, scene, updates); + } + + return { start, end }; }; -export const bindOrUnbindLinearElement = ( - linearElement: NonDeleted, - startBindingElement: ExcalidrawBindableElement | null | "keep", - endBindingElement: ExcalidrawBindableElement | null | "keep", - scene: Scene, -): void => { - const elementsMap = scene.getNonDeletedElementsMap(); - const boundToElementIds: Set = new Set(); - const unboundFromElementIds: Set = new Set(); - bindOrUnbindLinearElementEdge( - linearElement, - startBindingElement, - endBindingElement, - "start", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - bindOrUnbindLinearElementEdge( - linearElement, - endBindingElement, - startBindingElement, - "end", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - - const onlyUnbound = Array.from(unboundFromElementIds).filter( - (id) => !boundToElementIds.has(id), - ); - - getNonDeletedElements(scene, onlyUnbound).forEach((element) => { - scene.mutateElement(element, { - boundElements: element.boundElements?.filter( - (element) => - element.type !== "arrow" || element.id !== linearElement.id, - ), - }); - }); -}; - -const bindOrUnbindLinearElementEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement | null | "keep", - otherEdgeBindableElement: ExcalidrawBindableElement | null | "keep", +const bindOrUnbindBindingElementEdge = ( + arrow: NonDeleted, + { mode, element, focusPoint }: BindingStrategy, startOrEnd: "start" | "end", - // Is mutated - boundToElementIds: Set, - // Is mutated - unboundFromElementIds: Set, scene: Scene, - elementsMap: ElementsMap, ): void => { - // "keep" is for method chaining convenience, a "no-op", so just bail out - if (bindableElement === "keep") { - return; - } - - // null means break the bind, so nothing to consider here - if (bindableElement === null) { - const unbound = unbindLinearElement(linearElement, startOrEnd, scene); - if (unbound != null) { - unboundFromElementIds.add(unbound); - } - return; - } - - // While complext arrows can do anything, simple arrow with both ends trying - // to bind to the same bindable should not be allowed, start binding takes - // precedence - if (isLinearElementSimple(linearElement)) { - if ( - otherEdgeBindableElement == null || - (otherEdgeBindableElement === "keep" - ? // TODO: Refactor - Needlessly complex - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - bindableElement, - startOrEnd, - ) - : startOrEnd === "start" || - otherEdgeBindableElement.id !== bindableElement.id) - ) { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); - } - } else { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); + if (mode === null) { + // null means break the binding + unbindBindingElement(arrow, startOrEnd, scene); + } else if (mode !== undefined) { + bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint); } }; -const getOriginalBindingsIfStillCloseToArrowEnds = ( - linearElement: NonDeleted, +const getOriginalBindingsIfStillCloseToBindingEnds = ( + linearElement: NonDeleted, elementsMap: NonDeletedSceneElementsMap, zoom?: AppState["zoom"], ): (NonDeleted | null)[] => (["start", "end"] as const).map((edge) => { - const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); + const coors = tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edge === "start" ? 0 : -1, + elementsMap, + ), + ); const elementId = edge === "start" ? linearElement.startBinding?.elementId @@ -231,7 +220,12 @@ const getOriginalBindingsIfStillCloseToArrowEnds = ( const element = elementsMap.get(elementId); if ( isBindableElement(element) && - bindingBorderTest(element, coors, elementsMap, zoom) + bindingBorderTest( + element, + pointFrom(coors.x, coors.y), + elementsMap, + zoom, + ) ) { return element; } @@ -240,119 +234,518 @@ const getOriginalBindingsIfStillCloseToArrowEnds = ( return null; }); -const getBindingStrategyForDraggingArrowEndpoints = ( - selectedElement: NonDeleted, - isBindingEnabled: boolean, - draggingPoints: readonly number[], - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], -): (NonDeleted | null | "keep")[] => { - const startIdx = 0; - const endIdx = selectedElement.points.length - 1; - const startDragged = draggingPoints.findIndex((i) => i === startIdx) > -1; - const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; - const start = startDragged - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "start", +export const getStartGlobalEndLocalPointsForSimpleArrowBinding = ( + arrow: NonDeleted, + start: BindingStrategy, + end: BindingStrategy, + startPoint: GlobalPoint, + endPoint: LocalPoint, + elementsMap: ElementsMap, +): [GlobalPoint, LocalPoint] => { + let startGlobalPoint = startPoint; + let endLocalPoint = endPoint; + if (start.mode) { + const newStartLocalPoint = updateBoundPoint( + arrow, + "startBinding", + start.mode + ? { + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + start.element, + "start", + elementsMap, + start.focusPoint, + ), + elementId: start.element.id, + mode: start.mode, + } + : null, + start.element, + elementsMap, + ); + startGlobalPoint = newStartLocalPoint + ? LinearElementEditor.getPointGlobalCoordinates( + arrow, + newStartLocalPoint, elementsMap, - elements, - zoom, ) - : null // If binding is disabled and start is dragged, break all binds - : "keep"; - const end = endDragged - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "end", - elementsMap, - elements, - zoom, - ) - : null // If binding is disabled and end is dragged, break all binds - : "keep"; - - return [start, end]; -}; - -const getBindingStrategyForDraggingArrowOrJoints = ( - selectedElement: NonDeleted, - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - isBindingEnabled: boolean, - zoom?: AppState["zoom"], -): (NonDeleted | null | "keep")[] => { - // Elbow arrows don't bind when dragged as a whole - if (isElbowArrow(selectedElement)) { - return ["keep", "keep"]; + : startGlobalPoint; } - const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( - selectedElement, - elementsMap, - zoom, - ); - const start = startIsClose - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "start", - elementsMap, - elements, - zoom, - ) - : null - : null; - const end = endIsClose - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "end", - elementsMap, - elements, - zoom, - ) - : null - : null; + if (end.mode) { + const newEndLocalPoint = updateBoundPoint( + arrow, + "endBinding", + end.mode + ? { + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + end.element, + "end", + elementsMap, + end.focusPoint, + ), + elementId: end.element.id, + mode: end.mode, + } + : null, + end.element, + elementsMap, + ); + endLocalPoint = newEndLocalPoint ?? endLocalPoint; + } - return [start, end]; + return [ + startGlobalPoint, + pointFrom( + endLocalPoint[0] - (startGlobalPoint[0] - arrow.x), + endLocalPoint[1] - (startGlobalPoint[1] - arrow.y), + ), + ]; }; -export const bindOrUnbindLinearElements = ( - selectedElements: NonDeleted[], - isBindingEnabled: boolean, - draggingPoints: readonly number[] | null, - scene: Scene, - zoom?: AppState["zoom"], -): void => { - selectedElements.forEach((selectedElement) => { - const [start, end] = draggingPoints?.length - ? // The arrow edge points are dragged (i.e. start, end) - getBindingStrategyForDraggingArrowEndpoints( - selectedElement, - isBindingEnabled, - draggingPoints ?? [], - scene.getNonDeletedElementsMap(), - scene.getNonDeletedElements(), - zoom, - ) - : // The arrow itself (the shaft) or the inner joins are dragged - getBindingStrategyForDraggingArrowOrJoints( - selectedElement, - scene.getNonDeletedElementsMap(), - scene.getNonDeletedElements(), - isBindingEnabled, - zoom, - ); +const bindingStrategyForNewSimpleArrowEndpointDragging = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + startDragged: boolean, + endDragged: boolean, + startIdx: number, + endIdx: number, + appState: AppState, + globalBindMode?: AppState["bindMode"], +): { + start: BindingStrategy; + end: BindingStrategy; +} => { + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; - bindOrUnbindLinearElement(selectedElement, start, end, scene); + const point = LinearElementEditor.getPointGlobalCoordinates( + arrow, + draggingPoints.get(startDragged ? startIdx : endIdx)!.point, + elementsMap, + ); + const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise( + point, + elements, + elementsMap, + appState.zoom, + true, + ); + + // With new arrows this handles the binding at arrow creation + if (startDragged) { + if (hovered) { + if (hit) { + start = { + element: hovered, + mode: "inside", + focusPoint: point, + }; + } else { + start = { + element: hovered, + mode: "orbit", + focusPoint: point, + }; + } + } else { + start = { mode: null }; + } + + return { start, end }; + } + + // With new arrows it represents the continuous dragging of the end point + if (endDragged) { + const arrowOriginalStartPoint = + appState?.selectedLinearElement?.pointerDownState.arrowOriginalStartPoint; + + // Inside -> inside binding + if (hovered && hit && arrow.startBinding?.elementId === hovered.id) { + const center = pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ); + + return { + start: { + mode: "inside", + element: hovered, + focusPoint: arrowOriginalStartPoint ?? center, + }, + end: { mode: "inside", element: hovered, focusPoint: point }, + }; + } + + // Inside -> orbit binding + if (hovered && !hit && arrow.startBinding?.elementId === hovered.id) { + const center = pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ); + + return { + start: { + mode: globalBindMode === "inside" ? "inside" : "orbit", + element: hovered, + focusPoint: arrowOriginalStartPoint ?? center, + }, + end: { mode: null }, + }; + } + + // Inside -> outside binding + if (arrow.startBinding && arrow.startBinding.elementId !== hovered?.id) { + const otherElement = elementsMap.get( + arrow.startBinding.elementId, + ) as ExcalidrawBindableElement; + invariant(otherElement, "Other element must be in the elements map"); + + const otherIsInsideBinding = + !!appState.selectedLinearElement?.pointerDownState.arrowStartIsInside; + + const other: BindingStrategy = { + mode: otherIsInsideBinding ? "inside" : "orbit", + element: otherElement, + focusPoint: snapToCenter( + otherElement, + elementsMap, + arrowOriginalStartPoint ?? pointFrom(arrow.x, arrow.y), + ), + }; + + // We are hovering another element with the end point + let current: BindingStrategy; + if (hovered) { + const isInsideBinding = globalBindMode === "inside"; + current = { + mode: isInsideBinding ? "inside" : "orbit", + element: hovered, + focusPoint: snapToCenter(hovered, elementsMap, point), + }; + } else { + current = { mode: null }; + } + + return { + start: other, + end: current, + }; + } + + // No start binding + if (!arrow.startBinding) { + if (hovered) { + const isInsideBinding = + globalBindMode === "inside" || isAlwaysInsideBinding(hovered); + + end = { + mode: isInsideBinding ? "inside" : "orbit", + element: hovered, + focusPoint: point, + }; + } else { + end = { mode: null }; + } + + return { start, end }; + } + } + + invariant(false, "New arrow creation should not reach here"); + + return { start, end }; +}; + +const bindingStrategyForSimpleArrowEndpointDragging = ( + point: GlobalPoint, + oppositeBinding: FixedPointBinding | null, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + zoom: AppState["zoom"], + globalBindMode?: AppState["bindMode"], + opts?: { + newArrow?: boolean; + appState?: AppState; + }, +): { current: BindingStrategy; other: BindingStrategy } => { + let current: BindingStrategy = { mode: undefined }; + let other: BindingStrategy = { mode: undefined }; + + const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise( + point, + elements, + elementsMap, + zoom, + true, + ); + + // If the global bind mode is in free binding mode, just bind + // where the pointer is and keep the other end intact + if ( + globalBindMode === "inside" || + (hovered && isAlwaysInsideBinding(hovered)) + ) { + current = hovered + ? { + element: hovered, + focusPoint: point, + mode: "inside", + } + : { mode: undefined }; + + return { current, other }; + } + + // Dragged point is outside of any bindable element + // so we break any existing binding + if (!hovered) { + return { current: { mode: null }, other }; + } + + // Dragged point is on the binding gap of a bindable element + if (!hit) { + // If the opposite binding (if exists) is on the same element + if (oppositeBinding) { + if (oppositeBinding.elementId === hovered.id) { + return { current: { mode: null }, other }; + } + // The opposite binding is on a different element + // eslint-disable-next-line no-else-return + else { + current = { + element: hovered, + mode: "orbit", + focusPoint: opts?.newArrow + ? pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ) + : point, + }; + + return { current, other }; + } + } + + // No opposite binding or the opposite binding is on a different element + current = { element: hovered, mode: "orbit", focusPoint: point }; + } + // The dragged point is inside the hovered bindable element + else { + // The opposite binding is on the same element + // eslint-disable-next-line no-lonely-if + if (oppositeBinding) { + if (oppositeBinding.elementId === hovered.id) { + // The opposite binding is on the binding gap of the same element + if (oppositeBinding.mode !== "inside") { + current = { element: hovered, mode: "orbit", focusPoint: point }; + other = { mode: null }; + + return { current, other }; + } + // The opposite binding is inside the same element + // eslint-disable-next-line no-else-return + else { + current = { element: hovered, mode: "inside", focusPoint: point }; + + return { current, other }; + } + } + // The opposite binding is on a different element + // eslint-disable-next-line no-else-return + else { + current = { + element: hovered, + mode: "orbit", + focusPoint: opts?.newArrow + ? pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ) + : point, + }; + + return { current, other }; + } + } + // The opposite binding is on a different element or no binding + else { + current = { + element: hovered, + mode: "orbit", + focusPoint: opts?.newArrow + ? pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ) + : point, + }; + } + } + + // Must return as only one endpoint is dragged, therefore + // the end binding strategy might accidentally gets overriden + return { current, other }; +}; + +export const getBindingStrategyForDraggingBindingElementEndpoints = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + const globalBindMode = appState.bindMode || "orbit"; + const startIdx = 0; + const endIdx = arrow.points.length - 1; + const startDragged = draggingPoints.has(startIdx); + const endDragged = draggingPoints.has(endIdx); + + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + invariant( + arrow.points.length > 1, + "Do not attempt to bind linear elements with a single point", + ); + + // If none of the ends are dragged, we don't change anything + if (!startDragged && !endDragged) { + return { start, end }; + } + + // If both ends are dragged, we don't bind to anything + // and break existing bindings + if (startDragged && endDragged) { + return { start: { mode: null }, end: { mode: null } }; + } + + // If binding is disabled and an endpoint is dragged, + // we actively break the end binding + if (!isBindingEnabled(appState)) { + start = startDragged ? { mode: null } : start; + end = endDragged ? { mode: null } : end; + + return { start, end }; + } + + // Handle simpler elbow arrow binding, which always binds as orbiting the + // element, even if the mouse cursor is over the element itself + if (isElbowArrow(arrow)) { + const p = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startDragged ? startIdx : endIdx, + elementsMap, + ); + const hoveredElement = getHoveredElementForBinding( + p, + elements, + elementsMap, + appState.zoom, + ); + const current: BindingStrategy = hoveredElement + ? { + element: hoveredElement, + mode: "orbit", + focusPoint: p, + } + : { mode: null }; + const other: BindingStrategy = { mode: undefined }; + + return { + start: startDragged ? current : other, + end: startDragged ? other : current, + }; + } + + // Handle new arrow creation separately, as it is special + if (opts?.newArrow) { + return bindingStrategyForNewSimpleArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + startDragged, + endDragged, + startIdx, + endIdx, + appState, + globalBindMode, + ); + } + + // Only the start point is dragged + if (startDragged) { + const localPoint = draggingPoints.get(startIdx)?.point; + invariant(localPoint, "Local point must be defined for start dragging"); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + + const { current, other } = bindingStrategyForSimpleArrowEndpointDragging( + globalPoint, + arrow.endBinding, + elementsMap, + elements, + appState.zoom, + globalBindMode, + opts, + ); + + return { start: current, end: other }; + } + + // Only the end point is dragged + if (endDragged) { + const localPoint = draggingPoints.get(endIdx)?.point; + invariant(localPoint, "Local point must be defined for end dragging"); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + const { current, other } = bindingStrategyForSimpleArrowEndpointDragging( + globalPoint, + arrow.startBinding, + elementsMap, + elements, + appState.zoom, + globalBindMode, + opts, + ); + + return { start: other, end: current }; + } + + return { start, end }; +}; + +export const bindOrUnbindBindingElements = ( + selectedArrows: NonDeleted[], + scene: Scene, + appState: AppState, +): void => { + selectedArrows.forEach((arrow) => { + bindOrUnbindBindingElement( + arrow, + new Map(), // No dragging points in this case + scene, + appState, + ); }); }; -export const getSuggestedBindingsForArrows = ( +export const getSuggestedBindingsForBindingElements = ( selectedElements: NonDeleted[], elementsMap: NonDeletedSceneElementsMap, zoom: AppState["zoom"], @@ -364,9 +757,13 @@ export const getSuggestedBindingsForArrows = ( return ( selectedElements - .filter(isLinearElement) + .filter(isArrowElement) .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), + getOriginalBindingsIfStillCloseToBindingEnds( + element, + elementsMap, + zoom, + ), ) .filter( (element): element is NonDeleted => @@ -384,353 +781,146 @@ export const getSuggestedBindingsForArrows = ( ); }; -export const maybeSuggestBindingsForLinearElementAtCoords = ( - linearElement: NonDeleted, - /** scene coords */ - pointerCoords: { - x: number; - y: number; - }[], +export const maybeSuggestBindingsForBindingElementAtCoords = ( + linearElement: NonDeleted, + startOrEndOrBoth: "start" | "end" | "both", scene: Scene, zoom: AppState["zoom"], - // During line creation the start binding hasn't been written yet - // into `linearElement` - oppositeBindingBoundElement?: ExcalidrawBindableElement | null, -): ExcalidrawBindableElement[] => - Array.from( - pointerCoords.reduce( - (acc: Set>, coords) => { - const hoveredBindableElement = getHoveredElementForBinding( - coords, - scene.getNonDeletedElements(), - scene.getNonDeletedElementsMap(), - zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); - - if ( - hoveredBindableElement != null && - !isLinearElementSimpleAndAlreadyBound( - linearElement, - oppositeBindingBoundElement?.id, - hoveredBindableElement, - ) - ) { - acc.add(hoveredBindableElement); - } - - return acc; - }, - new Set() as Set>, - ), +): ExcalidrawBindableElement[] => { + const startCoords = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + 0, + scene.getNonDeletedElementsMap(), + ); + const endCoords = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + -1, + scene.getNonDeletedElementsMap(), + ); + const startHovered = getHoveredElementForBinding( + startCoords, + scene.getNonDeletedElements(), + scene.getNonDeletedElementsMap(), + zoom, + ); + const endHovered = getHoveredElementForBinding( + endCoords, + scene.getNonDeletedElements(), + scene.getNonDeletedElementsMap(), + zoom, ); -export const maybeBindLinearElement = ( - linearElement: NonDeleted, - appState: AppState, - pointerCoords: { x: number; y: number }, - scene: Scene, -): void => { - const elements = scene.getNonDeletedElements(); - const elementsMap = scene.getNonDeletedElementsMap(); + const suggestedBindings = []; - if (appState.startBoundElement != null) { - bindLinearElement( - linearElement, - appState.startBoundElement, - "start", - scene, - ); - } - - const hoveredElement = getHoveredElementForBinding( - pointerCoords, - elements, - elementsMap, - appState.zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); - - if (hoveredElement !== null) { - if ( - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - hoveredElement, - "end", - ) - ) { - bindLinearElement(linearElement, hoveredElement, "end", scene); + if (startHovered != null && startHovered.id === endHovered?.id) { + const hitStart = hitElementItself({ + element: startHovered, + elementsMap: scene.getNonDeletedElementsMap(), + point: pointFrom(startCoords[0], startCoords[1]), + threshold: 0, + overrideShouldTestInside: true, + }); + const hitEnd = hitElementItself({ + element: endHovered, + elementsMap: scene.getNonDeletedElementsMap(), + point: pointFrom(endCoords[0], endCoords[1]), + threshold: 0, + overrideShouldTestInside: true, + }); + if (hitStart && hitEnd) { + suggestedBindings.push(startHovered); } + } else if (startOrEndOrBoth === "start" && startHovered != null) { + suggestedBindings.push(startHovered); + } else if (startOrEndOrBoth === "end" && endHovered != null) { + suggestedBindings.push(endHovered); } + + return suggestedBindings; }; -const normalizePointBinding = ( - binding: { focus: number; gap: number }, - hoveredElement: ExcalidrawBindableElement, -) => ({ - ...binding, - gap: Math.min( - binding.gap, - maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height), - ), -}); - -export const bindLinearElement = ( - linearElement: NonDeleted, +export const bindBindingElement = ( + arrow: NonDeleted, hoveredElement: ExcalidrawBindableElement, + mode: BindMode, startOrEnd: "start" | "end", scene: Scene, + focusPoint?: GlobalPoint, ): void => { - if (!isArrowElement(linearElement)) { - return; - } + const elementsMap = scene.getNonDeletedElementsMap(); - let binding: PointBinding | FixedPointBinding = { - elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - scene.getNonDeletedElementsMap(), - ), - hoveredElement, - ), - }; + let binding: FixedPointBinding; - if (isElbowArrow(linearElement)) { + if (isElbowArrow(arrow)) { binding = { - ...binding, + elementId: hoveredElement.id, + mode: "orbit", ...calculateFixedPointForElbowArrowBinding( - linearElement, + arrow, hoveredElement, startOrEnd, - scene.getNonDeletedElementsMap(), + elementsMap, + ), + }; + } else { + binding = { + elementId: hoveredElement.id, + mode, + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + hoveredElement, + startOrEnd, + elementsMap, + focusPoint, ), }; } - scene.mutateElement(linearElement, { + scene.mutateElement(arrow, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, }); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); - if (!boundElementsMap.has(linearElement.id)) { + if (!boundElementsMap.has(arrow.id)) { scene.mutateElement(hoveredElement, { boundElements: (hoveredElement.boundElements || []).concat({ - id: linearElement.id, + id: arrow.id, type: "arrow", }), }); } }; -// Don't bind both ends of a simple segment -const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", -): boolean => { - const otherBinding = - linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; - return isLinearElementSimpleAndAlreadyBound( - linearElement, - otherBinding?.elementId, - bindableElement, - ); -}; - -export const isLinearElementSimpleAndAlreadyBound = ( - linearElement: NonDeleted, - alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, - bindableElement: ExcalidrawBindableElement, -): boolean => { - return ( - alreadyBoundToId === bindableElement.id && - isLinearElementSimple(linearElement) - ); -}; - -const isLinearElementSimple = ( - linearElement: NonDeleted, -): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement); - -const unbindLinearElement = ( - linearElement: NonDeleted, +export const unbindBindingElement = ( + arrow: NonDeleted, startOrEnd: "start" | "end", scene: Scene, ): ExcalidrawBindableElement["id"] | null => { const field = startOrEnd === "start" ? "startBinding" : "endBinding"; - const binding = linearElement[field]; + const binding = arrow[field]; + if (binding == null) { return null; } - scene.mutateElement(linearElement, { [field]: null }); - return binding.elementId; -}; -export const getHoveredElementForBinding = ( - pointerCoords: { - x: number; - y: number; - }, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, - considerAllElements?: boolean, -): NonDeleted | null => { - if (considerAllElements) { - let cullRest = false; - const candidateElements = getAllElementsAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - (fullShape || - !isBindingFallthroughEnabled( - element as ExcalidrawBindableElement, - )) && - // disable fullshape snapping for frame elements so we - // can bind to frame children - !isFrameLikeElement(element), - ), - ).filter((element) => { - if (cullRest) { - return false; - } - - if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) { - cullRest = true; - } - - return true; - }) as NonDeleted[] | null; - - // Return early if there are no candidates or just one candidate - if (!candidateElements || candidateElements.length === 0) { - return null; - } - - if (candidateElements.length === 1) { - return candidateElements[0] as NonDeleted; - } - - // Prefer the shape with the border being tested (if any) - const borderTestElements = candidateElements.filter((element) => - bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), - ); - if (borderTestElements.length === 1) { - return borderTestElements[0]; - } - - // Prefer smaller shapes - return candidateElements - .sort( - (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), - ) - .pop() as NonDeleted; - } - - const hoveredElement = getElementAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element), + const oppositeBinding = + arrow[startOrEnd === "start" ? "endBinding" : "startBinding"]; + if (!oppositeBinding || oppositeBinding.elementId !== binding.elementId) { + // Only remove the record on the bound element if the other + // end is not bound to the same element + const boundElement = scene + .getNonDeletedElementsMap() + .get(binding.elementId) as ExcalidrawBindableElement; + scene.mutateElement(boundElement, { + boundElements: boundElement.boundElements?.filter( + (element) => element.id !== arrow.id, ), - ); - - return hoveredElement as NonDeleted | null; -}; - -const getElementAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - let hitElement = null; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - // because array is ordered from lower z-index to highest and we want element z-index - // with higher z-index - for (let index = elements.length - 1; index >= 0; --index) { - const element = elements[index]; - if (element.isDeleted) { - continue; - } - if (isAtPositionFn(element)) { - hitElement = element; - break; - } + }); } - return hitElement; -}; + scene.mutateElement(arrow, { [field]: null }); -const getAllElementsAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - const elementsAtPosition: NonDeletedExcalidrawElement[] = []; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - // because array is ordered from lower z-index to highest and we want element z-index - // with higher z-index - for (let index = elements.length - 1; index >= 0; --index) { - const element = elements[index]; - if (element.isDeleted) { - continue; - } - - if (isAtPositionFn(element)) { - elementsAtPosition.push(element); - } - } - - 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, - elementsMap, - adjacentPoint, - edgePoint, - ), - gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)), - }; + return binding.elementId; }; // Supports translating, rotating and scaling `changedElement` with bound @@ -740,7 +930,6 @@ export const updateBoundElements = ( scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { @@ -748,7 +937,7 @@ export const updateBoundElements = ( return; } - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -762,7 +951,7 @@ export const updateBoundElements = ( } boundElementsVisitor(elementsMap, changedElement, (element) => { - if (!isLinearElement(element) || element.isDeleted) { + if (!isArrowElement(element) || element.isDeleted) { return; } @@ -776,7 +965,10 @@ export const updateBoundElements = ( ? elementsMap.get(element.startBinding.elementId) : null; const endBindingElement = element.endBinding - ? elementsMap.get(element.endBinding.elementId) + ? // PERF: If the arrow is bound to the same element on both ends. + startBindingElement?.id === element.endBinding.elementId + ? startBindingElement + : elementsMap.get(element.endBinding.elementId) : null; let startBounds: Bounds | null = null; @@ -786,22 +978,8 @@ 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)) { - scene.mutateElement(element, bindings); return; } @@ -823,7 +1001,7 @@ export const updateBoundElements = ( const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + element[bindingProp], bindableElement, elementsMap, ); @@ -843,12 +1021,9 @@ export const updateBoundElements = ( ); LinearElementEditor.movePoints(element, scene, new Map(updates), { - ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } - : {}), - ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } - : {}), + moveMidPointsWithElement: + !!startBindingElement && + startBindingElement?.id === endBindingElement?.id, }); const boundText = getBoundTextElement(element, elementsMap); @@ -861,14 +1036,14 @@ export const updateBoundElements = ( export const updateBindings = ( latestElement: ExcalidrawElement, scene: Scene, + appState: AppState, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; - zoom?: AppState["zoom"]; }, ) => { - if (isLinearElement(latestElement)) { - bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom); + if (isArrowElement(latestElement)) { + bindOrUnbindBindingElement(latestElement, new Map(), scene, appState); } else { updateBoundElements(latestElement, scene, { ...options, @@ -878,7 +1053,7 @@ export const updateBindings = ( }; const doesNeedUpdate = ( - boundElement: NonDeleted, + boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, ) => { return ( @@ -908,13 +1083,16 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding( - origPoint, + const d = distanceToElement(bindableElement, elementsMap, origPoint); + const bindDistance = maxBindingDistanceFromOutline( bindableElement, - elementsMap, + bindableElement.width, + bindableElement.height, zoom, ); + const distance = d > bindDistance ? null : d; + if (!distance) { return vectorToHeading( vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)), @@ -924,75 +1102,66 @@ export const getHeadingForElbowArrowSnap = ( return headingForPointFromElement(bindableElement, aabb, p); }; -const getDistanceForBinding = ( - point: Readonly, - bindableElement: ExcalidrawBindableElement, - elementsMap: ElementsMap, - zoom?: AppState["zoom"], -) => { - const distance = distanceToElement(bindableElement, elementsMap, point); - const bindDistance = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - zoom, - ); - - return distance > bindDistance ? null : distance; -}; - export const bindPointToSnapToElementOutline = ( - arrow: ExcalidrawElbowArrowElement, + linearElement: ExcalidrawArrowElement, bindableElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + customIntersector?: LineSegment, ): GlobalPoint => { - if (isDevEnv() || isTestEnv()) { - invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); + const aabb = aabbForElement(bindableElement, elementsMap); + const localPoint = + linearElement.points[ + startOrEnd === "start" ? 0 : linearElement.points.length - 1 + ]; + const point = pointFrom( + linearElement.x + localPoint[0], + linearElement.y + localPoint[1], + ); + + if (linearElement.points.length < 2) { + // New arrow creation, so no snapping + return point; } - const aabb = aabbForElement(bindableElement, elementsMap); - 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, elementsMap, globalP) - : globalP; - const elbowed = isElbowArrow(arrow); + ? avoidRectangularCorner(bindableElement, elementsMap, point) + : point; + const elbowed = isElbowArrow(linearElement); const center = getCenterForBounds(aabb); - const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2; + const adjacentPointIdx = + startOrEnd === "start" ? 1 : linearElement.points.length - 2; const adjacentPoint = pointRotateRads( pointFrom( - arrow.x + arrow.points[adjacentPointIdx][0], - arrow.y + arrow.points[adjacentPointIdx][1], + linearElement.x + linearElement.points[adjacentPointIdx][0], + linearElement.y + linearElement.points[adjacentPointIdx][1], ), center, - arrow.angle ?? 0, + linearElement.angle ?? 0, ); let intersection: GlobalPoint | null = null; if (elbowed) { const isHorizontal = headingIsHorizontal( - headingForPointFromElement(bindableElement, aabb, globalP), + headingForPointFromElement(bindableElement, aabb, point), ); const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint); const otherPoint = pointFrom( isHorizontal ? center[0] : snapPoint[0], !isHorizontal ? center[1] : snapPoint[1], ); - const intersector = lineSegment( - otherPoint, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), - Math.max(bindableElement.width, bindableElement.height) * 2, - ), + const intersector = + customIntersector ?? + lineSegment( otherPoint, - ), - ); + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), + Math.max(bindableElement.width, bindableElement.height) * 2, + ), + otherPoint, + ), + ); intersection = intersectElementWithLineSegment( bindableElement, elementsMap, @@ -1000,25 +1169,31 @@ export const bindPointToSnapToElementOutline = ( FIXED_BINDING_DISTANCE, ).sort(pointDistanceSq)[0]; } else { - intersection = intersectElementWithLineSegment( - bindableElement, - elementsMap, + const halfVector = vectorScale( + vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), + pointDistance(edgePoint, adjacentPoint) + + Math.max(bindableElement.width, bindableElement.height) + + FIXED_BINDING_DISTANCE * 2, + ); + const intersector = + customIntersector ?? lineSegment( - adjacentPoint, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), - pointDistance(edgePoint, adjacentPoint) + - Math.max(bindableElement.width, bindableElement.height) * 2, - ), - adjacentPoint, - ), - ), - FIXED_BINDING_DISTANCE, - ).sort( - (g, h) => - pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), - )[0]; + pointFromVector(halfVector, adjacentPoint), + pointFromVector(vectorScale(halfVector, -1), adjacentPoint), + ); + intersection = + pointDistance(edgePoint, adjacentPoint) < 1 + ? edgePoint + : intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + FIXED_BINDING_DISTANCE, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - + pointDistanceSq(h, adjacentPoint), + )[0]; } if ( @@ -1029,7 +1204,52 @@ export const bindPointToSnapToElementOutline = ( return edgePoint; } - return elbowed ? intersection : edgePoint; + return intersection; +}; + +export const getOutlineAvoidingPoint = ( + element: NonDeleted, + hoveredElement: ExcalidrawBindableElement | null, + coords: GlobalPoint, + pointIndex: number, + elementsMap: ElementsMap, + customIntersector?: LineSegment, +): GlobalPoint => { + if (hoveredElement) { + return bindPointToSnapToElementOutline( + { + ...element, + x: pointIndex === 0 ? coords[0] : element.x, + y: pointIndex === 0 ? coords[1] : element.y, + points: + pointIndex === 0 + ? [ + pointFrom(0, 0), + ...element.points + .slice(1) + .map((p) => + pointFrom( + p[0] - (coords[0] - element.x), + p[1] - (coords[1] - element.y), + ), + ), + ] + : [ + ...element.points.slice(0, -1), + pointFrom( + coords[0] - element.x, + coords[1] - element.y, + ), + ], + }, + hoveredElement, + pointIndex === 0 ? "start" : "end", + elementsMap, + customIntersector, + ); + } + + return coords; }; export const avoidRectangularCorner = ( @@ -1128,7 +1348,50 @@ export const avoidRectangularCorner = ( return p; }; -export const snapToMid = ( +export const snapToCenter = ( + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + p: GlobalPoint, +) => { + const extent = Math.min(element.width, element.height); + const center = elementCenterPoint(element, elementsMap); + const nonRotated = pointRotateRads(p, center, -element.angle as Radians); + if (isRectanguloidElement(element)) { + if ( + Math.abs(nonRotated[0] - (element.x + element.width / 2)) > + element.width * 0.9 || + Math.abs(nonRotated[1] - (element.y + element.height / 2)) > + element.height * 0.9 + ) { + return pointFrom(center[0], center[1]); + } + } + + if (element.type === "diamond") { + const center = elementCenterPoint(element, elementsMap); + const nonRotated = pointRotateRads(p, center, -element.angle as Radians); + const cx = element.x + element.width / 2; + const cy = element.y + element.height / 2; + const scale = 0.9; // 90% sized inner diamond + const halfW = (element.width / 2) * scale; + const halfH = (element.height / 2) * scale; + + if (halfW > 0 && halfH > 0) { + const dx = Math.abs(nonRotated[0] - cx); + const dy = Math.abs(nonRotated[1] - cy); + if (dx / halfW + dy / halfH <= 1) { + return pointFrom(center[0], center[1]); + } + } + } + + if (pointDistance(nonRotated, center) < extent * 0.5) { + return pointFrom(center[0], center[1]); + } + return p; +}; + +const snapToMid = ( element: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, @@ -1235,130 +1498,42 @@ export const snapToMid = ( return p; }; -const updateBoundPoint = ( - linearElement: NonDeleted, +export const updateBoundPoint = ( + arrow: NonDeleted, startOrEnd: "startBinding" | "endBinding", - binding: PointBinding | null | undefined, + binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, ): LocalPoint | null => { if ( binding == null || // We only need to update the other end if this is a 2 point line element - (binding.elementId !== bindableElement.id && - linearElement.points.length > 2) + (binding.elementId !== bindableElement.id && arrow.points.length > 2) ) { return null; } - const direction = startOrEnd === "startBinding" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - - if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { - const fixedPoint = - normalizeFixedPoint(binding.fixedPoint) ?? - calculateFixedPointForElbowArrowBinding( - linearElement, - bindableElement, - startOrEnd === "startBinding" ? "start" : "end", - elementsMap, - ).fixedPoint; - const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); - const global = pointFrom( - bindableElement.x + fixedPoint[0] * bindableElement.width, - bindableElement.y + fixedPoint[1] * bindableElement.height, - ); - const rotatedGlobal = pointRotateRads( - global, - globalMidPoint, - bindableElement.angle, - ); - - return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - rotatedGlobal, - elementsMap, - ); - } - - const adjacentPointIndex = edgePointIndex - direction; - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - const focusPointAbsolute = determineFocusPoint( + const fixedPoint = normalizeFixedPoint(binding.fixedPoint); + const global = getGlobalFixedPointForBindableElement( + fixedPoint, bindableElement, elementsMap, - binding.focus, - adjacentPoint, ); - 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) { - newEdgePoint = focusPointAbsolute; - } else { - const edgePointAbsolute = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - - const center = elementCenterPoint(bindableElement, elementsMap); - const interceptorLength = - pointDistance(adjacentPoint, edgePointAbsolute) + - pointDistance(adjacentPoint, center) + - Math.max(bindableElement.width, bindableElement.height) * 2; - const intersections = [ - ...intersectElementWithLineSegment( - bindableElement, - elementsMap, - 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; - } - } + const maybeOutlineGlobal = + binding.mode === "orbit" + ? getOutlineAvoidingPoint( + arrow, + bindableElement, + global, + startOrEnd === "startBinding" ? 0 : arrow.points.length - 1, + elementsMap, + ) + : global; return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - newEdgePoint, + arrow, + maybeOutlineGlobal, elementsMap, ); }; @@ -1401,58 +1576,43 @@ 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 getEligibleElementForBindingElement = ( - linearElement: NonDeleted, +export const calculateFixedPointForNonElbowArrowBinding = ( + linearElement: NonDeleted, + hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], -): NonDeleted | null => { - return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - elements, - elementsMap, - zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); -}; + elementsMap: ElementsMap, + focusPoint?: GlobalPoint, +): { fixedPoint: FixedPoint } => { + const edgePoint = focusPoint + ? focusPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); -const getLinearElementEdgeCoors = ( - linearElement: NonDeleted, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { x: number; y: number } => { - const index = startOrEnd === "start" ? 0 : -1; - return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - index, - elementsMap, - ), + // Convert the global point to element-local coordinates + const elementCenter = pointFrom( + hoveredElement.x + hoveredElement.width / 2, + hoveredElement.y + hoveredElement.height / 2, ); + + // Rotate the point to account for element rotation + const nonRotatedPoint = pointRotateRads( + edgePoint, + elementCenter, + -hoveredElement.angle as Radians, + ); + + // Calculate the ratio relative to the element's bounds + const fixedPointX = + (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + const fixedPointY = + (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + + return { + fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), + }; }; export const fixDuplicatedBindingsAfterDuplication = ( @@ -1568,324 +1728,6 @@ const newBoundElements = ( return nextBoundElements; }; -export const bindingBorderTest = ( - element: NonDeleted, - { x, y }: { x: number; y: number }, - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, -): boolean => { - const p = pointFrom(x, y); - const threshold = maxBindingGap(element, element.width, element.height, zoom); - const shouldTestInside = - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element); - - // PERF: Run a cheap test to see if the binding element - // is even close to the element - const bounds = [ - x - threshold, - y - threshold, - x + threshold, - y + threshold, - ] as Bounds; - const elementBounds = getElementBounds(element, elementsMap); - if (!doBoundsIntersect(bounds, elementBounds)) { - return false; - } - - // Do the intersection test against the element since it's close enough - const intersections = intersectElementWithLineSegment( - element, - elementsMap, - lineSegment(elementCenterPoint(element, elementsMap), p), - ); - const distance = distanceToElement(element, elementsMap, p); - - return shouldTestInside - ? intersections.length === 0 || distance <= threshold - : intersections.length > 0 && distance <= threshold; -}; - -export const maxBindingGap = ( - element: ExcalidrawElement, - elementWidth: number, - elementHeight: number, - zoom?: AppState["zoom"], -): number => { - const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; - - // Aligns diamonds with rectangles - const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; - const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); - - return Math.max( - 16, - // bigger bindable boundary for bigger elements - Math.min(0.25 * smallerDimension, 32), - // keep in sync with the zoomed highlight - BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE, - ); -}; - -// The focus distance is the oriented ratio between the size of -// the `element` and the "focus image" of the element on which -// all focus points lie, so it's a number between -1 and 1. -// The line going through `a` and `b` is a tangent to the "focus image" -// of the element. -const determineFocusDistance = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // Point on the line, in absolute coordinates - a: GlobalPoint, - // Another point on the line, in absolute coordinates (closer to element) - b: GlobalPoint, -): number => { - const center = elementCenterPoint(element, elementsMap); - - if (pointsEqual(a, b)) { - return 0; - } - - const rotatedA = pointRotateRads(a, center, -element.angle as Radians); - const rotatedB = pointRotateRads(b, center, -element.angle as Radians); - const sign = - Math.sign( - vectorCross( - vectorFromPoint(rotatedB, a), - vectorFromPoint(rotatedB, center), - ), - ) * -1; - const rotatedInterceptor = lineSegment( - rotatedB, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(rotatedB, rotatedA)), - Math.max(element.width * 2, element.height * 2), - ), - rotatedB, - ), - ); - const axes = - element.type === "diamond" - ? [ - lineSegment( - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x, element.y + element.height / 2), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom(element.x, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x + element.width, element.y), - pointFrom(element.x, element.y + element.height), - ), - ]; - const interceptees = - element.type === "diamond" - ? [ - lineSegment( - pointFrom( - element.x + element.width / 2, - element.y - element.height, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x - element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom( - element.x - element.width, - element.y - element.height, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x + element.width * 2, - element.y - element.height, - ), - pointFrom( - element.x - element.width, - element.y + element.height * 2, - ), - ), - ]; - - const ordered = [ - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), - ] - .filter((p): p is GlobalPoint => p !== null) - .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b)) - .map( - (p, idx): number => - (sign * pointDistance(center, p)) / - (element.type === "diamond" - ? pointDistance(axes[idx][0], axes[idx][1]) / 2 - : Math.sqrt(element.width ** 2 + element.height ** 2) / 2), - ) - .sort((g, h) => Math.abs(g) - Math.abs(h)); - - const signedDistanceRatio = ordered[0] ?? 0; - - return signedDistanceRatio; -}; - -const determineFocusPoint = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // The oriented, relative distance from the center of `element` of the - // returned focusPoint - focus: number, - adjacentPoint: GlobalPoint, -): GlobalPoint => { - const center = elementCenterPoint(element, elementsMap); - - if (focus === 0) { - return center; - } - - const candidates = ( - element.type === "diamond" - ? [ - pointFrom(element.x, element.y + element.height / 2), - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ] - : [ - pointFrom(element.x, element.y), - pointFrom(element.x + element.width, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - pointFrom(element.x, element.y + element.height), - ] - ) - .map((p) => - pointFromVector( - vectorScale(vectorFromPoint(p, center), Math.abs(focus)), - center, - ), - ) - .map((p) => pointRotateRads(p, center, element.angle as Radians)); - - const selected = [ - vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) > 0 && // TOP - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) > 0 && // RIGHT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) > 0 && // BOTTOM - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) > 0 && // LEFT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0), - ]; - - const focusPoint = selected[0] - ? focus > 0 - ? candidates[1] - : candidates[0] - : selected[1] - ? focus > 0 - ? candidates[2] - : candidates[1] - : selected[2] - ? focus > 0 - ? candidates[3] - : candidates[2] - : focus > 0 - ? candidates[0] - : candidates[3]; - - return focusPoint; -}; - export const bindingProperties: Set = new Set([ "boundElements", "frameId", @@ -2212,7 +2054,7 @@ export const getGlobalFixedPointForBindableElement = ( }; export const getGlobalFixedPoints = ( - arrow: ExcalidrawElbowArrowElement, + arrow: ExcalidrawArrowElement, elementsMap: ElementsMap, ): [GlobalPoint, GlobalPoint] => { const startElement = diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index cc15947ed..a7cda59d4 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -1,4 +1,4 @@ -import { isTransparent } from "@excalidraw/common"; +import { invariant, isTransparent } from "@excalidraw/common"; import { curveIntersectLineSegment, isPointWithinBounds, @@ -25,7 +25,7 @@ import type { Radians, } from "@excalidraw/math"; -import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; +import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types"; import { isPathALoop } from "./utils"; import { @@ -38,6 +38,8 @@ import { } from "./bounds"; import { hasBoundTextElement, + isBindableElement, + isFrameLikeElement, isFreeDrawElement, isIframeLikeElement, isImageElement, @@ -56,14 +58,21 @@ import { LinearElementEditor } from "./linearElementEditor"; import { distanceToElement } from "./distance"; +import { BINDING_HIGHLIGHT_THICKNESS, FIXED_BINDING_DISTANCE } from "./binding"; + import type { ElementsMap, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawRectanguloidElement, + NonDeleted, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, } from "./types"; export const shouldTestInside = (element: ExcalidrawElement) => { @@ -94,6 +103,7 @@ export type HitTestArgs = { threshold: number; elementsMap: ElementsMap; frameNameBound?: FrameNameBounds | null; + overrideShouldTestInside?: boolean; }; export const hitElementItself = ({ @@ -102,6 +112,7 @@ export const hitElementItself = ({ threshold, elementsMap, frameNameBound = null, + overrideShouldTestInside = false, }: HitTestArgs) => { // Hit test against a frame's name const hitFrameName = frameNameBound @@ -134,7 +145,9 @@ export const hitElementItself = ({ } // Do the precise (and relatively costly) hit test - const hitElement = shouldTestInside(element) + const hitElement = ( + overrideShouldTestInside ? true : shouldTestInside(element) + ) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" isPointInElement(point, element, elementsMap) || @@ -193,6 +206,143 @@ export const hitElementBoundText = ( return isPointInElement(point, boundTextElement, elementsMap); }; +export const maxBindingDistanceFromOutline = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, + zoom?: AppState["zoom"], +): number => { + const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; + + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + + return Math.max( + 16, + // bigger bindable boundary for bigger elements + Math.min(0.25 * smallerDimension, 32), + // keep in sync with the zoomed highlight + BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE, + ); +}; + +export const bindingBorderTest = ( + element: NonDeleted, + [x, y]: Readonly, + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], +): boolean => { + const p = pointFrom(x, y); + const threshold = maxBindingDistanceFromOutline( + element, + element.width, + element.height, + zoom, + ); + const shouldTestInside = + // disable fullshape snapping for frame elements so we + // can bind to frame children + !isFrameLikeElement(element); + + // PERF: Run a cheap test to see if the binding element + // is even close to the element + const bounds = [ + x - threshold, + y - threshold, + x + threshold, + y + threshold, + ] as Bounds; + const elementBounds = getElementBounds(element, elementsMap); + if (!doBoundsIntersect(bounds, elementBounds)) { + return false; + } + + // Do the intersection test against the element since it's close enough + const intersections = intersectElementWithLineSegment( + element, + elementsMap, + lineSegment(elementCenterPoint(element, elementsMap), p), + ); + const distance = distanceToElement(element, elementsMap, p); + + return shouldTestInside + ? intersections.length === 0 || distance <= threshold + : intersections.length > 0 && distance <= threshold; +}; + +export const getHoveredElementForBinding = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], +): NonDeleted | null => { + const candidateElements: NonDeleted[] = []; + // We need to to hit testing from front (end of the array) to back (beginning of the array) + // because array is ordered from lower z-index to highest and we want element z-index + // with higher z-index + for (let index = elements.length - 1; index >= 0; --index) { + const element = elements[index]; + + invariant( + !element.isDeleted, + "Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements", + ); + + if ( + isBindableElement(element, false) && + bindingBorderTest(element, point, elementsMap, zoom) + ) { + candidateElements.push(element); + } + } + + if (!candidateElements || candidateElements.length === 0) { + return null; + } + + if (candidateElements.length === 1) { + return candidateElements[0]; + } + + // Prefer smaller shapes + return candidateElements + .sort( + (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), + ) + .pop() as NonDeleted; +}; + +export const getHoveredElementForBindingAndIfItsPrecise = ( + point: GlobalPoint, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + zoom: AppState["zoom"], + shouldTestInside: boolean = true, +): { + hovered: NonDeleted | null; + hit: boolean; +} => { + const hoveredElement = getHoveredElementForBinding( + point, + elements, + elementsMap, + zoom, + ); + // TODO: Optimize this to avoid recalculating the point - element distance + const hit = + !!hoveredElement && + hitElementItself({ + element: hoveredElement, + elementsMap, + point, + threshold: 0, + overrideShouldTestInside: shouldTestInside, + }); + + return { hovered: hoveredElement, hit }; +}; + /** * Intersect a line with an element for binding test * diff --git a/packages/element/src/dragElements.ts b/packages/element/src/dragElements.ts index 4b17ba20c..08e791a62 100644 --- a/packages/element/src/dragElements.ts +++ b/packages/element/src/dragElements.ts @@ -2,6 +2,7 @@ import { TEXT_AUTOWRAP_THRESHOLD, getGridPoint, getFontString, + DRAGGING_THRESHOLD, } from "@excalidraw/common"; import type { @@ -13,7 +14,7 @@ import type { import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; -import { updateBoundElements } from "./binding"; +import { unbindBindingElement, updateBoundElements } from "./binding"; import { getCommonBounds } from "./bounds"; import { getPerfectElementSize } from "./sizeHelpers"; import { getBoundTextElement } from "./textElement"; @@ -102,9 +103,26 @@ export const dragSelectedElements = ( gridSize, ); + const elementsToUpdateIds = new Set( + Array.from(elementsToUpdate, (el) => el.id), + ); + elementsToUpdate.forEach((element) => { - updateElementCoords(pointerDownState, element, scene, adjustedOffset); + const isArrow = !isArrowElement(element); + const isStartBoundElementSelected = + isArrow || + (element.startBinding + ? elementsToUpdateIds.has(element.startBinding.elementId) + : false); + const isEndBoundElementSelected = + isArrow || + (element.endBinding + ? elementsToUpdateIds.has(element.endBinding.elementId) + : false); + if (!isArrowElement(element)) { + updateElementCoords(pointerDownState, element, scene, adjustedOffset); + // skip arrow labels since we calculate its position during render const textElement = getBoundTextElement( element, @@ -121,6 +139,30 @@ export const dragSelectedElements = ( updateBoundElements(element, scene, { simultaneouslyUpdated: Array.from(elementsToUpdate), }); + } else if ( + // NOTE: Add a little initial drag to the arrow dragging to avoid + // accidentally unbinding the arrow when the user just wants to select it. + Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > + DRAGGING_THRESHOLD || + (!element.startBinding && !element.endBinding) + ) { + updateElementCoords(pointerDownState, element, scene, adjustedOffset); + + const shouldUnbindStart = + element.startBinding && !isStartBoundElementSelected; + const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected; + if (shouldUnbindStart || shouldUnbindEnd) { + // NOTE: Moving the bound arrow should unbind it, otherwise we would + // have weird situations, like 0 lenght arrow when the user moves + // the arrow outside a filled shape suddenly forcing the arrow start + // and end point to jump "outside" the shape. + if (shouldUnbindStart) { + unbindBindingElement(element, "start", scene); + } + if (shouldUnbindEnd) { + unbindBindingElement(element, "end", scene); + } + } } }); }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index b988eb25b..4348e81b5 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -17,7 +17,6 @@ import { BinaryHeap, invariant, isAnyTrue, - tupleToCoors, getSizeFromPoints, isDevEnv, arrayToMap, @@ -30,7 +29,6 @@ import { FIXED_BINDING_DISTANCE, getHeadingForElbowArrowSnap, getGlobalFixedPointForBindableElement, - getHoveredElementForBinding, } from "./binding"; import { distanceToElement } from "./distance"; import { @@ -51,8 +49,8 @@ import { type ExcalidrawElbowArrowElement, type NonDeletedSceneElementsMap, } from "./types"; - import { aabbForElement, pointInsideBounds } from "./bounds"; +import { getHoveredElementForBinding } from "./collision"; import type { Bounds } from "./bounds"; import type { Heading } from "./heading"; @@ -63,6 +61,7 @@ import type { FixedPointBinding, FixedSegment, NonDeletedExcalidrawElement, + Ordered, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -2262,17 +2261,10 @@ const getBindPointHeading = ( const getHoveredElement = ( origPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], + elements: readonly Ordered[], zoom?: AppState["zoom"], ) => { - return getHoveredElementForBinding( - tupleToCoors(origPoint), - elements, - elementsMap, - zoom, - true, - true, - ); + return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom); }; const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index 6cffb56a8..daa98ed39 100644 --- a/packages/element/src/flowchart.ts +++ b/packages/element/src/flowchart.ts @@ -7,7 +7,7 @@ import type { PendingExcalidrawElements, } from "@excalidraw/excalidraw/types"; -import { bindLinearElement } from "./binding"; +import { bindBindingElement } from "./binding"; import { updateElbowArrowPoints } from "./elbowArrow"; import { HEADING_DOWN, @@ -446,8 +446,14 @@ const createBindingArrow = ( const elementsMap = scene.getNonDeletedElementsMap(); - bindLinearElement(bindingArrow, startBindingElement, "start", scene); - bindLinearElement(bindingArrow, endBindingElement, "end", scene); + bindBindingElement( + bindingArrow, + startBindingElement, + "orbit", + "start", + scene, + ); + bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene); const changedElements = new Map(); changedElements.set( diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 995d866b5..53a85178f 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,6 +9,7 @@ import { vectorFromPoint, curveLength, curvePointAtLength, + lineSegment, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -20,12 +21,15 @@ import { getGridPoint, invariant, tupleToCoors, - viewportCoordsToSceneCoords, } from "@excalidraw/common"; import { + bindingBorderTest, + CaptureUpdateAction, deconstructLinearOrFreeDrawElement, + getHoveredElementForBinding, isPathALoop, + moveArrowAboveBindable, type Store, } from "@excalidraw/element"; @@ -40,13 +44,11 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; -import type { Mutable } from "@excalidraw/common/utility-types"; - import { - bindOrUnbindLinearElement, - getHoveredElementForBinding, + getGlobalFixedPointForBindableElement, + getOutlineAvoidingPoint, isBindingEnabled, - maybeSuggestBindingsForLinearElementAtCoords, + maybeSuggestBindingsForBindingElementAtCoords, } from "./binding"; import { getElementAbsoluteCoords, @@ -56,11 +58,16 @@ import { import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; -import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { + getBoundTextElement, + getContainerElement, + handleBindTextResize, +} from "./textElement"; import { isBindingElement, isElbowArrow, - isFixedPointBinding, + isSimpleArrow, + isTextElement, } from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -76,7 +83,6 @@ import type { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, - PointBinding, ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, @@ -85,6 +91,8 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, PointsPositionUpdates, + NonDeletedExcalidrawElement, + Ordered, } from "./types"; /** @@ -134,17 +142,14 @@ export class LinearElementEditor { index: number | null; added: boolean; }; + arrowOriginalStartPoint?: GlobalPoint; + arrowStartIsInside: boolean; }>; /** whether you're dragging a point */ public readonly isDragging: boolean; public readonly lastUncommittedPoint: LocalPoint | null; public readonly pointerOffset: Readonly<{ x: number; y: number }>; - public readonly startBindingElement: - | ExcalidrawBindableElement - | null - | "keep"; - public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; @@ -171,8 +176,6 @@ export class LinearElementEditor { this.lastUncommittedPoint = null; this.isDragging = false; this.pointerOffset = { x: 0, y: 0 }; - this.startBindingElement = "keep"; - this.endBindingElement = "keep"; this.pointerDownState = { prevSelectedPointsIndices: null, lastClickedPoint: -1, @@ -184,6 +187,7 @@ export class LinearElementEditor { index: null, added: false, }, + arrowStartIsInside: false, }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; @@ -293,19 +297,22 @@ export class LinearElementEditor { const elementsMap = app.scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); let customLineAngle = linearElementEditor.customLineAngle; + 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 @@ -315,7 +322,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 @@ -325,6 +332,8 @@ export class LinearElementEditor { const draggingPoint = element.points[lastClickedPoint]; if (selectedPointsIndices && draggingPoint) { + const elements = app.scene.getNonDeletedElements(); + if ( shouldRotateWithDiscreteAngle(event) && selectedPointsIndices.length === 1 && @@ -339,7 +348,6 @@ export class LinearElementEditor { element.points[selectedIndex][1] - referencePoint[1], element.points[selectedIndex][0] - referencePoint[0], ); - const [width, height] = LinearElementEditor._getShiftLockedDelta( element, elementsMap, @@ -348,22 +356,32 @@ export class LinearElementEditor { event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), customLineAngle, ); - + const [x, y] = LinearElementEditor.getPointGlobalCoordinates( + element, + pointFrom( + width + referencePoint[0], + height + referencePoint[1], + ), + elementsMap, + ); LinearElementEditor.movePoints( element, app.scene, - new Map([ - [ - selectedIndex, - { - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - isDragging: selectedIndex === lastClickedPoint, - }, - ], - ]), + pointDraggingUpdates( + selectedPointsIndices, + 0, + 0, + elementsMap, + lastClickedPoint, + element, + x, + y, + linearElementEditor, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + elements, + app, + true, + ), ); } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( @@ -373,38 +391,25 @@ export class LinearElementEditor { scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; LinearElementEditor.movePoints( element, app.scene, - new Map( - 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, - ); - return [ - pointIndex, - { - point: newPointPosition, - isDragging: pointIndex === lastClickedPoint, - }, - ]; - }), + pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + lastClickedPoint, + element, + scenePointerX, + scenePointerY, + linearElementEditor, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + elements, + app, ), ); } @@ -417,16 +422,14 @@ export class LinearElementEditor { // suggest bindings for first and last point if selected let suggestedBindings: ExcalidrawBindableElement[] = []; if (isBindingElement(element, false)) { - const firstSelectedIndex = selectedPointsIndices[0] === 0; - const lastSelectedIndex = + const firstIndexIsSelected = selectedPointsIndices[0] === 0; + const lastIndexIsSelected = selectedPointsIndices[selectedPointsIndices.length - 1] === element.points.length - 1; const coords: { x: number; y: number }[] = []; - if (!firstSelectedIndex !== !lastSelectedIndex) { - coords.push({ x: scenePointerX, y: scenePointerY }); - } else { - if (firstSelectedIndex) { + if (firstIndexIsSelected !== lastIndexIsSelected) { + if (firstIndexIsSelected) { coords.push( tupleToCoors( LinearElementEditor.getPointGlobalCoordinates( @@ -438,7 +441,7 @@ export class LinearElementEditor { ); } - if (lastSelectedIndex) { + if (lastIndexIsSelected) { coords.push( tupleToCoors( LinearElementEditor.getPointGlobalCoordinates( @@ -454,9 +457,13 @@ export class LinearElementEditor { } if (coords.length) { - suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords( + suggestedBindings = maybeSuggestBindingsForBindingElementAtCoords( element, - coords, + firstIndexIsSelected && lastIndexIsSelected + ? "both" + : firstIndexIsSelected + ? "start" + : "end", app.scene, app.state.zoom, ); @@ -485,10 +492,9 @@ export class LinearElementEditor { }; return { - ...app.state, selectedLinearElement: newLinearElementEditor, suggestedBindings, - }; + } as Pick; } return null; @@ -501,8 +507,6 @@ export class LinearElementEditor { scene: Scene, ): LinearElementEditor { const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); - const pointerCoords = viewportCoordsToSceneCoords(event, appState); const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; @@ -511,15 +515,6 @@ export class LinearElementEditor { return editingLinearElement; } - const bindings: Mutable< - Partial< - Pick< - InstanceType, - "startBindingElement" | "endBindingElement" - > - > - > = {}; - if (isDragging && selectedPointsIndices) { for (const selectedPoint of selectedPointsIndices) { if ( @@ -555,36 +550,12 @@ export class LinearElementEditor { ]), ); } - - const bindingElement = isBindingEnabled(appState) - ? getHoveredElementForBinding( - (selectedPointsIndices?.length ?? 0) > 1 - ? tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - selectedPoint!, - elementsMap, - ), - ) - : pointerCoords, - elements, - elementsMap, - appState.zoom, - isElbowArrow(element), - isElbowArrow(element), - ) - : null; - - bindings[ - selectedPoint === 0 ? "startBindingElement" : "endBindingElement" - ] = bindingElement; } } } return { ...editingLinearElement, - ...bindings, segmentMidPointHoveredCoords: null, hoverPointIndex: -1, // if clicking without previously dragging a point(s), and not holding @@ -609,6 +580,11 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + pointerDownState: { + ...editingLinearElement.pointerDownState, + arrowOriginalStartPoint: undefined, + arrowStartIsInside: false, + }, }; } @@ -853,7 +829,6 @@ export class LinearElementEditor { } { const appState = app.state; const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -871,6 +846,7 @@ export class LinearElementEditor { if (!element) { return ret; } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, @@ -878,6 +854,7 @@ export class LinearElementEditor { elementsMap, ); let segmentMidpointIndex = null; + if (segmentMidpoint) { segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, @@ -914,19 +891,16 @@ export class LinearElementEditor { index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding( - scenePointer, - elements, - elementsMap, - app.state.zoom, - linearElementEditor.elbowed, - ), }; ret.didAddPoint = true; + return ret; } @@ -941,21 +915,6 @@ export class LinearElementEditor { // it would get deselected if the point is outside the hitbox area if (clickedPointIndex >= 0 || segmentMidpoint) { ret.hitElement = element; - } else { - // You might be wandering why we are storing the binding elements on - // LinearElementEditor and passing them in, instead of calculating them - // from the end points of the `linearElement` - this is to allow disabling - // binding (which needs to happen at the point the user finishes moving - // the point). - const { startBindingElement, endBindingElement } = linearElementEditor; - if (isBindingEnabled(appState) && isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); - } } const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -994,6 +953,9 @@ export class LinearElementEditor { index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), }, selectedPointsIndices: nextSelectedPointsIndices, pointerOffset: targetPoint @@ -1056,7 +1018,6 @@ export class LinearElementEditor { if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { const lastCommittedPoint = points[points.length - 2]; - const [width, height] = LinearElementEditor._getShiftLockedDelta( element, elementsMap, @@ -1141,7 +1102,6 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, - indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, ): GlobalPoint { @@ -1409,8 +1369,9 @@ export class LinearElementEditor { scene: Scene, pointUpdates: PointsPositionUpdates, otherUpdates?: { - startBinding?: PointBinding | null; - endBinding?: PointBinding | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + moveMidPointsWithElement?: boolean | null; }, ) { const { points } = element; @@ -1456,6 +1417,15 @@ export class LinearElementEditor { : points.map((p, idx) => { const current = pointUpdates.get(idx)?.point ?? p; + if ( + otherUpdates?.moveMidPointsWithElement && + idx !== 0 && + idx !== points.length - 1 && + !pointUpdates.has(idx) + ) { + return pointFrom(current[0], current[1]); + } + return pointFrom( current[0] - offsetX, current[1] - offsetY, @@ -1578,8 +1548,8 @@ export class LinearElementEditor { offsetX: number, offsetY: number, otherUpdates?: { - startBinding?: PointBinding | null; - endBinding?: PointBinding | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; }, options?: { isDragging?: boolean; @@ -1594,18 +1564,10 @@ export class LinearElementEditor { points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { - updates.startBinding = - otherUpdates.startBinding !== null && - isFixedPointBinding(otherUpdates.startBinding) - ? otherUpdates.startBinding - : null; + updates.startBinding = otherUpdates.startBinding; } if (otherUpdates?.endBinding !== undefined) { - updates.endBinding = - otherUpdates.endBinding !== null && - isFixedPointBinding(otherUpdates.endBinding) - ? otherUpdates.endBinding - : null; + updates.endBinding = otherUpdates.endBinding; } updates.points = Array.from(nextPoints); @@ -1984,3 +1946,212 @@ const normalizeSelectedPoints = ( nextPoints = nextPoints.sort((a, b) => a - b); return nextPoints.length ? nextPoints : null; }; + +const pointDraggingUpdates = ( + selectedPointsIndices: readonly number[], + deltaX: number, + deltaY: number, + elementsMap: NonDeletedSceneElementsMap, + lastClickedPoint: number, + element: NonDeleted, + scenePointerX: number, + scenePointerY: number, + linearElementEditor: LinearElementEditor, + gridSize: NullableGridSize, + elements: readonly Ordered[], + app: AppClassProperties, + angleLocked?: boolean, +): PointsPositionUpdates => { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true); + const hasMidPoints = + selectedPointsIndices.filter( + (_, idx) => idx > 0 && idx < element.points.length - 1, + ).length > 0; + + const updates = new Map( + selectedPointsIndices.map((pointIndex) => { + let newPointPosition: LocalPoint = + pointIndex === lastClickedPoint + ? LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + gridSize, + ) + : pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ); + + if ( + isSimpleArrow(element) && + !hasMidPoints && + (pointIndex === 0 || pointIndex === element.points.length - 1) + ) { + let newGlobalPointPosition = pointRotateRads( + pointFrom( + element.x + newPointPosition[0], + element.y + newPointPosition[1], + ), + pointFrom(cx, cy), + element.angle, + ); + const hoveredElement = getHoveredElementForBinding( + newGlobalPointPosition, + elements, + elementsMap, + app.state.zoom, + ); + const otherGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + pointIndex === 0 ? -1 : 0, + elementsMap, + ); + const otherPointInsideElement = + !!hoveredElement && + !!bindingBorderTest( + hoveredElement, + otherGlobalPoint, + elementsMap, + app.state.zoom, + ); + + if ( + isBindingEnabled(app.state) && + isBindingElement(element, false) && + hoveredElement && + app.state.bindMode === "orbit" && + !otherPointInsideElement + ) { + let customIntersector; + if (angleLocked) { + const adjacentPointIndex = + pointIndex === 0 ? 1 : element.points.length - 2; + const globalAdjacentPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + adjacentPointIndex, + elementsMap, + ); + customIntersector = lineSegment( + globalAdjacentPoint, + newGlobalPointPosition, + ); + } + + newGlobalPointPosition = getOutlineAvoidingPoint( + element, + hoveredElement, + newGlobalPointPosition, + pointIndex, + elementsMap, + customIntersector, + ); + } + + newPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, + newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, + null, + ); + + // Update z-index of the arrow + if ( + isBindingEnabled(app.state) && + isBindingElement(element) && + hoveredElement + ) { + const boundTextElement = getBoundTextElement( + hoveredElement, + elementsMap, + ); + const containerElement = isTextElement(hoveredElement) + ? getContainerElement(hoveredElement, elementsMap) + : null; + const newElements = moveArrowAboveBindable( + element, + [ + hoveredElement.id, + boundTextElement?.id, + containerElement?.id, + ].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id), + app.scene, + ); + + app.syncActionResult({ + elements: newElements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }); + } + } + + return [ + pointIndex, + { + point: newPointPosition, + isDragging: pointIndex === lastClickedPoint, + }, + ]; + }), + ); + + if (isSimpleArrow(element)) { + const adjacentPointIndices = + element.points.length === 2 + ? [0, 1] + : element.points.length === 3 + ? [1] + : [1, element.points.length - 2]; + + adjacentPointIndices + .filter((adjacentPointIndex) => + selectedPointsIndices.includes(adjacentPointIndex), + ) + .flatMap((adjacentPointIndex) => + element.points.length === 3 + ? [0, 2] + : adjacentPointIndex === 1 + ? 0 + : element.points.length - 1, + ) + .forEach((pointIndex) => { + const binding = + element[pointIndex === 0 ? "startBinding" : "endBinding"]; + const bindingIsOrbiting = binding?.mode === "orbit"; + if (bindingIsOrbiting) { + const hoveredElement = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + const focusGlobalPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + hoveredElement, + elementsMap, + ); + const newGlobalPointPosition = getOutlineAvoidingPoint( + element, + hoveredElement, + focusGlobalPoint, + pointIndex, + elementsMap, + ); + const newPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, + newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, + null, + ); + updates.set(pointIndex, { + point: newPointPosition, + isDragging: false, + }); + } + }); + } + + return updates; +}; diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index 0fc3e0bb8..c45c6df08 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -46,16 +46,13 @@ export const mutateElement = >( // casting to any because can't use `in` operator // (see https://github.com/microsoft/TypeScript/issues/21732) - const { points, fixedSegments, startBinding, endBinding, fileId } = - updates as any; + const { points, fixedSegments, fileId } = updates as any; if ( isElbowArrow(element) && (Object.keys(updates).length === 0 || // normalization case typeof points !== "undefined" || // repositioning - typeof fixedSegments !== "undefined" || // segment fixing - typeof startBinding !== "undefined" || - typeof endBinding !== "undefined") // manual binding to element + typeof fixedSegments !== "undefined") // segment fixing ) { updates = { ...updates, diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index acb72b299..feb52d177 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -45,6 +45,7 @@ import { import { wrapText } from "./textWrapping"; import { isArrowElement, + isBindingElement, isBoundToContainer, isElbowArrow, isFrameLikeElement, @@ -73,7 +74,9 @@ import type { ExcalidrawImageElement, ElementsMap, ExcalidrawElbowArrowElement, + ExcalidrawArrowElement, } from "./types"; +import type { ElementUpdate } from "./mutateElement"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -219,7 +222,19 @@ const rotateSingleElement = ( } const boundTextElementId = getBoundTextElementId(element); - scene.mutateElement(element, { angle }); + let update: ElementUpdate = { + angle, + }; + + if (isBindingElement(element)) { + update = { + ...update, + startBinding: null, + endBinding: null, + } as ElementUpdate; + } + + scene.mutateElement(element, update); if (boundTextElementId) { const textElement = scene.getElement(boundTextElementId); @@ -819,13 +834,29 @@ export const resizeSingleElement = ( Number.isFinite(newOrigin.x) && Number.isFinite(newOrigin.y) ) { - const updates = { + let updates: ElementUpdate = { ...newOrigin, width: Math.abs(nextWidth), height: Math.abs(nextHeight), ...rescaledPoints, }; + if (isBindingElement(latestElement)) { + if (latestElement.startBinding) { + updates = { + ...updates, + startBinding: null, + } as ElementUpdate; + } + + if (latestElement.endBinding) { + updates = { + ...updates, + endBinding: null, + } as ElementUpdate; + } + } + scene.mutateElement(latestElement, updates, { informMutation: shouldInformMutation, isDragging: false, @@ -843,10 +874,7 @@ export const resizeSingleElement = ( shouldMaintainAspectRatio, ); - updateBoundElements(latestElement, scene, { - // TODO: confirm with MARK if this actually makes sense - newSize: { width: nextWidth, height: nextHeight }, - }); + updateBoundElements(latestElement, scene); } }; @@ -1385,13 +1413,12 @@ export const resizeMultipleElements = ( element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { - const { width, height, angle } = update; + const { angle } = update; scene.mutateElement(element, update); updateBoundElements(element, scene, { simultaneouslyUpdated: elementsToUpdate, - newSize: { width, height }, }); const boundTextElement = getBoundTextElement(element, elementsMap); diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index ab7a1935f..f328ee947 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -28,8 +28,6 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, ExcalidrawLineElement, - PointBinding, - FixedPointBinding, ExcalidrawFlowchartNodeElement, ExcalidrawLinearElementSubType, } from "./types"; @@ -163,7 +161,7 @@ export const isLinearElementType = ( export const isBindingElement = ( element?: ExcalidrawElement | null, includeLocked = true, -): element is ExcalidrawLinearElement => { +): element is ExcalidrawArrowElement => { return ( element != null && (!element.locked || includeLocked === true) && @@ -358,15 +356,6 @@ export const getDefaultRoundnessTypeForElement = ( return null; }; -export const isFixedPointBinding = ( - binding: PointBinding | FixedPointBinding, -): binding is FixedPointBinding => { - return ( - Object.hasOwn(binding, "fixedPoint") && - (binding as FixedPointBinding).fixedPoint != null - ); -}; - // TODO: Move this to @excalidraw/math export const isBounds = (box: unknown): box is Bounds => Array.isArray(box) && diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index c2becd3e6..0ddb44883 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -279,23 +279,22 @@ export type ExcalidrawTextElementWithContainer = { export type FixedPoint = [number, number]; -export type PointBinding = { - elementId: ExcalidrawBindableElement["id"]; - focus: number; - gap: number; -}; +export type BindMode = "inside" | "orbit"; -export type FixedPointBinding = Merge< - PointBinding, - { - // Represents the fixed point binding information in form of a vertical and - // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio - // gives the user selected fixed point by multiplying the bound element width - // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the - // bound element-local point coordinate. - fixedPoint: FixedPoint; - } ->; +export type FixedPointBinding = { + elementId: ExcalidrawBindableElement["id"]; + + // Represents the fixed point binding information in form of a vertical and + // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio + // gives the user selected fixed point by multiplying the bound element width + // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the + // bound element-local point coordinate. + fixedPoint: FixedPoint; + + // Determines whether the arrow remains outside the shape or is allowed to + // go all the way inside the shape up to the exact fixed point. + mode: BindMode; +}; type Index = number; @@ -323,8 +322,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & type: "line" | "arrow"; points: readonly LocalPoint[]; lastCommittedPoint: LocalPoint | null; - startBinding: PointBinding | null; - endBinding: PointBinding | null; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; startArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null; }>; @@ -351,9 +350,9 @@ export type ExcalidrawElbowArrowElement = Merge< ExcalidrawArrowElement, { elbowed: true; + fixedSegments: readonly FixedSegment[] | null; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; - fixedSegments: readonly FixedSegment[] | null; /** * Marks that the 3rd point should be used as the 2nd point of the arrow in * order to temporarily hide the first segment of the arrow without losing diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index fed937825..78bdcb9cc 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -12,7 +12,12 @@ import { getSelectedElements } from "./selection"; import type { Scene } from "./Scene"; -import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; +import type { + ExcalidrawArrowElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + OrderedExcalidrawElement, +} from "./types"; const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { return element.frameId === frameId || element.id === frameId; @@ -139,6 +144,27 @@ const getContiguousFrameRangeElements = ( return allElements.slice(rangeStart, rangeEnd + 1); }; +export const moveArrowAboveBindable = ( + arrow: ExcalidrawArrowElement, + bindableIds: string[], + scene: Scene, +): readonly OrderedExcalidrawElement[] => { + const elements = scene.getElementsIncludingDeleted(); + const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id)); + const arrowIdx = elements.findIndex((el) => el.id === arrow.id); + + if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) { + const updatedElements = Array.from(elements); + const arrow = updatedElements.splice(arrowIdx, 1)[0]; + updatedElements.splice(bindableIdx, 0, arrow); + syncMovedIndices(elements, arrayToMap([arrow])); + + return updatedElements; + } + + return elements; +}; + /** * Returns next candidate index that's available to be moved to. Currently that * is a non-deleted element, and not inside a group (unless we're editing it). diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index a3da1c66d..35892d1c5 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -8,7 +8,13 @@ import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; -import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; +import { + act, + fireEvent, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; + +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; import { getTransformHandles } from "../src/transformHandles"; import { @@ -16,463 +22,708 @@ import { TEXT_EDITOR_SELECTOR, } from "../../excalidraw/tests/queries/dom"; +import type { + ExcalidrawArrowElement, + ExcalidrawLinearElement, + FixedPointBinding, +} from "../src/types"; + const { h } = window; const mouse = new Pointer("mouse"); -describe("element binding", () => { - beforeEach(async () => { - await render(); - }); +describe("binding for simple arrows", () => { + describe("when both endpoints are bound inside the same element", () => { + beforeEach(async () => { + mouse.reset(); - it("should create valid binding if duplicate start/end points", async () => { - const rect = API.createElement({ - type: "rectangle", - x: 0, - y: 0, - width: 50, - height: 50, - }); - const arrow = API.createElement({ - type: "arrow", - x: 100, - y: 0, - width: 100, - height: 1, - points: [ - pointFrom(0, 0), - pointFrom(0, 0), - pointFrom(100, 0), - pointFrom(100, 0), - ], - }); - API.setElements([rect, arrow]); - expect(arrow.startBinding).toBe(null); - - // select arrow - mouse.clickAt(150, 0); - - // move arrow start to potential binding position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - // Point selection is evaluated like the points are rendered, - // from right to left. So clicking on the first point should move the joint, - // not the start point. - expect(arrow.startBinding).toBe(null); - - // Now that the start point is free, move it into overlapping position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - expect(API.getSelectedElements()).toEqual([arrow]); - - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + await act(() => { + return setLanguage(defaultLang); + }); + await render(); }); - // Move the end point to the overlapping binding position - mouse.downAt(200, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); + it("should create an `inside` binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.reset(); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); - // Both the start and the end points should be bound - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + const rect = API.getSelectedElement(); + + // Draw arrow with endpoint inside the filled rectangle + UI.clickTool("arrow"); + mouse.downAt(110, 110); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(110); + expect(arrow.y).toBe(110); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const startBinding = arrow.startBinding as FixedPointBinding; + expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(startBinding.mode).toBe("inside"); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(endBinding.mode).toBe("inside"); + + // Move the bindable + mouse.downAt(100, 150); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the start point of the arrow to check if + // the behavior remains the same for old arrows + mouse.reset(); + mouse.downAt(110, 110); + mouse.moveTo(120, 120); + mouse.up(); + + // Move the bindable again + mouse.reset(); + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); }); - expect(arrow.endBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + + it("3+ point arrow should be dragged along with the bindable", () => { + // Create two rectangles as binding targets + const rectLeft = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const rectRight = API.createElement({ + type: "rectangle", + x: 300, + y: 0, + width: 100, + height: 100, + }); + + // Create a non-elbowed arrow with inner points bound to different elements + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 50, + width: 200, + height: 0, + points: [ + pointFrom(0, 0), // start point + pointFrom(50, -20), // first inner point + pointFrom(150, 20), // second inner point + pointFrom(200, 0), // end point + ], + startBinding: { + elementId: rectLeft.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + endBinding: { + elementId: rectRight.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + }); + + API.setElements([rectLeft, rectRight, arrow]); + + // Store original inner point positions + const originalInnerPoint1 = [...arrow.points[1]]; + const originalInnerPoint2 = [...arrow.points[2]]; + + // Move the right rectangle down by 50 pixels + mouse.reset(); + mouse.downAt(350, 50); // Click on the right rectangle + mouse.moveTo(350, 100); // Move it down + mouse.up(); + + // Verify that inner points did NOT move when bound to different elements + // The arrow should NOT translate inner points proportionally when only one end moves + expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); + expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); + expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); + expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); }); }); - //@TODO fix the test with rotation - it.skip("rotation of arrow should rebind both ends", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); + describe("when arrow is outside of shape", () => { + beforeEach(async () => { + mouse.reset(); - const rotation = getTransformHandles( - arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).rotation!; - const rotationHandleX = rotation[0] + rotation[2] / 2; - const rotationHandleY = rotation[1] + rotation[3] / 2; - mouse.down(rotationHandleX, rotationHandleY); - mouse.move(300, 400); - mouse.up(); - expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); - expect(arrow.angle).toBeLessThan(1.3 * Math.PI); - expect(arrow.startBinding?.elementId).toBe(rectRight.id); - expect(arrow.endBinding?.elementId).toBe(rectLeft.id); + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should handle new arrow start point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with arrow tool + UI.clickTool("arrow"); + mouse.downAt(150, 150); // Start inside rectangle + mouse.moveTo(250, 150); // End outside + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have start binding to rectangle + expect(arrow.startBinding?.elementId).toBe(rectangle.id); + expect(arrow.startBinding?.mode).toBe("orbit"); // Default is orbit, not inside + expect(arrow.endBinding).toBeNull(); + }); + + it("should handle new arrow end point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with end point in binding zone + UI.clickTool("arrow"); + mouse.downAt(50, 150); // Start outside + mouse.moveTo(190, 190); // End near rectangle edge (should bind as orbit) + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have end binding to rectangle + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + expect(arrow.endBinding?.mode).toBe("orbit"); + expect(arrow.startBinding).toBeNull(); + }); + + it("should create orbit binding when one of the cursor is inside rectangle", () => { + // Create a filled solid rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rect = API.getSelectedElement(); + API.updateElement(rect, { + fillStyle: "solid", + backgroundColor: "#a5d8ff", + }); + + // Draw arrow with endpoint inside the filled rectangle, since only + // filled bindables bind inside the shape + UI.clickTool("arrow"); + mouse.downAt(10, 10); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(86.4669660940663); + expect(arrow.height).toBeCloseTo(86.46696609406821); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + + mouse.reset(); + + // Move the bindable + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(235); + expect(arrow.height).toBeCloseTo(117.5); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the arrow out + mouse.reset(); + mouse.click(10, 10); + mouse.downAt(96.466, 96.466); + mouse.moveTo(50, 50); + mouse.up(); + + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); + + // Re-bind the arrow by moving the cursor inside the rectangle + mouse.reset(); + mouse.downAt(50, 50); + mouse.moveTo(150, 150); + mouse.up(); + + // Check if the arrow is still on the outside + expect(arrow.width).toBeCloseTo(86, 0); + expect(arrow.height).toBeCloseTo(86, 0); + }); + + it("should happen even if the arrow is not pointing at the element", () => { + // Create a rectangle positioned so the extended arrow segment will miss it + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 100, + height: 100, + }); + + API.setElements([rect]); + + // Draw an arrow that doesn't point at the rectangle (extended segment will miss) + UI.clickTool("arrow"); + mouse.reset(); + mouse.downAt(125, 93); // Start point + mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Should create a fixed point binding since the extended line segment + // from the last arrow segment misses the rectangle + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.startBinding).toHaveProperty("fixedPoint"); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeGreaterThanOrEqual(0); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeLessThanOrEqual(1); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(0.5); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(1); + expect(arrow.endBinding).toBe(null); + }); }); - // TODO fix & reenable once we rewrite tests to work with concurrency - it.skip( - "editing arrow and moving its head to bind it to element A, finalizing the" + - "editing by clicking on element A should end up selecting A", - async () => { - UI.createElement("rectangle", { + describe("", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it( + "editing arrow and moving its head to bind it to element A, finalizing the" + + "editing by clicking on element A should end up selecting A", + async () => { + UI.createElement("rectangle", { + y: 0, + size: 100, + }); + // Create arrow bound to rectangle + UI.clickTool("arrow"); + mouse.down(50, -100); + mouse.up(0, 80); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + + // move arrow head + mouse.down(); + mouse.up(0, 10); + expect(API.getSelectedElement().type).toBe("arrow"); + + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(-50, -50); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(API.getSelectedElement().type).toBe("arrow"); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(0, 0); + expect(h.state.selectedLinearElement).toBeNull(); + expect(API.getSelectedElement().type).toBe("rectangle"); + }, + ); + + it("should unbind on bound element deletion", () => { + const rectangle = UI.createElement("rectangle", { + x: 60, y: 0, size: 100, }); - // Create arrow bound to rectangle - UI.clickTool("arrow"); - mouse.down(50, -100); - mouse.up(0, 80); - // Edit arrow with multi-point - mouse.doubleClick(); - // move arrow head - mouse.down(); - mouse.up(0, 10); - expect(API.getSelectedElement().type).toBe("arrow"); + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); - // NOTE this mouse down/up + await needs to be done in order to repro - // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 - mouse.reset(); - expect(h.state.selectedLinearElement?.isEditing).toBe(true); - mouse.down(0, 0); - await new Promise((r) => setTimeout(r, 100)); - expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + + mouse.select(rectangle); expect(API.getSelectedElement().type).toBe("rectangle"); + Keyboard.keyDown(KEYS.DELETE); + expect(arrow.endBinding).toBe(null); + }); + + it("should unbind arrow when arrow is resized", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 180, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + // Drag arrow off of bound rectangle range + const handles = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).se!; + + const elX = handles[0] + handles[2] / 2; + const elY = handles[1] + handles[3] / 2; + mouse.downAt(elX, elY); + mouse.moveTo(300, 400); mouse.up(); - expect(API.getSelectedElement().type).toBe("rectangle"); - }, - ); - it("should unbind arrow when moving it with keyboard", () => { - const rectangle = UI.createElement("rectangle", { - x: 75, - y: 0, - size: 100, + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); }); - // Creates arrow 1px away from bidding with rectangle - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 49, + it("should unbind arrow when arrow is rotated", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + + UI.clickTool("arrow"); + mouse.reset(); + mouse.clickAt(210, 250); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(390, 251); + mouse.clickAt(390, 251); + + const arrow = API.getSelectedElement() as ExcalidrawArrowElement; + + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; + const rotationHandleX = rotation[0] + rotation[2] / 2; + const rotationHandleY = rotation[1] + rotation[3] / 2; + mouse.reset(); + mouse.down(rotationHandleX, rotationHandleY); + mouse.move(300, 400); + mouse.up(); + expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); + expect(arrow.angle).toBeLessThan(1.3 * Math.PI); + expect(arrow.startBinding).toBeNull(); + expect(arrow.endBinding).toBeNull(); }); - expect(arrow.endBinding).toBe(null); + it("should not unbind when duplicating via selection group", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + y: 200, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 177, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); - mouse.downAt(49, 49); - mouse.moveTo(51, 0); - mouse.up(0, 0); - - // Test sticky connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - // Sever connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding).toBe(null); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on bound element deletion", () => { - const rectangle = UI.createElement("rectangle", { - x: 60, - y: 0, - size: 100, - }); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - mouse.select(rectangle); - expect(API.getSelectedElement().type).toBe("rectangle"); - Keyboard.keyDown(KEYS.DELETE); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on text element deletion by submitting empty text", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // edit text element and submit - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - - const editor = await getTextEditor(); - - fireEvent.change(editor, { target: { value: "" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should keep binding on text update", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // delete text element by submitting empty text - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - const editor = await getTextEditor(); - - expect(editor).not.toBe(null); - - fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding?.elementId).toBe(text.id); - }); - - it("should update binding when text containerized", async () => { - const rectangle1 = API.createElement({ - type: "rectangle", - id: "rectangle1", - width: 100, - height: 100, - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - const arrow1 = API.createElement({ - type: "arrow", - id: "arrow1", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const arrow2 = API.createElement({ - type: "arrow", - id: "arrow2", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const text1 = API.createElement({ - type: "text", - id: "text1", - text: "ola", - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - API.setElements([rectangle1, arrow1, arrow2, text1]); - - API.setSelectedElements([text1]); - - expect(h.state.selectedElementIds[text1.id]).toBe(true); - - API.executeAction(actionWrapTextInContainer); - - // new text container will be placed before the text element - const container = h.elements.at(-2)!; - - expect(container.type).toBe("rectangle"); - expect(container.id).not.toBe(rectangle1.id); - - expect(container).toEqual( - expect.objectContaining({ - boundElements: expect.arrayContaining([ - { - type: "text", - id: text1.id, - }, - { - type: "arrow", - id: arrow1.id, - }, - { - type: "arrow", - id: arrow2.id, - }, - ]), - }), - ); - - expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); - expect(arrow1.endBinding?.elementId).toBe(container.id); - expect(arrow2.startBinding?.elementId).toBe(container.id); - expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); - }); - - // #6459 - it("should unbind arrow only from the latest element", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - // Drag arrow off of bound rectangle range - const handles = getTransformHandles( - arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).se!; - - Keyboard.keyDown(KEYS.CTRL_OR_CMD); - const elX = handles[0] + handles[2] / 2; - const elY = handles[1] + handles[3] / 2; - mouse.downAt(elX, elY); - mouse.moveTo(300, 400); - mouse.up(); - - expect(arrow.startBinding).not.toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should not unbind when duplicating via selection group", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - y: 200, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 177, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - mouse.downAt(-100, -100); - mouse.moveTo(650, 750); - mouse.up(0, 0); - - expect(API.getSelectedElements().length).toBe(3); - - mouse.moveTo(5, 5); - Keyboard.withModifierKeys({ alt: true }, () => { - mouse.downAt(5, 5); - mouse.moveTo(1000, 1000); + mouse.downAt(-100, -100); + mouse.moveTo(650, 750); mouse.up(0, 0); - expect(window.h.elements.length).toBe(6); - window.h.elements.forEach((element) => { - if (isLinearElement(element)) { - expect(element.startBinding).not.toBe(null); - expect(element.endBinding).not.toBe(null); - } else { - expect(element.boundElements).not.toBe(null); - } + expect(API.getSelectedElements().length).toBe(3); + + mouse.moveTo(5, 5); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.downAt(5, 5); + mouse.moveTo(1000, 1000); + mouse.up(0, 0); + + expect(window.h.elements.length).toBe(6); + window.h.elements.forEach((element) => { + if (isLinearElement(element)) { + expect(element.startBinding).not.toBe(null); + expect(element.endBinding).not.toBe(null); + } else { + expect(element.boundElements).not.toBe(null); + } + }); }); }); }); + + describe("to text elements", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "rectangle1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "text1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "text1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "rectangle1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + API.setElements([rectangle1, arrow1, arrow2, text1]); + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + API.executeAction(actionWrapTextInContainer); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); + + it("should keep binding on text update", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // delete text element by submitting empty text + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding?.elementId).toBe(text.id); + }); + + it("should unbind on text element deletion by submitting empty text", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // edit text element and submit + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + + const editor = await getTextEditor(); + + fireEvent.change(editor, { target: { value: "" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding).toBe(null); + }); + }); }); diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index 10b9346a6..60c5e6d83 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => { id: "arrow2", endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, boundElements: [{ id: "text2", type: "text" }], }); @@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => { id: "arrow2", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => { id: "arrow3", startBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index b279e596c..25ef2b2ac 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -1,13 +1,10 @@ import { ARROW_TYPE } from "@excalidraw/common"; import { pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; - import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection"; - import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; - import { act, fireEvent, @@ -15,13 +12,11 @@ import { queryByTestId, render, } from "@excalidraw/excalidraw/tests/test-utils"; - import "@excalidraw/utils/test-utils"; +import { bindBindingElement } from "@excalidraw/element"; import type { LocalPoint } from "@excalidraw/math"; -import { bindLinearElement } from "../src/binding"; - import { Scene } from "../src/Scene"; import type { @@ -160,8 +155,8 @@ describe("elbow arrow routing", () => { expect(arrow.width).toEqual(90); expect(arrow.height).toEqual(200); }); + it("can generate proper points for bound elbow arrow", () => { - const scene = new Scene(); const rectangle1 = API.createElement({ type: "rectangle", x: -150, @@ -185,17 +180,15 @@ describe("elbow arrow routing", () => { height: 200, points: [pointFrom(0, 0), pointFrom(90, 200)], }) as ExcalidrawElbowArrowElement; - scene.insertElement(rectangle1); - scene.insertElement(rectangle2); - scene.insertElement(arrow); + API.setElements([rectangle1, rectangle2, arrow]); - bindLinearElement(arrow, rectangle1, "start", scene); - bindLinearElement(arrow, rectangle2, "end", scene); + bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); + bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - h.app.scene.mutateElement(arrow, { + h.scene.mutateElement(arrow, { points: [pointFrom(0, 0), pointFrom(90, 200)], }); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index f1306b872..d53492541 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -379,7 +379,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -549,7 +549,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -600,7 +600,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -641,7 +641,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -689,7 +689,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `17`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -747,7 +747,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -845,7 +845,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -1316,7 +1316,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBe(400); + expect(arrow.width).toBeCloseTo(405); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1335,7 +1335,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBeCloseTo(200, 0); + expect(arrow.width).toBeCloseTo(205); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index 1d0b6ac0b..1ab1fafce 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -174,29 +174,29 @@ describe("generic element", () => { expect(rectangle.angle).toBeCloseTo(0); }); - it("resizes with bound arrow", async () => { - const rectangle = UI.createElement("rectangle", { - width: 200, - height: 100, - }); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const rectangle = UI.createElement("rectangle", { + // width: 200, + // height: 100, + // }); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - UI.resize(rectangle, "e", [40, 0]); + // UI.resize(rectangle, "e", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - UI.resize(rectangle, "w", [50, 0]); + // UI.resize(rectangle, "w", [50, 0]); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); - }); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); + // }); it("resizes with a label", async () => { const rectangle = UI.createElement("rectangle", { @@ -595,31 +595,31 @@ describe("text element", () => { expect(text.fontSize).toBeCloseTo(fontSize * scale); }); - it("resizes with bound arrow", async () => { - const text = UI.createElement("text"); - await UI.editText(text, "hello\nworld"); - const boundArrow = UI.createElement("arrow", { - x: -30, - y: 25, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const text = UI.createElement("text"); + // await UI.editText(text, "hello\nworld"); + // const boundArrow = UI.createElement("arrow", { + // x: -30, + // y: 25, + // width: 28, + // height: 5, + // }); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); - UI.resize(text, "ne", [40, 0]); + // UI.resize(text, "ne", [40, 0]); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); - const textWidth = text.width; - const scale = 20 / text.height; - UI.resize(text, "nw", [50, 20]); + // const textWidth = text.width; + // const scale = 20 / text.height; + // UI.resize(text, "nw", [50, 20]); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( - 30 + textWidth * scale, - ); - }); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( + // 30 + textWidth * scale, + // ); + // }); it("updates font size via keyboard", async () => { const text = UI.createElement("text"); @@ -801,36 +801,36 @@ describe("image element", () => { expect(image.scale).toEqual([1, 1]); }); - it("resizes with bound arrow", async () => { - const image = API.createElement({ - type: "image", - width: 100, - height: 100, - }); - API.setElements([image]); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const image = API.createElement({ + // type: "image", + // width: 100, + // height: 100, + // }); + // API.setElements([image]); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(arrow.endBinding?.elementId).toEqual(image.id); - UI.resize(image, "ne", [40, 0]); + // UI.resize(image, "ne", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - const imageWidth = image.width; - const scale = 20 / image.height; - UI.resize(image, "nw", [50, 20]); + // const imageWidth = image.width; + // const scale = 20 / image.height; + // 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, - 0, - ); - }); + // expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( + // 30 + imageWidth * scale, + // 0, + // ); + // }); }); describe("multiple selection", () => { @@ -997,68 +997,80 @@ describe("multiple selection", () => { expect(diagLine.angle).toEqual(0); }); - it("resizes with bound arrows", async () => { - const rectangle = UI.createElement("rectangle", { - position: 0, - size: 100, - }); - const leftBoundArrow = UI.createElement("arrow", { - x: -110, - y: 50, - width: 100, - height: 0, - }); + // it("resizes with bound arrows", async () => { + // const rectangle = UI.createElement("rectangle", { + // position: 0, + // size: 100, + // }); + // const leftBoundArrow = UI.createElement("arrow", { + // x: -110, + // y: 50, + // width: 100, + // height: 0, + // }); - const rightBoundArrow = UI.createElement("arrow", { - x: 210, - y: 50, - width: -100, - height: 0, - }); + // const rightBoundArrow = UI.createElement("arrow", { + // x: 210, + // y: 50, + // width: -100, + // height: 0, + // }); - const selectionWidth = 210; - const selectionHeight = 100; - const move = [40, 40] as [number, number]; - const scale = Math.max( - 1 - move[0] / selectionWidth, - 1 - move[1] / selectionHeight, - ); - const leftArrowBinding = { ...leftBoundArrow.endBinding }; - const rightArrowBinding = { ...rightBoundArrow.endBinding }; - delete rightArrowBinding.gap; + // const selectionWidth = 210; + // const selectionHeight = 100; + // const move = [40, 40] as [number, number]; + // const scale = Math.max( + // 1 - move[0] / selectionWidth, + // 1 - move[1] / selectionHeight, + // ); + // const leftArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...leftBoundArrow.endBinding, + // } as PointBinding; + // const rightArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...rightBoundArrow.endBinding, + // } as PointBinding; + // delete rightArrowBinding.gap; - UI.resize([rectangle, rightBoundArrow], "nw", move, { - shift: true, - }); + // UI.resize([rectangle, rightBoundArrow], "nw", move, { + // shift: true, + // }); - expect(leftBoundArrow.x).toBeCloseTo(-110); - expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(140, 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?.elementId).toBe( - leftArrowBinding.elementId, - ); - expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); + // expect(leftBoundArrow.x).toBeCloseTo(-110); + // expect(leftBoundArrow.y).toBeCloseTo(50); + // expect(leftBoundArrow.width).toBeCloseTo(140, 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?.elementId).toBe( + // leftArrowBinding.elementId, + // ); + // expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); - expect(rightBoundArrow.x).toBeCloseTo(210); - expect(rightBoundArrow.y).toBeCloseTo( - (selectionHeight - 50) * (1 - scale) + 50, - ); - expect(rightBoundArrow.width).toBeCloseTo(100 * scale); - expect(rightBoundArrow.height).toBeCloseTo(0); - expect(rightBoundArrow.angle).toEqual(0); - expect(rightBoundArrow.startBinding).toBeNull(); - expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); - expect(rightBoundArrow.endBinding?.elementId).toBe( - rightArrowBinding.elementId, - ); - expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( - rightArrowBinding.focus!, - ); - }); + // expect(rightBoundArrow.x).toBeCloseTo(210); + // expect(rightBoundArrow.y).toBeCloseTo( + // (selectionHeight - 50) * (1 - scale) + 50, + // ); + // expect(rightBoundArrow.width).toBeCloseTo(100 * scale); + // expect(rightBoundArrow.height).toBeCloseTo(0); + // expect(rightBoundArrow.angle).toEqual(0); + // expect(rightBoundArrow.startBinding).toBeNull(); + // expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); + // expect(rightBoundArrow.endBinding?.elementId).toBe( + // rightArrowBinding.elementId, + // ); + // expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( + // rightArrowBinding.focus!, + // ); + // }); it("resizes with labeled arrows", async () => { const topArrow = UI.createElement("arrow", { @@ -1338,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX); - expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); + expect(boundArrow.points[1][0]).toBeCloseTo(64.1246); + expect(boundArrow.points[1][1]).toBeCloseTo(-85.4995); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 535d96c7d..c3a5bde8b 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -51,7 +51,7 @@ import { register } from "./register"; import type { AppState, Offsets } from "../types"; -export const actionChangeViewBackgroundColor = register({ +export const actionChangeViewBackgroundColor = register>({ name: "changeViewBackgroundColor", label: "labels.canvasBackground", trackEvent: false, @@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - captureUpdate: !!value.viewBackgroundColor + captureUpdate: !!value?.viewBackgroundColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -463,7 +463,7 @@ export const actionZoomToFit = register({ !event[KEYS.CTRL_OR_CMD], }); -export const actionToggleTheme = register({ +export const actionToggleTheme = register({ name: "toggleTheme", label: (_, appState) => { return appState.theme === THEME.DARK @@ -471,7 +471,8 @@ export const actionToggleTheme = register({ : "buttons.darkMode"; }, keywords: ["toggle", "dark", "light", "mode", "theme"], - icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), + icon: (appState, elements) => + appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_, appState, value) => { diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index d9b011d2b..8d5ed2a30 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -20,12 +20,12 @@ import { t } from "../i18n"; import { actionDeleteSelected } from "./actionDeleteSelected"; import { register } from "./register"; -export const actionCopy = register({ +export const actionCopy = register({ name: "copy", label: "labels.copy", icon: DuplicateIcon, trackEvent: { category: "element" }, - perform: async (elements, appState, event: ClipboardEvent | null, app) => { + perform: async (elements, appState, event, app) => { const elementsToCopy = app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: true, @@ -109,12 +109,12 @@ export const actionPaste = register({ keyTest: undefined, }); -export const actionCut = register({ +export const actionCut = register({ name: "cut", label: "labels.cut", icon: cutIcon, trackEvent: { category: "element" }, - perform: (elements, appState, event: ClipboardEvent | null, app) => { + perform: (elements, appState, event, app) => { actionCopy.perform(elements, appState, event, app); return actionDeleteSelected.perform(elements, appState, null, app); }, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 78a346568..ef9858b85 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -206,12 +206,8 @@ export const actionDeleteSelected = register({ trackEvent: { category: "element", action: "delete" }, perform: (elements, appState, formData, app) => { if (appState.selectedLinearElement?.isEditing) { - const { - elementId, - selectedPointsIndices, - startBindingElement, - endBindingElement, - } = appState.selectedLinearElement; + const { elementId, selectedPointsIndices } = + appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); const linearElement = LinearElementEditor.getElement( elementId, @@ -248,19 +244,6 @@ export const actionDeleteSelected = register({ }; } - // We cannot do this inside `movePoint` because it is also called - // when deleting the uncommitted point (which hasn't caused any binding) - const binding = { - startBindingElement: selectedPointsIndices?.includes(0) - ? null - : startBindingElement, - endBindingElement: selectedPointsIndices?.includes( - linearElement.points.length - 1, - ) - ? null - : endBindingElement, - }; - LinearElementEditor.deletePoints( linearElement, app, @@ -273,7 +256,6 @@ export const actionDeleteSelected = register({ ...appState, selectedLinearElement: { ...appState.selectedLinearElement, - ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 ? [selectedPointsIndices[0] - 1] diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 908e2463e..1604d3849 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -31,7 +31,9 @@ import "../components/ToolIcon.scss"; import { register } from "./register"; -export const actionChangeProjectName = register({ +import type { AppState } from "../types"; + +export const actionChangeProjectName = register({ name: "changeProjectName", label: "labels.fileTitle", trackEvent: false, @@ -51,7 +53,7 @@ export const actionChangeProjectName = register({ ), }); -export const actionChangeExportScale = register({ +export const actionChangeExportScale = register({ name: "changeExportScale", label: "imageExportDialog.scale", trackEvent: { category: "export", action: "scale" }, @@ -101,7 +103,9 @@ export const actionChangeExportScale = register({ }, }); -export const actionChangeExportBackground = register({ +export const actionChangeExportBackground = register< + AppState["exportBackground"] +>({ name: "changeExportBackground", label: "imageExportDialog.label.withBackground", trackEvent: { category: "export", action: "toggleBackground" }, @@ -121,7 +125,9 @@ export const actionChangeExportBackground = register({ ), }); -export const actionChangeExportEmbedScene = register({ +export const actionChangeExportEmbedScene = register< + AppState["exportEmbedScene"] +>({ name: "changeExportEmbedScene", label: "imageExportDialog.tooltip.embedScene", trackEvent: { category: "export", action: "embedScene" }, @@ -288,7 +294,9 @@ export const actionLoadScene = register({ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, }); -export const actionExportWithDarkMode = register({ +export const actionExportWithDarkMode = register< + AppState["exportWithDarkMode"] +>({ name: "exportWithDarkMode", label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 877c817ad..c853167cd 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,10 +1,6 @@ import { pointFrom } from "@excalidraw/math"; -import { - maybeBindLinearElement, - bindOrUnbindLinearElement, - isBindingEnabled, -} from "@excalidraw/element/binding"; +import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import { isValidPolygon, LinearElementEditor, @@ -21,7 +17,7 @@ import { import { KEYS, arrayToMap, - tupleToCoors, + invariant, updateActiveTool, } from "@excalidraw/common"; import { isPathALoop } from "@excalidraw/element"; @@ -30,11 +26,12 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; -import type { LocalPoint } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, + PointsPositionUpdates, } from "@excalidraw/element/types"; import { t } from "../i18n"; @@ -46,20 +43,37 @@ import { register } from "./register"; import type { AppState } from "../types"; -export const actionFinalize = register({ +type FormData = { + event: PointerEvent; + sceneCoords: { x: number; y: number }; +}; + +export const actionFinalize = register({ name: "finalize", label: "", trackEvent: false, perform: (elements, appState, data, app) => { + let newElements = elements; const { interactiveCanvas, focusContainer, scene } = app; - const { event, sceneCoords } = - (data as { - event?: PointerEvent; - sceneCoords?: { x: number; y: number }; - }) ?? {}; const elementsMap = scene.getNonDeletedElementsMap(); - if (event && appState.selectedLinearElement) { + if (data && appState.selectedLinearElement) { + const { event, sceneCoords } = data; + const element = LinearElementEditor.getElement( + appState.selectedLinearElement.elementId, + elementsMap, + ); + + invariant( + element, + "Arrow element should exist if selectedLinearElement is set", + ); + + invariant( + sceneCoords, + "sceneCoords should be defined if actionFinalize is called with event", + ); + const linearElementEditor = LinearElementEditor.handlePointerUp( event, appState.selectedLinearElement, @@ -67,19 +81,46 @@ export const actionFinalize = register({ app.scene, ); - const { startBindingElement, endBindingElement } = linearElementEditor; - const element = app.scene.getElement(linearElementEditor.elementId); if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - app.scene, - ); + const newArrow = !appState.selectedLinearElement?.selectedPointsIndices; + + const selectedPointsIndices = newArrow + ? [element.points.length - 1] // New arrow creation + : appState.selectedLinearElement.selectedPointsIndices; + + const draggedPoints: PointsPositionUpdates = + selectedPointsIndices.reduce((map, index) => { + map.set(index, { + point: LinearElementEditor.pointFromAbsoluteCoords( + element, + pointFrom(sceneCoords.x, sceneCoords.y), + elementsMap, + ), + }); + + return map; + }, new Map()) ?? new Map(); + + bindOrUnbindBindingElement(element, draggedPoints, scene, appState, { + newArrow, + }); + } else if (isLineElement(element)) { + if ( + appState.selectedLinearElement?.isEditing && + !appState.newElement && + !isValidPolygon(element.points) + ) { + scene.mutateElement(element, { + polygon: false, + }); + } } if (linearElementEditor !== appState.selectedLinearElement) { - let newElements = elements; + // `handlePointerUp()` updated the linear element instance, + // so filter out this element if it is too small, + // but do an update to all new elements anyway for undo/redo purposes. + if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.map((el) => { @@ -91,39 +132,6 @@ export const actionFinalize = register({ return el; }); } - return { - elements: newElements, - appState: { - selectedLinearElement: { - ...linearElementEditor, - selectedPointsIndices: null, - }, - suggestedBindings: [], - }, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }; - } - } - - if (appState.selectedLinearElement?.isEditing) { - const { elementId, startBindingElement, endBindingElement } = - appState.selectedLinearElement; - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (element) { - if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); - } - if (isLineElement(element) && !isValidPolygon(element.points)) { - scene.mutateElement(element, { - polygon: false, - }); - } return { elements: @@ -134,23 +142,25 @@ export const actionFinalize = register({ } return el; }) - : undefined, + : newElements, appState: { ...appState, cursorButton: "up", - selectedLinearElement: new LinearElementEditor( - element, - arrayToMap(elementsMap), - false, // exit editing mode - ), + selectedLinearElement: { + ...linearElementEditor, + selectedPointsIndices: null, + isEditing: false, + }, + selectionElement: null, + suggestedBindings: [], + newElement: null, + multiElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } } - let newElements = elements; - if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -174,7 +184,11 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if (appState.multiElement && element.type !== "freedraw") { + if ( + appState.multiElement && + element.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || @@ -227,25 +241,6 @@ export const actionFinalize = register({ polygon: false, }); } - - if ( - isBindingElement(element) && - !isLoop && - element.points.length > 1 && - isBindingEnabled(appState) - ) { - const coords = - sceneCoords ?? - tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - -1, - arrayToMap(elements), - ), - ); - - maybeBindLinearElement(element, appState, coords, scene); - } } } @@ -271,6 +266,24 @@ export const actionFinalize = register({ }); } + let selectedLinearElement = + element && isLinearElement(element) + ? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing + : appState.selectedLinearElement; + + selectedLinearElement = selectedLinearElement + ? { + ...selectedLinearElement, + isEditing: appState.newElement + ? false + : selectedLinearElement.isEditing, + pointerDownState: { + ...selectedLinearElement.pointerDownState, + arrowOriginalStartPoint: undefined, + }, + } + : selectedLinearElement; + return { elements: newElements, appState: { @@ -298,11 +311,8 @@ export const actionFinalize = register({ [element.id]: true, } : appState.selectedElementIds, - // To select the linear element when user has finished mutipoint editing - selectedLinearElement: - element && isLinearElement(element) - ? new LinearElementEditor(element, arrayToMap(newElements)) - : appState.selectedLinearElement, + + selectedLinearElement, }, // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit captureUpdate: CaptureUpdateAction.IMMEDIATELY, diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 23e4ffc12..69050e9b2 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -38,15 +38,13 @@ describe("flipping re-centers selection", () => { height: 239.9, startBinding: { elementId: "rec1", - focus: 0, - gap: 5, fixedPoint: [0.49, -0.05], + mode: "orbit", }, endBinding: { elementId: "rec2", - focus: 0, - gap: 5, fixedPoint: [-0.05, 0.49], + mode: "orbit", }, startArrowhead: null, endArrowhead: "arrow", @@ -99,8 +97,8 @@ describe("flipping arrowheads", () => { endArrowhead: null, endBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); @@ -139,13 +137,13 @@ describe("flipping arrowheads", () => { endArrowhead: "circle", startBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, endBinding: { elementId: rect2.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); @@ -195,8 +193,8 @@ describe("flipping arrowheads", () => { endArrowhead: null, endBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 6456fca8d..b7e15275d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,17 +1,10 @@ import { getNonDeletedElements } from "@excalidraw/element"; -import { - bindOrUnbindLinearElements, - isBindingEnabled, -} from "@excalidraw/element"; +import { bindOrUnbindBindingElements } from "@excalidraw/element"; import { getCommonBoundingBox } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element"; import { resizeMultipleElements } from "@excalidraw/element"; -import { - isArrowElement, - isElbowArrow, - isLinearElement, -} from "@excalidraw/element"; +import { isArrowElement, isElbowArrow } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; @@ -103,7 +96,6 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, elementsMap, - appState, flipDirection, app, ); @@ -118,7 +110,6 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], elementsMap: NonDeletedSceneElementsMap, - appState: AppState, flipDirection: "horizontal" | "vertical", app: AppClassProperties, ): ExcalidrawElement[] => { @@ -158,12 +149,10 @@ const flipElements = ( }, ); - bindOrUnbindLinearElements( - selectedElements.filter(isLinearElement), - isBindingEnabled(appState), - [], + bindOrUnbindBindingElements( + selectedElements.filter(isArrowElement), app.scene, - appState.zoom, + app.state, ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 27f0d6024..02dcecef5 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -2,6 +2,8 @@ import clsx from "clsx"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { invariant } from "@excalidraw/common"; + import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { @@ -16,12 +18,17 @@ import { register } from "./register"; import type { GoToCollaboratorComponentProps } from "../components/UserList"; import type { Collaborator } from "../types"; -export const actionGoToCollaborator = register({ +export const actionGoToCollaborator = register({ name: "goToCollaborator", label: "Go to a collaborator", viewMode: true, trackEvent: { category: "collab" }, - perform: (_elements, appState, collaborator: Collaborator) => { + perform: (_elements, appState, collaborator) => { + invariant( + collaborator, + "actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called", + ); + if ( !collaborator.socketId || appState.userToFollow?.socketId === collaborator.socketId || diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 63cfe7672..75a7bbd8a 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,5 @@ import { pointFrom } from "@excalidraw/math"; + import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -21,12 +22,13 @@ import { getLineHeight, isTransparent, reduceToCommonValue, + invariant, } from "@excalidraw/common"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { - bindLinearElement, + bindBindingElement, calculateFixedPointForElbowArrowBinding, updateBoundElements, } from "@excalidraw/element"; @@ -292,13 +294,15 @@ const changeFontSize = ( // ----------------------------------------------------------------------------- -export const actionChangeStrokeColor = register({ +export const actionChangeStrokeColor = register< + Pick +>({ name: "changeStrokeColor", label: "labels.stroke", trackEvent: false, perform: (elements, appState, value) => { return { - ...(value.currentItemStrokeColor && { + ...(value?.currentItemStrokeColor && { elements: changeProperty( elements, appState, @@ -316,7 +320,7 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - captureUpdate: !!value.currentItemStrokeColor + captureUpdate: !!value?.currentItemStrokeColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -346,12 +350,14 @@ export const actionChangeStrokeColor = register({ ), }); -export const actionChangeBackgroundColor = register({ +export const actionChangeBackgroundColor = register< + Pick +>({ name: "changeBackgroundColor", label: "labels.changeBackground", trackEvent: false, perform: (elements, appState, value, app) => { - if (!value.currentItemBackgroundColor) { + if (!value?.currentItemBackgroundColor) { return { appState: { ...appState, @@ -423,7 +429,7 @@ export const actionChangeBackgroundColor = register({ ), }); -export const actionChangeFillStyle = register({ +export const actionChangeFillStyle = register({ name: "changeFillStyle", label: "labels.fill", trackEvent: false, @@ -503,7 +509,9 @@ export const actionChangeFillStyle = register({ }, }); -export const actionChangeStrokeWidth = register({ +export const actionChangeStrokeWidth = register< + ExcalidrawElement["strokeWidth"] +>({ name: "changeStrokeWidth", label: "labels.strokeWidth", trackEvent: false, @@ -559,7 +567,7 @@ export const actionChangeStrokeWidth = register({ ), }); -export const actionChangeSloppiness = register({ +export const actionChangeSloppiness = register({ name: "changeSloppiness", label: "labels.sloppiness", trackEvent: false, @@ -613,7 +621,9 @@ export const actionChangeSloppiness = register({ ), }); -export const actionChangeStrokeStyle = register({ +export const actionChangeStrokeStyle = register< + ExcalidrawElement["strokeStyle"] +>({ name: "changeStrokeStyle", label: "labels.strokeStyle", trackEvent: false, @@ -666,7 +676,7 @@ export const actionChangeStrokeStyle = register({ ), }); -export const actionChangeOpacity = register({ +export const actionChangeOpacity = register({ name: "changeOpacity", label: "labels.opacity", trackEvent: false, @@ -690,78 +700,89 @@ export const actionChangeOpacity = register({ ), }); -export const actionChangeFontSize = register({ - name: "changeFontSize", - label: "labels.fontSize", - trackEvent: false, - perform: (elements, appState, value, app) => { - return changeFontSize(elements, appState, app, () => value, value); +export const actionChangeFontSize = register( + { + name: "changeFontSize", + label: "labels.fontSize", + trackEvent: false, + perform: (elements, appState, value, app) => { + return changeFontSize( + elements, + appState, + app, + () => { + invariant(value, "actionChangeFontSize: Expected a font size value"); + return value; + }, + value, + ); + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( +
+ {t("labels.fontSize")} +
+ { + if (isTextElement(element)) { + return element.fontSize; + } + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + if (boundTextElement) { + return boundTextElement.fontSize; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontSize || DEFAULT_FONT_SIZE, + )} + onChange={(value) => updateData(value)} + /> +
+
+ ), }, - PanelComponent: ({ elements, appState, updateData, app }) => ( -
- {t("labels.fontSize")} -
- { - if (isTextElement(element)) { - return element.fontSize; - } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ); - if (boundTextElement) { - return boundTextElement.fontSize; - } - return null; - }, - (element) => - isTextElement(element) || - getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ) !== null, - (hasSelection) => - hasSelection - ? null - : appState.currentItemFontSize || DEFAULT_FONT_SIZE, - )} - onChange={(value) => updateData(value)} - /> -
-
- ), -}); +); export const actionDecreaseFontSize = register({ name: "decreaseFontSize", @@ -821,7 +842,10 @@ type ChangeFontFamilyData = Partial< resetContainers?: true; }; -export const actionChangeFontFamily = register({ +export const actionChangeFontFamily = register<{ + currentItemFontFamily: any; + currentHoveredFontFamily: any; +}>({ name: "changeFontFamily", label: "labels.fontFamily", trackEvent: false, @@ -858,6 +882,8 @@ export const actionChangeFontFamily = register({ }; } + invariant(value, "actionChangeFontFamily: value must be defined"); + const { currentItemFontFamily, currentHoveredFontFamily } = value; let nextCaptureUpdateAction: CaptureUpdateActionType = @@ -1191,7 +1217,7 @@ export const actionChangeFontFamily = register({ }, }); -export const actionChangeTextAlign = register({ +export const actionChangeTextAlign = register({ name: "changeTextAlign", label: "Change text alignment", trackEvent: false, @@ -1283,7 +1309,7 @@ export const actionChangeTextAlign = register({ }, }); -export const actionChangeVerticalAlign = register({ +export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", label: "Change vertical alignment", trackEvent: { category: "element" }, @@ -1375,7 +1401,7 @@ export const actionChangeVerticalAlign = register({ }, }); -export const actionChangeRoundness = register({ +export const actionChangeRoundness = register<"sharp" | "round">({ name: "changeRoundness", label: "Change edge roundness", trackEvent: false, @@ -1532,15 +1558,16 @@ const getArrowheadOptions = (flip: boolean) => { ] as const; }; -export const actionChangeArrowhead = register({ +export const actionChangeArrowhead = register<{ + position: "start" | "end"; + type: Arrowhead; +}>({ name: "changeArrowhead", label: "Change arrowheads", trackEvent: false, - perform: ( - elements, - appState, - value: { position: "start" | "end"; type: Arrowhead }, - ) => { + perform: (elements, appState, value) => { + invariant(value, "actionChangeArrowhead: value must be defined"); + return { elements: changeProperty(elements, appState, (el) => { if (isLinearElement(el)) { @@ -1616,7 +1643,7 @@ export const actionChangeArrowhead = register({ }, }); -export const actionChangeArrowType = register({ +export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", trackEvent: false, @@ -1717,7 +1744,13 @@ export const actionChangeArrowType = register({ newElement.startBinding.elementId, ) as ExcalidrawBindableElement; if (startElement) { - bindLinearElement(newElement, startElement, "start", app.scene); + bindBindingElement( + newElement, + startElement, + appState.bindMode === "inside" ? "inside" : "orbit", + "start", + app.scene, + ); } } if (newElement.endBinding) { @@ -1725,7 +1758,13 @@ export const actionChangeArrowType = register({ newElement.endBinding.elementId, ) as ExcalidrawBindableElement; if (endElement) { - bindLinearElement(newElement, endElement, "end", app.scene); + bindBindingElement( + newElement, + endElement, + appState.bindMode === "inside" ? "inside" : "orbit", + "end", + app.scene, + ); } } } diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts index 7c841e3ae..8f2281039 100644 --- a/packages/excalidraw/actions/register.ts +++ b/packages/excalidraw/actions/register.ts @@ -2,7 +2,12 @@ import type { Action } from "./types"; export let actions: readonly Action[] = []; -export const register = (action: T) => { +export const register = < + TData extends any, + T extends Action = Action, +>( + action: T, +) => { actions = actions.concat(action); return action as T & { keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index e6f363126..0a91bc625 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -32,10 +32,10 @@ export type ActionResult = } | false; -type ActionFn = ( +type ActionFn = ( elements: readonly OrderedExcalidrawElement[], appState: Readonly, - formData: any, + formData: TData | undefined, app: AppClassProperties, ) => ActionResult | Promise; @@ -158,7 +158,7 @@ export type PanelComponentProps = { ) => React.JSX.Element | null; }; -export interface Action { +export interface Action { name: ActionName; label: | string @@ -175,7 +175,7 @@ export interface Action { elements: readonly ExcalidrawElement[], ) => React.ReactNode); PanelComponent?: React.FC; - perform: ActionFn; + perform: ActionFn; keyPriority?: number; keyTest?: ( event: React.KeyboardEvent | KeyboardEvent, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 6c4a97116..6b9e2ad31 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -123,6 +123,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + bindMode: "orbit", }; }; @@ -247,6 +248,7 @@ const APP_STATE_STORAGE_CONF = (< searchMatches: { browser: false, export: false, server: false }, lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, export: false, server: false }, + bindMode: { browser: true, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 337fe180a..f850455c4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -103,20 +103,22 @@ import { isMobile, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, + BIND_MODE_TIMEOUT, + invariant, } from "@excalidraw/common"; import { getObservedAppState, getCommonBounds, - maybeSuggestBindingsForLinearElementAtCoords, + maybeSuggestBindingsForBindingElementAtCoords, getElementAbsoluteCoords, - bindOrUnbindLinearElements, + bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, shouldEnableBindingForPointerEvent, updateBoundElements, - getSuggestedBindingsForArrows, + getSuggestedBindingsForBindingElements, LinearElementEditor, newElementWith, newFrameElement, @@ -237,9 +239,15 @@ import { isSimpleArrow, StoreDelta, type ApplyToOptions, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + getBindingStrategyForDraggingBindingElementEndpoints, + getStartGlobalEndLocalPointsForSimpleArrowBinding, + snapToCenter, + mutateElement, } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, @@ -264,6 +272,7 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -575,7 +584,6 @@ class App extends React.Component { public renderer: Renderer; public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; - private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; @@ -609,6 +617,8 @@ class App extends React.Component { public flowChartCreator: FlowChartCreator = new FlowChartCreator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); + private bindModeHandler: ReturnType | null = null; + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -762,6 +772,27 @@ class App extends React.Component { this.actionManager.registerAction(createRedoAction(this.history)); } + // setState: React.Component["setState"] = ( + // state, + // callback?, + // ) => { + // let newState: Parameters[0] = null; + // if (typeof state === "function") { + // newState = state(this.state, this.props) as Pick< + // AppState, + // keyof AppState + // >; + // } else { + // newState = state as Pick; + // } + + // if (newState && Object.hasOwn(newState, "selectedLinearElement")) { + // console.trace(!!newState.selectedLinearElement); + // } + + // super.setState(newState, callback); + // }; + updateEditorAtom = ( atom: WritableAtom, ...args: Args @@ -4398,6 +4429,19 @@ class App extends React.Component { return; } + // Handle Alt key for bind mode + if (event.key === KEYS.ALT && this.state.bindMode === "orbit") { + // Cancel any pending bind mode timer + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + // Immediately switch to skip bind mode + this.setState({ + bindMode: "skip", + }); + } + if (this.actionManager.handleKeyDown(event)) { return; } @@ -4407,6 +4451,10 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } this.setState({ isBindingEnabled: false }); } @@ -4424,7 +4472,9 @@ class App extends React.Component { const arrowIdsToRemove = new Set(); selectedElements - .filter(isElbowArrow) + .filter((el): el is NonDeleted => + isBindingElement(el), + ) .filter((arrow) => { const startElementNotInSelection = arrow.startBinding && @@ -4482,7 +4532,7 @@ class App extends React.Component { }); this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( + suggestedBindings: getSuggestedBindingsForBindingElements( selectedElements.filter( (element) => element.id !== elbowArrow?.id || step !== 0, ), @@ -4693,17 +4743,95 @@ class App extends React.Component { } isHoldingSpace = false; } + if ( + (event.key === KEYS.ALT && this.state.bindMode === "skip") || + (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) + ) { + // Handle Alt key release for bind mode + this.setState({ + bindMode: "orbit", + }); + + // Restart the timer if we're creating/editing a linear element and hovering over an element + if (this.lastPointerMoveEvent) { + const scenePointer = viewportCoordsToSceneCoords( + { + clientX: this.lastPointerMoveEvent.clientX, + clientY: this.lastPointerMoveEvent.clientY, + }, + this.state, + ); + + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointer.x, scenePointer.y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + this.state.zoom, + ); + + if (hoveredElement && !this.bindModeHandler) { + this.bindModeHandler = setTimeout(() => { + if (hoveredElement) { + this.setState({ + bindMode: "inside", + }); + } + this.bindModeHandler = null; + }, BIND_MODE_TIMEOUT); + } + } + } if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { this.setState({ isBindingEnabled: true }); } if (isArrowKey(event.key)) { - bindOrUnbindLinearElements( - this.scene.getSelectedElements(this.state).filter(isLinearElement), - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], + bindOrUnbindBindingElements( + this.scene.getSelectedElements(this.state).filter(isArrowElement), this.scene, - this.state.zoom, + this.state, ); + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + this.scene + .getSelectedElements(this.state) + .filter(isSimpleArrow) + .forEach((element) => { + // Update the fixed point bindings for non-elbow arrows + // when the pointer is released, so that they are correctly positioned + // after the drag. + if (element.startBinding) { + this.scene.mutateElement(element, { + startBinding: { + ...element.startBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement, + "start", + elementsMap, + ), + }, + }); + } + if (element.endBinding) { + this.scene.mutateElement(element, { + endBinding: { + ...element.endBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement, + "end", + elementsMap, + ), + }, + }); + } + }); + this.setState({ suggestedBindings: [] }); } @@ -5839,6 +5967,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5878,6 +6008,10 @@ class App extends React.Component { const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; if ( !this.state.newElement && @@ -5930,15 +6064,14 @@ class App extends React.Component { this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement.isDragging ) { - const editingLinearElement = LinearElementEditor.handlePointerMove( - event, - scenePointerX, - scenePointerY, - this, - ); - const linearElement = editingLinearElement - ? this.scene.getElement(editingLinearElement.elementId) - : null; + const editingLinearElement = this.state.newElement + ? null + : LinearElementEditor.handlePointerMove( + event, + scenePointerX, + scenePointerY, + this, + ); if ( editingLinearElement && @@ -5953,18 +6086,6 @@ class App extends React.Component { }); }); } - if ( - editingLinearElement?.lastUncommittedPoint != null && - linearElement && - isBindingElementType(linearElement.type) - ) { - this.maybeSuggestBindingAtCursor( - scenePointer, - editingLinearElement.elbowed, - ); - } else if (this.state.suggestedBindings.length) { - this.setState({ suggestedBindings: [] }); - } } if (isBindingElementType(this.state.activeTool.type)) { @@ -5973,24 +6094,21 @@ class App extends React.Component { const { newElement } = this.state; if (isBindingElement(newElement, false)) { this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( + suggestedBindings: maybeSuggestBindingsForBindingElementAtCoords( newElement, - [scenePointer], + "end", this.scene, this.state.zoom, - this.state.startBoundElement, ), }); } else { - this.maybeSuggestBindingAtCursor(scenePointer, false); + this.maybeSuggestBindingAtCursor(scenePointer); } } if (this.state.multiElement) { const { multiElement } = this.state; - const { x: rx, y: ry } = multiElement; - - const { points, lastCommittedPoint } = multiElement; + const { x: rx, y: ry, points, lastCommittedPoint } = multiElement; const lastPoint = points[points.length - 1]; setCursorForShape(this.interactiveCanvas, this.state); @@ -6036,17 +6154,15 @@ class App extends React.Component { { informMutation: false, isDragging: false }, ); } else { - const [gridX, gridY] = getGridPoint( - scenePointerX, - scenePointerY, - event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement) - ? null - : this.getEffectiveGridSize(), - ); - const [lastCommittedX, lastCommittedY] = multiElement?.lastCommittedPoint ?? [0, 0]; + // Handle grid snapping + const [gridX, gridY] = getGridPoint( + scenePointerX, + scenePointerY, + event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), + ); let dxFromLastCommitted = gridX - rx - lastCommittedX; let dyFromLastCommitted = gridY - ry - lastCommittedY; @@ -6066,17 +6182,127 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } + // Update arrow points + const elementsMap = this.scene.getNonDeletedElementsMap(); + let startGlobalPoint = + this.state.selectedLinearElement?.pointerDownState + ?.arrowOriginalStartPoint ?? + LinearElementEditor.getPointAtIndexGlobalCoordinates( + multiElement, + 0, + elementsMap, + ); + let endLocalPoint = pointFrom( + lastCommittedX + dxFromLastCommitted, + lastCommittedY + dyFromLastCommitted, + ); + let startBinding = multiElement.startBinding; + + if (isBindingElement(multiElement) && !isElbowArrow(multiElement)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + elementsMap, + this.state.zoom, + ); + + // Timed bind mode handler for arrow elements + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler && !hoveredElement) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } else if (!this.bindModeHandler && hoveredElement) { + this.bindModeHandler = setTimeout(() => { + if (hoveredElement) { + flushSync(() => { + this.setState({ + bindMode: "inside", + selectedLinearElement: this.state.selectedLinearElement + ? { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement + .pointerDownState, + arrowStartIsInside: true, + }, + } + : null, + }); + }); + this.scene.mutateElement(multiElement, { + points: [ + ...multiElement.points.slice(0, -1), + pointFrom( + this.lastPointerMoveCoords!.x - multiElement.x, + this.lastPointerMoveCoords!.y - multiElement.y, + ), + ], + }); + } + + this.bindModeHandler = null; + }, BIND_MODE_TIMEOUT); + } + } else if (!hoveredElement) { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + + const point = pointFrom( + scenePointerX - rx, + scenePointerY - ry, + ); + const { start, end } = + getBindingStrategyForDraggingBindingElementEndpoints( + multiElement, + new Map([ + [multiElement.points.length - 1, { point, isDragging: true }], + ]), + elementsMap, + this.scene.getNonDeletedElements(), + this.state, + { newArrow: !!this.state.newElement }, + ); + + if (start.mode) { + startBinding = { + elementId: start.element.id, + mode: start.mode, + ...calculateFixedPointForNonElbowArrowBinding( + multiElement, + start.element, + "start", + elementsMap, + ), + }; + } + + [startGlobalPoint, endLocalPoint] = + getStartGlobalEndLocalPointsForSimpleArrowBinding( + multiElement, + start, + end, + startGlobalPoint, + endLocalPoint, + elementsMap, + ); + } + // update last uncommitted point this.scene.mutateElement( multiElement, { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], + x: startGlobalPoint[0], + y: startGlobalPoint[1], + points: [...points.slice(0, -1), endLocalPoint], + startBinding, }, { isDragging: true, @@ -6256,7 +6482,7 @@ class App extends React.Component { }); } else if ( !hitElement || - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(hitElement) || !(hitElement.startBinding || hitElement.endBinding) ) { @@ -6378,7 +6604,7 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { @@ -6392,7 +6618,7 @@ class App extends React.Component { } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrow can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { @@ -6864,6 +7090,22 @@ class App extends React.Component { this.removePointer(event); this.lastPointerUpEvent = event; + // Cancel any pending timeout for bind mode change + if (this.state.bindMode === "inside" || this.state.bindMode === "skip") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // We need this iteration to complete binding and change + // back to orbit mode after that + setTimeout(() => + this.setState({ + bindMode: "orbit", + }), + ); + } + const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, @@ -6967,6 +7209,15 @@ class App extends React.Component { * pointerup handlers manually */ private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + this.setState({ + bindMode: "orbit", + }); + lastPointerUp?.(); this.missingPointerEventCleanupEmitter.trigger(event).clear(); }; @@ -7734,7 +7985,10 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( - pointerDownState.origin, + pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), this.state.zoom, @@ -7924,25 +8178,48 @@ class App extends React.Component { lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } 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( + lastGlobalPoint, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + this.state.zoom, + ); // 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 + (isBindingElement(multiElement) && hoveredElementForBinding) || + (multiElement.points.length > 1 && + lastCommittedPoint && + pointDistance( + pointFrom( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + ), + lastCommittedPoint, + ) < LINE_CONFIRM_THRESHOLD) ) { - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } @@ -8031,35 +8308,99 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => { - const nextSelectedElementIds = { - ...prevState.selectedElementIds, - }; - delete nextSelectedElementIds[element.id]; - return { - selectedElementIds: makeNextSelectedElementIds( - nextSelectedElementIds, - prevState, - ), - }; - }); - this.scene.mutateElement(element, { - points: [...element.points, pointFrom(0, 0)], - }); + + const point = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const boundElement = getHoveredElementForBinding( - pointerDownState.origin, + point, this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + elementsMap, this.state.zoom, - isElbowArrow(element), - isElbowArrow(element), ); + this.scene.mutateElement(element, { + points: [pointFrom(0, 0), pointFrom(0, 0)], + }); + this.scene.insertElement(element); - this.setState({ - newElement: element, - startBoundElement: boundElement, - suggestedBindings: [], + + if (isBindingElement(element)) { + // Do the initial binding so the binding strategy has the initial state + bindOrUnbindBindingElement( + element, + new Map([ + [ + 0, + { + point: pointFrom(0, 0), + isDragging: false, + }, + ], + ]), + this.scene, + this.state, + { newArrow: true }, + ); + } + + if (isSimpleArrow(element)) { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + this.bindModeHandler = setTimeout(() => { + this.setState({ + bindMode: "inside", + selectedLinearElement: this.state.selectedLinearElement + ? { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement?.pointerDownState, + arrowStartIsInside: !!boundElement, + }, + } + : null, + }); + }, BIND_MODE_TIMEOUT); + } + + this.setState((prevState) => { + let linearElementEditor = null; + let nextSelectedElementIds = prevState.selectedElementIds; + if (isBindingElement(element)) { + const linearElement = new LinearElementEditor( + element, + this.scene.getNonDeletedElementsMap(), + ); + linearElementEditor = { + ...linearElement, + pointerDownState: { + ...linearElement.pointerDownState, + arrowOriginalStartPoint: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + }, + }; + nextSelectedElementIds = makeNextSelectedElementIds( + { [element.id]: true }, + prevState, + ); + } + + return { + ...prevState, + bindMode: "orbit", + newElement: element, + startBoundElement: boundElement, + suggestedBindings: boundElement ? [boundElement] : [], + selectedElementIds: nextSelectedElementIds, + selectedLinearElement: linearElementEditor, + }; }); } }; @@ -8353,26 +8694,6 @@ class App extends React.Component { 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 - // triggering pointermove) - if ( - !pointerDownState.drag.hasOccurred && - (this.state.activeTool.type === "arrow" || - this.state.activeTool.type === "line") - ) { - if ( - pointDistance( - pointFrom(pointerCoords.x, pointerCoords.y), - pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) * - this.state.zoom.value < - MINIMUM_ARROW_SIZE - ) { - return; - } - } if (pointerDownState.resize.isResizing) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8432,19 +8753,154 @@ class App extends React.Component { return; } + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + let [x, y] = [pointerCoords.x, pointerCoords.y]; + + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + elementsMap, + this.state.zoom, + ); + + // Timed bind mode handler for arrow elements + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler && !hoveredElement) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } else if (!this.bindModeHandler && hoveredElement) { + this.bindModeHandler = setTimeout(() => { + if (hoveredElement) { + flushSync(() => { + this.setState({ + bindMode: "inside", + selectedLinearElement: this.state.selectedLinearElement + ? { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement + .pointerDownState, + arrowStartIsInside: true, + }, + } + : null, + }); + }); + + const [lastX, lastY] = + hoveredElement && element.startBinding?.mode !== "inside" + ? snapToCenter( + hoveredElement, + elementsMap, + pointFrom( + this.lastPointerMoveCoords?.x ?? + pointerDownState.origin.x, + this.lastPointerMoveCoords?.y ?? + pointerDownState.origin.y, + ), + ) + : [ + this.lastPointerMoveCoords?.x ?? + pointerDownState.origin.x, + this.lastPointerMoveCoords?.y ?? + pointerDownState.origin.y, + ]; + + const newState = LinearElementEditor.handlePointDragging( + event, + this, + lastX, + lastY, + linearElementEditor, + ); + if (newState) { + pointerDownState.lastCoords.x = + this.lastPointerMoveCoords?.x ?? + pointerDownState.origin.x; + pointerDownState.lastCoords.y = + this.lastPointerMoveCoords?.y ?? + pointerDownState.origin.y; + pointerDownState.drag.hasOccurred = true; + + flushSync(() => { + this.setState(newState); + }); + } + const selectedPointIndices = + this.state.selectedLinearElement?.selectedPointsIndices; + const nextPoint = pointFrom( + (this.lastPointerMoveCoords?.x ?? + pointerDownState.origin.x) - element.x, + (this.lastPointerMoveCoords?.y ?? + pointerDownState.origin.y) - element.y, + ); + if ( + selectedPointIndices?.length === 1 && + selectedPointIndices[0] === 0 + ) { + this.scene.mutateElement(element, { + points: [nextPoint, ...element.points.slice(1)], + }); + } else { + this.scene.mutateElement(element, { + points: [...element.points.slice(0, -1), nextPoint], + }); + } + } + + this.bindModeHandler = null; + }, BIND_MODE_TIMEOUT); + } + } else if (!hoveredElement) { + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + + [x, y] = + hoveredElement && element.startBinding?.mode !== "inside" + ? snapToCenter( + hoveredElement, + elementsMap, + pointFrom(pointerCoords.x, pointerCoords.y), + ) + : [pointerCoords.x, pointerCoords.y]; + } + const newState = LinearElementEditor.handlePointDragging( event, this, - pointerCoords.x, - pointerCoords.y, + x, + y, linearElementEditor, ); if (newState) { - pointerDownState.lastCoords.x = pointerCoords.x; - pointerDownState.lastCoords.y = pointerCoords.y; + pointerDownState.lastCoords.x = x; + pointerDownState.lastCoords.y = y; pointerDownState.drag.hasOccurred = true; - this.setState(newState); + if ( + !isShallowEqual( + newState.suggestedBindings ?? [], + this.state.suggestedBindings, + ) || + !isShallowEqual( + newState.selectedLinearElement?.selectedPointsIndices ?? [], + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ) || + newState.selectedLinearElement?.hoverPointIndex !== + this.state.selectedLinearElement?.hoverPointIndex || + newState.selectedLinearElement?.customLineAngle !== + this.state.selectedLinearElement?.customLineAngle + ) { + this.setState(newState); + } return; } @@ -8682,7 +9138,7 @@ class App extends React.Component { !isElbowArrow(selectedElements[0]) ) { this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( + suggestedBindings: getSuggestedBindingsForBindingElements( selectedElements, this.scene.getNonDeletedElementsMap(), this.state.zoom, @@ -8908,34 +9364,77 @@ class App extends React.Component { } else if (isLinearElement(newElement)) { pointerDownState.drag.hasOccurred = true; const points = newElement.points; - let dx = gridX - newElement.x; - let dy = gridY - newElement.y; - if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { - ({ width: dx, height: dy } = getLockedLinearCursorAlignSize( - newElement.x, - newElement.y, - pointerCoords.x, - pointerCoords.y, - )); + // Update arrow points + let startBinding = newElement.startBinding; + let startGlobalPoint = + this.state.selectedLinearElement?.pointerDownState + ?.arrowOriginalStartPoint ?? + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + let endLocalPoint = pointFrom( + gridX - newElement.x, + gridY - newElement.y, + ); + + // Simple arrows need both their start and end points adjusted + if (isBindingElement(newElement) && !isElbowArrow(newElement)) { + const point = pointFrom( + pointerCoords.x - newElement.x, + pointerCoords.y - newElement.y, + ); + const { start, end } = + getBindingStrategyForDraggingBindingElementEndpoints( + newElement, + new Map([ + [newElement.points.length - 1, { point, isDragging: true }], + ]), + elementsMap, + this.scene.getNonDeletedElements(), + this.state, + { newArrow: !!this.state.newElement }, + ); + + if (start.mode) { + startBinding = { + elementId: start.element.id, + mode: start.mode, + ...calculateFixedPointForNonElbowArrowBinding( + newElement, + start.element, + "start", + elementsMap, + ), + }; + } + + [startGlobalPoint, endLocalPoint] = + getStartGlobalEndLocalPointsForSimpleArrowBinding( + newElement, + start, + end, + startGlobalPoint, + endLocalPoint, + elementsMap, + ); } - if (points.length === 1) { + invariant( + points.length > 1, + "Do not create linear elements with less than 2 points", + ); + + if (isElbowArrow(newElement) || points.length === 2) { this.scene.mutateElement( newElement, { - points: [...points, pointFrom(dx, dy)], - }, - { informMutation: false, isDragging: false }, - ); - } else if ( - points.length === 2 || - (points.length > 1 && isElbowArrow(newElement)) - ) { - this.scene.mutateElement( - newElement, - { - points: [...points.slice(0, -1), pointFrom(dx, dy)], + x: startGlobalPoint[0], + y: startGlobalPoint[1], + points: [pointFrom(0, 0), endLocalPoint], + startBinding, }, { isDragging: true, informMutation: false }, ); @@ -8948,12 +9447,11 @@ class App extends React.Component { if (isBindingElement(newElement, false)) { // When creating a linear element by dragging this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( + suggestedBindings: maybeSuggestBindingsForBindingElementAtCoords( newElement, - [pointerCoords], + "end", this.scene, this.state.zoom, - this.state.startBoundElement, ), }); } @@ -9107,6 +9605,8 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + this.removePointer(childEvent); pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { @@ -9190,10 +9690,15 @@ class App extends React.Component { }); } + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + this.setState({ selectedElementsAreBeingDragged: false, + bindMode: "orbit", }); - const elementsMap = this.scene.getNonDeletedElementsMap(); if ( pointerDownState.drag.hasOccurred && @@ -9214,7 +9719,10 @@ class App extends React.Component { // Handle end of dragging a point of a linear element, might close a loop // and sets binding element - if (this.state.selectedLinearElement?.isEditing) { + if ( + this.state.selectedLinearElement?.isEditing && + !this.state.newElement + ) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== @@ -9228,6 +9736,10 @@ class App extends React.Component { this.state, this.scene, ); + this.actionManager.executeAction(actionFinalize, "ui", { + event: childEvent, + sceneCoords, + }); if (editingLinearElement !== this.state.selectedLinearElement) { this.setState({ selectedLinearElement: editingLinearElement, @@ -9325,7 +9837,11 @@ class App extends React.Component { } if (isLinearElement(newElement)) { - if (newElement!.points.length > 1) { + if ( + newElement!.points.length > 1 && + newElement.points[1][0] !== 0 && + newElement.points[1][1] !== 0 + ) { this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( @@ -9371,7 +9887,7 @@ class App extends React.Component { this.scene.mutateElement( newElement, { - points: [...newElement.points, pointFrom(dx, dy)], + points: [newElement.points[0], pointFrom(dx, dy)], }, { informMutation: false, isDragging: false }, ); @@ -9382,10 +9898,7 @@ class App extends React.Component { }); } } else if (pointerDownState.drag.hasOccurred && !multiElement) { - if ( - isBindingEnabled(this.state) && - isBindingElement(newElement, false) - ) { + if (isBindingElement(newElement, false)) { this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, @@ -9986,15 +10499,9 @@ class App extends React.Component { // the endpoints ("start" or "end"). const linearElements = this.scene .getSelectedElements(this.state) - .filter(isLinearElement); + .filter(isArrowElement); - bindOrUnbindLinearElements( - linearElements, - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], - this.scene, - this.state.zoom, - ); + bindOrUnbindBindingElements(linearElements, this.scene, this.state); } if (activeTool.type === "laser") { @@ -10052,6 +10559,67 @@ class App extends React.Component { private eraseElements = () => { let didChange = false; + + // Binding is double accounted on both elements and if one of them is + // deleted, the binding should be removed + this.elementsPendingErasure.forEach((id) => { + const element = this.scene.getElement(id); + if (isBindingElement(element)) { + if (element.startBinding) { + const bindable = this.scene.getElement( + element.startBinding.elementId, + )!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + if (element.endBinding) { + const bindable = this.scene.getElement(element.endBinding.elementId)!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + } else if (isBindableElement(element)) { + element.boundElements?.forEach((boundElement) => { + if (boundElement.type === "arrow") { + const arrow = this.scene.getElement( + boundElement.id, + ) as ExcalidrawArrowElement; + if (arrow?.startBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + startBinding: null, + }, + ); + } + if (arrow?.endBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + endBinding: null, + }, + ); + } + } + }); + } + }); + const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( this.elementsPendingErasure.has(ele.id) || @@ -10426,20 +10994,15 @@ class App extends React.Component { } }; - private maybeSuggestBindingAtCursor = ( - pointerCoords: { - x: number; - y: number; - }, - considerAll: boolean, - ): void => { + private maybeSuggestBindingAtCursor = (pointerCoords: { + x: number; + y: number; + }): void => { const hoveredBindableElement = getHoveredElementForBinding( - pointerCoords, + pointFrom(pointerCoords.x, pointerCoords.y), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), this.state.zoom, - false, - considerAll, ); this.setState({ suggestedBindings: @@ -10933,12 +11496,7 @@ class App extends React.Component { ), ); - updateBoundElements(croppingElement, this.scene, { - newSize: { - width: croppingElement.width, - height: croppingElement.height, - }, - }); + updateBoundElements(croppingElement, this.scene); this.setState({ isCropping: transformHandleType && transformHandleType !== "rotation", @@ -11064,7 +11622,7 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - const suggestedBindings = getSuggestedBindingsForArrows( + const suggestedBindings = getSuggestedBindingsForBindingElements( selectedElements, this.scene.getNonDeletedElementsMap(), this.state.zoom, @@ -11388,6 +11946,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11407,6 +11967,7 @@ declare global { elements: readonly ExcalidrawElement[]; state: AppState; setState: React.Component["setState"]; + watchState: (prev: any, next: any) => void | undefined; app: InstanceType; history: History; store: Store; diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3c6f110d2..d64a7001a 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -961,7 +961,7 @@ const CommandItem = ({ diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 957d69927..3eed838ce 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -1,6 +1,5 @@ import type { ActionManager } from "../../actions/manager"; import type { Action } from "../../actions/types"; -import type { UIAppState } from "../../types"; export type CommandPaletteItem = { label: string; @@ -12,7 +11,7 @@ export type CommandPaletteItem = { * (deburred name + keywords) */ haystack?: string; - icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode); + icon?: Action["icon"]; category: string; order?: number; predicate?: boolean | Action["predicate"]; diff --git a/packages/excalidraw/components/ConvertElementTypePopup.tsx b/packages/excalidraw/components/ConvertElementTypePopup.tsx index 8e527d549..596456671 100644 --- a/packages/excalidraw/components/ConvertElementTypePopup.tsx +++ b/packages/excalidraw/components/ConvertElementTypePopup.tsx @@ -844,7 +844,7 @@ const convertElementType = < }), ) as typeof element; - updateBindings(nextElement, app.scene); + updateBindings(nextElement, app.scene, app.state); return nextElement; } diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 773f86888..c79e9bb3b 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -34,6 +34,7 @@ const handleDegreeChange: DragInputCallbackType = ({ shouldChangeByStepSize, nextValue, scene, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -48,7 +49,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -74,7 +75,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 539a2ad59..4680858dc 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -94,9 +94,7 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, scene, { - newSize: { width: updates.width, height: updates.height }, - }); + updateBoundElements(latestElement, scene); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { scene.mutateElement(latestBoundTextElement, { diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 19b52e2f4..35f6cfb89 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -38,6 +38,7 @@ const moveElements = ( originalElements: readonly ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { for (let i = 0; i < originalElements.length; i++) { const origElement = originalElements[i]; @@ -63,6 +64,7 @@ const moveElements = ( newTopLeftY, origElement, scene, + appState, originalElementsMap, false, ); @@ -75,6 +77,7 @@ const moveGroupTo = ( originalElements: ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { const elementsMap = scene.getNonDeletedElementsMap(); const [x1, y1, ,] = getCommonBounds(originalElements); @@ -107,6 +110,7 @@ const moveGroupTo = ( topLeftY + offsetY, origElement, scene, + appState, originalElementsMap, false, ); @@ -125,6 +129,7 @@ const handlePositionChange: DragInputCallbackType< property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -152,6 +157,7 @@ const handlePositionChange: DragInputCallbackType< elementsInUnit.map((el) => el.original), originalElementsMap, scene, + app.state, ); } else { const origElement = elementsInUnit[0]?.original; @@ -178,6 +184,7 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, origElement, scene, + app.state, originalElementsMap, false, ); @@ -203,6 +210,7 @@ const handlePositionChange: DragInputCallbackType< originalElements, originalElementsMap, scene, + app.state, ); scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index f89ce2615..8b5718330 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -34,6 +34,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -131,6 +132,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); return; @@ -162,6 +164,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); }; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index bcfab8520..47fcd64be 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -4,9 +4,9 @@ import throttle from "lodash.throttle"; import { useEffect, useMemo, useState, memo } from "react"; import { STATS_PANELS } from "@excalidraw/common"; -import { getCommonBounds } from "@excalidraw/element"; +import { getCommonBounds, isBindingElement } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element"; -import { isElbowArrow, isImageElement } from "@excalidraw/element"; +import { isImageElement } from "@excalidraw/element"; import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; @@ -333,7 +333,7 @@ export const StatsInner = memo( appState={appState} /> - {!isElbowArrow(singleElement) && ( + {!isBindingElement(singleElement) && ( { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("204")); - expect(linear.startBinding).not.toBe(null); - }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("1")); + UI.updateInput(inputX, String("186")); expect(linear.startBinding).not.toBe(null); }); @@ -161,17 +150,6 @@ describe("binding with linear elements", () => { UI.updateInput(inputX, String("254")); expect(linear.startBinding).toBe(null); }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("45")); - expect(linear.startBinding).toBe(null); - }); }); // single element diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 68d202098..762826184 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,6 +1,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; -import { getBoundTextElement } from "@excalidraw/element"; +import { + getBoundTextElement, + isBindingElement, + unbindBindingElement, +} from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element"; import { @@ -12,6 +16,7 @@ import { import { getFrameChildren } from "@excalidraw/element"; import { updateBindings } from "@excalidraw/element"; +import { DRAGGING_THRESHOLD } from "@excalidraw/common"; import type { Radians } from "@excalidraw/math"; @@ -110,9 +115,25 @@ export const moveElement = ( newTopLeftY: number, originalElement: ExcalidrawElement, scene: Scene, + appState: AppState, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + if ( + isBindingElement(originalElement) && + (originalElement.startBinding || originalElement.endBinding) + ) { + if ( + Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD && + Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD + ) { + return; + } + + unbindBindingElement(originalElement, "start", scene); + unbindBindingElement(originalElement, "end", scene); + } + const elementsMap = scene.getNonDeletedElementsMap(); const latestElement = elementsMap.get(originalElement.id); if (!latestElement) { @@ -145,7 +166,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, appState); const boundTextElement = getBoundTextElement( originalElement, @@ -203,7 +224,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestChildElement, scene, { + updateBindings(latestChildElement, scene, appState, { simultaneouslyUpdated: originalChildren, }); }); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c375a2b16..1ff0ddbe7 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -201,6 +201,7 @@ const getRelevantAppStateProps = ( selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, + newElement: appState.newElement, isBindingEnabled: appState.isBindingEnabled, suggestedBindings: appState.suggestedBindings, isRotating: appState.isRotating, diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index f00a51817..cd95bedf9 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": -0.007519379844961235, - "gap": 11.562288374879595, + "fixedPoint": [ + 0.04, + 0.4633333333333333, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -118,8 +121,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "id49", - "focus": -0.0813953488372095, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1864ab", "strokeStyle": "solid", @@ -144,8 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": 0.10666666666666667, - "gap": 3.8343264684446097, + "fixedPoint": [ + -0.01, + 0.44666666666666666, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -174,8 +183,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "diamond-1", - "focus": 0, - "gap": 4.535423522449215, + "fixedPoint": [ + 0.9357142857142857, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -334,8 +346,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", - "focus": 0, - "gap": 16, + "fixedPoint": [ + -2.05, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -364,8 +379,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startArrowhead": null, "startBinding": { "elementId": "text-1", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -436,8 +454,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "endArrowhead": "arrow", "endBinding": { "elementId": "id42", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -466,8 +487,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startArrowhead": null, "startBinding": { "elementId": "id41", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -612,8 +636,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "endArrowhead": "arrow", "endBinding": { "elementId": "id46", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -642,8 +669,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startArrowhead": null, "startBinding": { "elementId": "id45", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1476,8 +1506,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "Alice", - "focus": -0, - "gap": 5.299874999999986, + "fixedPoint": [ + -0.07542628418945944, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1508,8 +1541,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1.000004978564514, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1539,8 +1575,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "B", - "focus": 0, - "gap": 32, + "fixedPoint": [ + 0.46387050630528887, + 0.48466257668711654, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1567,8 +1606,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 0.39381496335223337, + 1, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57..7970ba483 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -32,7 +32,6 @@ import { isArrowBoundToElement, isArrowElement, isElbowArrow, - isFixedPointBinding, isLinearElement, isLineElement, isTextElement, @@ -61,7 +60,6 @@ import type { FontFamilyValues, NonDeletedSceneElementsMap, OrderedExcalidrawElement, - PointBinding, StrokeRoundness, } from "@excalidraw/element/types"; @@ -123,36 +121,29 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { const repairBinding = ( element: T, - binding: PointBinding | FixedPointBinding | null, -): T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null => { + binding: FixedPointBinding | null, +): FixedPointBinding | null => { if (!binding) { return null; } - const focus = binding.focus || 0; - if (isElbowArrow(element)) { const fixedPointBinding: | ExcalidrawElbowArrowElement["startBinding"] - | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding) - ? { - ...binding, - focus, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), - } - : null; + | ExcalidrawElbowArrowElement["endBinding"] = { + ...binding, + fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + mode: binding.mode || "orbit", + }; return fixedPointBinding; } return { - ...binding, - focus, - } as T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null; + elementId: binding.elementId, + mode: binding.mode || "orbit", + fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.51, 0.51]), + } as FixedPointBinding | null; }; const restoreElementWithProperties = < diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0d9fcf316..b620abfe5 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -432,12 +432,9 @@ describe("Test Transform", () => { boundElements: [{ id: text.id, type: "text" }], startBinding: { elementId: rectangle.id, - focus: 0, - gap: 1, }, endBinding: { elementId: ellipse.id, - focus: -0, }, }); @@ -517,12 +514,9 @@ describe("Test Transform", () => { boundElements: [{ id: text1.id, type: "text" }], startBinding: { elementId: text2.id, - focus: 0, - gap: 1, }, endBinding: { elementId: text3.id, - focus: -0, }, }); @@ -780,8 +774,8 @@ describe("Test Transform", () => { const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", - focus: -0, - gap: 25, + fixedPoint: [-2.05, 0.5001], + mode: "orbit", }); expect(rect.boundElements).toStrictEqual([ { diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index fd0d3388f..5b9f67e65 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -16,7 +16,7 @@ import { getLineHeight, } from "@excalidraw/common"; -import { bindLinearElement } from "@excalidraw/element"; +import { bindBindingElement } from "@excalidraw/element"; import { newArrowElement, newElement, @@ -330,9 +330,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, startBoundElement as ExcalidrawBindableElement, + "orbit", "start", scene, ); @@ -405,9 +406,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, endBoundElement as ExcalidrawBindableElement, + "orbit", "end", scene, ); diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index e9b6c3f96..4d6bbbb6c 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -101,7 +101,10 @@ declare module "image-blob-reduce" { interface CustomMatchers { toBeNonNaNNumber(): void; - toCloselyEqualPoints(points: readonly [number, number][]): void; + toCloselyEqualPoints( + points: readonly [number, number][], + precision?: number, + ): void; } declare namespace jest { diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index e071d47aa..dc1411b4e 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -16,7 +16,10 @@ import { throttleRAF, } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element"; +import { + FIXED_BINDING_DISTANCE, + maxBindingDistanceFromOutline, +} from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { getOmitSidesForDevice, @@ -194,7 +197,12 @@ const renderBindingHighlightForBindableElement = ( elementsMap: ElementsMap, zoom: InteractiveCanvasAppState["zoom"], ) => { - const padding = maxBindingGap(element, element.width, element.height, zoom); + const padding = maxBindingDistanceFromOutline( + element, + element.width, + element.height, + zoom, + ); context.fillStyle = "rgba(0,0,0,.05)"; @@ -245,7 +253,7 @@ const renderBindingHighlightForSuggestedPointBinding = ( ) => { const [element, startOrEnd, bindableElement] = suggestedBinding; - const threshold = maxBindingGap( + const threshold = maxBindingDistanceFromOutline( bindableElement, bindableElement.width, bindableElement.height, @@ -891,7 +899,11 @@ const _renderInteractiveScene = ({ } // Paint selected elements - if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) { + if ( + !appState.multiElement && + !appState.newElement && + !appState.selectedLinearElement?.isEditing + ) { const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); const isSingleLinearElementSelected = diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index a7fe59644..962a6e957 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -11,6 +11,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -1082,6 +1083,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1294,6 +1296,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1623,6 +1626,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1952,6 +1956,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2164,6 +2169,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2403,6 +2409,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3069,6 +3077,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3560,6 +3569,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3881,6 +3891,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4202,6 +4213,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4611,6 +4623,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5826,6 +5839,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7092,6 +7106,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7757,6 +7772,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8746,6 +8762,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 2f9e04d56..47fe286a4 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -11,6 +11,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -118,7 +119,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -137,7 +143,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 13, + "version": 5, "width": 100, "x": -100, "y": -50, @@ -167,7 +173,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 4, "width": 100, "x": 100, "y": -50, @@ -182,25 +188,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id15", - "fixedPoint": [ - "0.50000", - 1, - ], - "focus": 0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "99.19972", + "height": "0.03787", "id": "id4", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -208,8 +207,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.40611", - "99.19972", + 95, + "0.03787", ], ], "roughness": 1, @@ -217,57 +216,29 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 35, - "width": "98.40611", - "x": 1, - "y": 0, + "version": 16, + "width": 95, + "x": 5, + "y": "0.01199", } `; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] element 3 1`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 50, - "id": "id15", - "index": "a3", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 10, - "width": 50, - "x": 100, - "y": 100, -} -`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `4`; - -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `21`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `12`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] redo stack 1`] = ` [ @@ -282,18 +253,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "version": 12, - }, - "inserted": { - "version": 11, - }, - }, "id1": { "deleted": { "boundElements": [], - "version": 9, + "version": 4, }, "inserted": { "boundElements": [ @@ -302,81 +265,67 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 8, - }, - }, - "id15": { - "deleted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 9, - }, - "inserted": { - "boundElements": [], - "version": 8, + "version": 3, }, }, "id4": { "deleted": { - "endBinding": { - "elementId": "id15", - "fixedPoint": [ - "0.50000", - 1, - ], - "focus": 0, - "gap": 1, - }, - "height": "68.58402", + "endBinding": null, + "height": "0.88851", "points": [ [ 0, 0, ], [ - 98, - "68.58402", + 90, + "0.88851", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02970", - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 33, + "version": 14, }, "inserted": { "endBinding": { "elementId": "id1", - "focus": "-0.02000", - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, - "height": "0.00656", + "height": "0.00047", "points": [ [ 0, 0, ], [ - "98.00000", - "-0.00656", + 90, + "0.00047", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02000", - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 30, + "version": 12, }, }, }, }, - "id": "id22", + "id": "id17", }, { "appState": AppStateDelta { @@ -389,70 +338,57 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "boundElements": [], - "version": 13, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 12, - }, - }, - "id15": { - "deleted": { - "version": 10, - }, - "inserted": { - "version": 9, - }, - }, "id4": { "deleted": { - "height": "99.19972", + "height": "0.03787", "points": [ [ 0, 0, ], [ - "98.40611", - "99.19972", - ], - ], - "startBinding": null, - "version": 35, - "y": 0, - }, - "inserted": { - "height": "68.58402", - "points": [ - [ - 0, - 0, - ], - [ - 98, - "68.58402", + 95, + "0.03787", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02970", - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 33, - "y": "35.82151", + "version": 16, + "width": 95, + }, + "inserted": { + "height": "0.88851", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "0.88851", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 14, + "width": 90, }, }, }, }, - "id": "id23", + "id": "id18", }, ] `; @@ -591,26 +527,179 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 4, + "version": 6, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 3, + "version": 5, + }, + }, + }, + "updated": { + "id0": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + "inserted": { + "boundElements": [], + "version": 2, }, }, }, - "updated": {}, }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 7, + "width": 95, + "x": 5, + "y": "0.95000", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 6, + "width": 100, + "x": 0, + "y": 0, + }, + }, + }, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.00950", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.00950", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 9, + "y": "0.00950", + }, + "inserted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 7, + "y": "0.95000", + }, + }, + }, + }, + "id": "id11", + }, ] `; @@ -625,6 +714,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -732,7 +822,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -751,9 +846,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 14, + "version": 5, "width": 100, - "x": 150, + "x": -100, "y": -50, } `; @@ -781,9 +876,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 4, "width": 100, - "x": 150, + "x": 100, "y": -50, } `; @@ -800,13 +895,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.88851", "id": "id4", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -814,8 +910,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 0, - 0, + 90, + "0.88851", ], ], "roughness": 1, @@ -823,22 +919,29 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 31, - "width": 0, - "x": 149, - "y": 0, + "version": 18, + "width": 90, + "x": 5, + "y": "0.05936", } `; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of renders 1`] = `23`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of renders 1`] = `14`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] redo stack 1`] = ` [ @@ -853,18 +956,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "version": 13, - }, - "inserted": { - "version": 12, - }, - }, "id1": { "deleted": { "boundElements": [], - "version": 9, + "version": 4, }, "inserted": { "boundElements": [ @@ -873,21 +968,62 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", }, ], - "version": 8, + "version": 3, }, }, "id4": { "deleted": { "endBinding": null, - "version": 30, + "height": "0.00900", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "-0.00900", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 16, }, "inserted": { "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, - "version": 28, + "height": "0.04676", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "-0.04676", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 14, }, }, }, @@ -905,33 +1041,52 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "added": {}, "removed": {}, "updated": { - "id0": { - "deleted": { - "boundElements": [], - "version": 14, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 13, - }, - }, "id4": { "deleted": { - "startBinding": null, - "version": 31, - }, - "inserted": { + "height": "0.88851", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "0.88851", + ], + ], "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 30, + "version": 18, + "y": "0.05936", + }, + "inserted": { + "height": "0.00900", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "-0.00900", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 16, + "y": "0.00950", }, }, }, @@ -1075,26 +1230,305 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 4, + "version": 6, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 3, + "version": 5, + }, + }, + }, + "updated": { + "id0": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + "inserted": { + "boundElements": [], + "version": 2, }, }, }, - "updated": {}, }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 7, + "width": 95, + "x": 5, + "y": "0.95000", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 6, + "width": 100, + "x": 0, + "y": 0, + }, + }, + }, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 95, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 8, + "y": 0, + }, + "inserted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 7, + "y": "0.95000", + }, + }, + }, + }, + "id": "id11", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.00950", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.00950", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 10, + "y": "0.00950", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 95, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 8, + "y": 0, + }, + }, + }, + }, + "id": "id13", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.93837", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "0.93837", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 11, + "width": 90, + }, + "inserted": { + "height": "0.00950", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.00950", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 10, + "width": 95, + }, + }, + }, + }, + "id": "id16", + }, ] `; @@ -1109,6 +1543,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1225,19 +1660,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "30.01725", "id": "id4", "index": "Zz", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1245,8 +1680,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "30.01725", ], ], "roughness": 1, @@ -1258,8 +1693,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1267,9 +1701,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "1.67622", } `; @@ -1433,8 +1867,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "startBinding": { "elementId": "id0", @@ -1442,8 +1875,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "version": 10, }, @@ -1471,6 +1903,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1587,19 +2020,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "15.64048", "id": "id5", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1607,8 +2040,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "-15.64048", ], ], "roughness": 1, @@ -1620,8 +2053,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1629,9 +2061,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "37.37707", } `; @@ -1737,13 +2169,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "15.64048", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1756,8 +2187,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "-15.64048", ], ], "roughness": 1, @@ -1769,17 +2200,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "37.37707", }, "inserted": { "isDeleted": true, @@ -1836,6 +2266,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2097,6 +2528,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2278,19 +2710,23 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": "440.95500", "id": "id4", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -2298,8 +2734,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + 490, + "-440.95500", ], ], "roughness": 1, @@ -2309,18 +2745,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 13, + "width": 490, + "x": 5, + "y": "-4.48954", } `; @@ -2440,13 +2879,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": "440.95500", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, @@ -2459,8 +2901,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + 490, + "-440.95500", ], ], "roughness": 1, @@ -2470,21 +2912,24 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 13, + "width": 490, + "x": 5, + "y": "-4.48954", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 10, }, }, }, @@ -2537,6 +2982,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2838,6 +3284,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3155,6 +3602,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3447,6 +3895,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3731,6 +4180,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3964,6 +4414,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4219,6 +4670,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4488,6 +4940,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4715,6 +5168,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4942,6 +5396,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5187,6 +5642,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5441,6 +5897,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5697,6 +6154,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6024,6 +6482,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6449,6 +6908,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6821,6 +7281,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7131,6 +7592,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7274,7 +7736,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 9, + "version": 7, "width": 10, "x": 0, "y": 0, @@ -7283,7 +7745,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] redo stack 1`] = `[]`; @@ -7296,9 +7758,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -7360,28 +7827,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, }, }, - "id": "id13", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id14", + "id": "id10", }, { "appState": AppStateDelta { @@ -7405,7 +7851,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id11", }, { "appState": AppStateDelta { @@ -7429,7 +7875,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id16", + "id": "id12", }, ] `; @@ -7445,6 +7891,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7673,6 +8120,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8023,6 +8471,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8373,6 +8822,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8777,6 +9227,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9062,6 +9513,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9324,6 +9776,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9587,6 +10040,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9817,6 +10271,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10112,6 +10567,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10269,7 +10725,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 12, "width": 30, "x": 0, "y": 0, @@ -10278,7 +10734,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `14`; +exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `12`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] redo stack 1`] = `[]`; @@ -10291,9 +10747,14 @@ exports[`history > multiplayer undo/redo > should override remotely added points "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -10342,20 +10803,20 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 11, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 11, + "version": 10, }, }, }, "updated": {}, }, - "id": "id10", + "id": "id7", }, { "appState": AppStateDelta { @@ -10397,7 +10858,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points 20, ], ], - "version": 13, + "version": 12, "width": 30, }, "inserted": { @@ -10416,34 +10877,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points 10, ], ], - "version": 12, + "version": 11, "width": 10, }, }, }, }, - "id": "id11", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id12", + "id": "id8", }, ] `; @@ -10459,6 +10899,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10693,6 +11134,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10869,8 +11311,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -10908,8 +11349,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -10957,8 +11397,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -10995,8 +11434,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -11139,6 +11577,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11397,6 +11836,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11630,6 +12070,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11865,6 +12306,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12266,6 +12708,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12474,6 +12917,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12682,6 +13126,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12904,6 +13349,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13126,6 +13572,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13369,6 +13816,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13604,6 +14052,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13839,6 +14288,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14084,6 +14534,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14413,6 +14864,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14581,6 +15033,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14863,6 +15316,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15124,6 +15578,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15275,6 +15730,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15555,6 +16011,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15715,6 +16172,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15941,8 +16399,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -15954,6 +16415,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -15961,7 +16423,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -15972,18 +16434,21 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", } `; @@ -16016,12 +16481,44 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": { "id13": { "deleted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", + }, "isDeleted": false, - "version": 10, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 12, }, "inserted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", + }, "isDeleted": true, - "version": 7, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "version": 10, }, }, }, @@ -16041,14 +16538,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "version": 5, }, }, - "id1": { - "deleted": { - "version": 5, - }, - "inserted": { - "version": 4, - }, - }, "id2": { "deleted": { "boundElements": [ @@ -16321,8 +16810,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -16340,7 +16832,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 90, 0, ], ], @@ -16351,21 +16843,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 9, + "width": 90, + "x": 5, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 8, }, }, }, @@ -16418,6 +16913,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16644,8 +17140,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -16657,6 +17156,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -16664,7 +17164,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -16675,18 +17175,21 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", } `; @@ -16946,8 +17449,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -16965,7 +17471,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -16976,21 +17482,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 10, }, }, }, @@ -17051,6 +17560,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17277,8 +17787,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17290,6 +17803,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -17297,7 +17811,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -17308,18 +17822,21 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", } `; @@ -17579,8 +18096,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17598,7 +18118,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -17609,21 +18129,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 10, }, }, }, @@ -17684,6 +18207,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17908,8 +18432,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17921,6 +18448,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -17928,7 +18456,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -17939,18 +18467,21 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 12, + "width": 90, + "x": 5, + "y": "0.01000", } `; @@ -18000,24 +18531,35 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "updated": { "id13": { "deleted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", + }, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 10, + "version": 12, }, "inserted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", + }, "startBinding": null, - "version": 7, - }, - }, - "id2": { - "deleted": { - "version": 4, - }, - "inserted": { - "version": 3, + "version": 10, }, }, }, @@ -18277,8 +18819,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -18296,7 +18841,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 90, 0, ], ], @@ -18307,21 +18852,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 9, + "width": 90, + "x": 5, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 8, }, }, }, @@ -18401,6 +18949,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18628,8 +19177,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -18641,6 +19193,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -18648,7 +19201,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 90, 0, ], ], @@ -18659,18 +19212,21 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": 98, - "x": 1, - "y": 0, + "version": 13, + "width": 90, + "x": 5, + "y": "0.01000", } `; @@ -18733,20 +19289,26 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "deleted": { "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, - "version": 11, + "version": 13, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 8, + "version": 11, }, }, }, @@ -19006,8 +19568,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -19025,7 +19590,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 90, 0, ], ], @@ -19036,21 +19601,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 9, + "width": 90, + "x": 5, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 8, }, }, }, @@ -19150,6 +19718,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19628,6 +20197,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20137,6 +20707,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20594,6 +21165,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20745,7 +21317,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 14, "width": 20, "x": 0, "y": 0, @@ -20754,7 +21326,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`; -exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `20`; +exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `23`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = `[]`; @@ -20767,9 +21339,14 @@ exports[`history > singleplayer undo/redo > should support linear element creati "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -20818,20 +21395,20 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 12, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 12, + "version": 11, }, }, }, "updated": {}, }, - "id": "id23", + "id": "id20", }, { "appState": AppStateDelta { @@ -20864,7 +21441,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 13, "width": 20, }, "inserted": { @@ -20882,34 +21459,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati 10, ], ], - "version": 13, + "version": 12, "width": 10, }, }, }, }, - "id": "id24", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id25", + "id": "id21", }, { "appState": AppStateDelta { @@ -20933,7 +21489,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id26", + "id": "id22", }, { "appState": AppStateDelta { @@ -20963,7 +21519,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 20, ], ], - "version": 15, + "version": 14, }, "inserted": { "height": 10, @@ -20981,12 +21537,12 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 13, }, }, }, }, - "id": "id27", + "id": "id23", }, { "appState": AppStateDelta { @@ -21010,7 +21566,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id28", + "id": "id24", }, ] `; diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 52614ed5f..e47ba06ff 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -126,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = ` "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1006504105, + "versionNonce": 760410951, "width": 100, "x": 0, "y": 0, @@ -163,7 +163,7 @@ exports[`move element > rectangles with binding arrow 6`] = ` "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 1984422985, + "versionNonce": 271613161, "width": 300, "x": 201, "y": 2, @@ -180,19 +180,23 @@ exports[`move element > rectangles with binding arrow 7`] = ` "endArrowhead": "arrow", "endBinding": { "elementId": "id3", - "focus": "-0.46667", - "gap": 10, + "fixedPoint": [ + "-0.01667", + "0.45000", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "81.40630", + "height": "91.98875", "id": "id6", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -200,8 +204,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` 0, ], [ - "81.00000", - "81.40630", + 91, + "91.98875", ], ], "roughness": 1, @@ -212,18 +216,21 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": "-0.60000", - "gap": 10, + "fixedPoint": [ + "1.05000", + "0.45011", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "versionNonce": 1573789895, - "width": "81.00000", - "x": "110.00000", - "y": 50, + "version": 14, + "versionNonce": 651223591, + "width": 91, + "x": 105, + "y": "45.01062", } `; diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index ee3f02490..821f1f6be 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -49,8 +49,8 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 7, + "versionNonce": 400692809, "width": 70, "x": 30, "y": 30, @@ -104,8 +104,8 @@ exports[`multi point mode in linear elements > line 3`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 7, + "versionNonce": 400692809, "width": 70, "x": 30, "y": 30, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index a895eb636..b16d6d002 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -11,6 +11,7 @@ exports[`given element A and group of elements B and given both are selected whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -435,6 +436,7 @@ exports[`given element A and group of elements B and given both are selected whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -849,6 +851,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1413,6 +1416,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1618,6 +1622,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2000,6 +2005,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2243,6 +2249,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2421,6 +2428,7 @@ exports[`regression tests > can drag element that covers another element, while "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2744,6 +2752,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2997,6 +3006,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3236,6 +3246,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3470,6 +3481,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3726,6 +3738,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4038,6 +4051,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4472,6 +4486,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4753,6 +4768,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5027,6 +5043,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5233,6 +5250,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5431,6 +5449,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5822,6 +5841,7 @@ exports[`regression tests > drags selected elements from point inside common bou "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6117,6 +6137,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6549,7 +6570,10 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id15": true, }, - "selectedLinearElement": null, + "selectedLinearElement": { + "elementId": "id15", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { @@ -6607,14 +6631,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 5, "width": 50, "x": 310, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 4, }, }, }, @@ -6654,7 +6678,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 7, "width": 80, }, "inserted": { @@ -6673,7 +6697,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 5, "width": 50, }, }, @@ -6681,33 +6705,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "id": "id19", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id15", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id21", - }, { "appState": AppStateDelta { "delta": Delta { "deleted": { "selectedElementIds": { - "id22": true, + "id20": true, }, "selectedLinearElement": null, }, @@ -6725,7 +6728,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id22": { + "id20": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6765,20 +6768,20 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "line", - "version": 6, + "version": 5, "width": 50, "x": 430, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 4, }, }, }, "updated": {}, }, - "id": "id24", + "id": "id22", }, { "appState": AppStateDelta { @@ -6791,7 +6794,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "added": {}, "removed": {}, "updated": { - "id22": { + "id20": { "deleted": { "height": 20, "lastCommittedPoint": [ @@ -6812,7 +6815,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 7, "width": 80, }, "inserted": { @@ -6831,25 +6834,45 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 5, "width": 50, }, }, }, }, + "id": "id24", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElement": { + "elementId": "id20", + "isEditing": false, + }, + }, + "inserted": { + "selectedLinearElement": null, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, "id": "id26", }, { "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, + "selectedElementIds": {}, }, "inserted": { - "selectedLinearElement": null, + "selectedElementIds": { + "id20": true, + }, }, }, }, @@ -6860,26 +6883,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "id": "id28", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": {}, - }, - "inserted": { - "selectedElementIds": { - "id22": true, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id30", - }, { "appState": AppStateDelta { "delta": Delta { @@ -6888,7 +6891,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "inserted": { "selectedLinearElement": { - "elementId": "id22", + "elementId": "id20", "isEditing": false, }, }, @@ -6897,7 +6900,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id31": { + "id29": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6955,7 +6958,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "updated": {}, }, - "id": "id33", + "id": "id31", }, ] `; @@ -6971,6 +6974,7 @@ exports[`regression tests > given a group of selected elements with an element t "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7303,6 +7307,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7580,6 +7585,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7813,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8051,6 +8058,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8229,6 +8237,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8407,6 +8416,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8585,6 +8595,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8668,12 +8679,12 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -8690,7 +8701,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -8813,6 +8823,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8896,12 +8907,12 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -8918,7 +8929,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9039,6 +9049,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9233,6 +9244,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9316,12 +9328,12 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -9338,7 +9350,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9461,6 +9472,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9639,6 +9651,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9722,12 +9735,12 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -9744,7 +9757,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9865,6 +9877,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10043,6 +10056,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10237,6 +10251,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10415,6 +10430,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10944,6 +10960,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11222,6 +11239,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11343,6 +11361,7 @@ exports[`regression tests > shift click on selected element should deselect it o "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11541,6 +11560,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11858,6 +11878,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12285,6 +12306,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12923,6 +12945,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13047,6 +13070,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13676,6 +13700,7 @@ exports[`regression tests > switches from group of selected elements to another "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14013,6 +14038,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14275,6 +14301,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14396,6 +14423,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14502,31 +14530,10 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`; -exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `19`; +exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `18`; exports[`regression tests > undo/redo drawing an element > [end of test] redo stack 1`] = ` [ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": null, - }, - "inserted": { - "selectedLinearElement": { - "elementId": "id6", - "isEditing": false, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id13", - }, { "appState": AppStateDelta { "delta": Delta { @@ -14555,7 +14562,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 10, ], ], - "version": 9, + "version": 8, "width": 60, }, "inserted": { @@ -14578,13 +14585,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 20, ], ], - "version": 8, + "version": 7, "width": 100, }, }, }, }, - "id": "id14", + "id": "id11", }, { "appState": AppStateDelta { @@ -14593,11 +14600,16 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "selectedElementIds": { "id3": true, }, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id6": true, }, + "selectedLinearElement": { + "elementId": "id6", + "isEditing": false, + }, }, }, }, @@ -14606,7 +14618,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "isDeleted": true, - "version": 10, + "version": 9, }, "inserted": { "angle": 0, @@ -14649,7 +14661,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 8, "width": 60, "x": 130, "y": 10, @@ -14659,7 +14671,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id12", }, ] `; @@ -14770,7 +14782,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st }, "updated": {}, }, - "id": "id17", + "id": "id14", }, ] `; @@ -14786,6 +14798,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "locked": false, "type": "text", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14907,6 +14920,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 9ef819856..4fe1096df 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1070,7 +1070,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1087,7 +1087,7 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1107,11 +1107,11 @@ describe("history", () => { mouse.clickAt(0, 0); mouse.clickAt(10, 10); mouse.clickAt(20, 20); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1128,10 +1128,10 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(3); + expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` + expect(h.state.selectedLinearElement?.isEditing).toBe(false); // undo `open editor` expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1144,29 +1144,29 @@ describe("history", () => { }), ]); - Keyboard.undo(); - expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); + // Keyboard.undo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(4); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1179,9 +1179,8 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(6); + expect(API.getRedoStack().length).toBe(5); expect(API.getSelectedElements().length).toBe(0); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1195,10 +1194,10 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1209,25 +1208,25 @@ describe("history", () => { }), ]); + // Keyboard.redo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(3); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); + Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); - - Keyboard.redo(); - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` @@ -1244,7 +1243,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1261,7 +1260,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1278,7 +1277,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1638,13 +1637,13 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(rect1.boundElements).toStrictEqual([ { id: text.id, type: "text" }, @@ -1661,13 +1660,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1684,13 +1683,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1715,13 +1714,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1738,13 +1737,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1793,13 +1792,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: true, }), @@ -1838,13 +1843,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1882,8 +1893,11 @@ describe("history", () => { startBinding: null, endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1917,13 +1931,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1990,13 +2010,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -2347,15 +2373,13 @@ describe("history", () => { ], startBinding: { elementId: "KPrBI4g_v9qUB1XxYLgSz", - focus: -0.001587301587301948, - gap: 5, fixedPoint: [1.0318471337579618, 0.49920634920634904], + mode: "orbit", } as FixedPointBinding, endBinding: { elementId: "u2JGnnmoJ0VATV4vCNJE5", - focus: -0.0016129032258049847, - gap: 3.537079145500037, fixedPoint: [0.4991935483870975, -0.03875193720914723], + mode: "orbit", } as FixedPointBinding, }, ], @@ -2470,10 +2494,9 @@ describe("history", () => { captureUpdate: CaptureUpdateAction.NEVER, }); - Keyboard.undo(); // undo `actionFinalize` Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ points: [ @@ -2487,7 +2510,7 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(3); + expect(API.getRedoStack().length).toBe(2); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: true, @@ -2500,7 +2523,7 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -2513,21 +2536,6 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - points: [ - [0, 0], - [5, 5], - [10, 10], - [15, 15], - [20, 20], - ], - }), - ]); - - Keyboard.redo(); // redo `actionFinalize` - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.elements).toEqual([ expect.objectContaining({ @@ -3027,7 +3035,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement?.isEditing).toBe(false); @@ -3044,11 +3052,11 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(4); + expect(API.getRedoStack().length).toBe(3); expect(h.state.selectedLinearElement).toBeNull(); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -4573,13 +4581,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4707,13 +4721,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4751,9 +4771,8 @@ describe("history", () => { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }), remoteContainer, @@ -4840,15 +4859,13 @@ describe("history", () => { type: "arrow", startBinding: { elementId: rect1.id, - gap: 1, - focus: 0, fixedPoint: [1, 0.5], + mode: "orbit", }, endBinding: { elementId: rect2.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -4902,8 +4919,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ // now we are back in the previous state! @@ -4912,8 +4928,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), }), expect.objectContaining({ @@ -4949,15 +4964,13 @@ describe("history", () => { newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, { startBinding: { elementId: rect1.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: rect2.id, - gap: 1, - focus: 0, fixedPoint: [1, 0.5], + mode: "orbit", }, }), newElementWith(rect1, { @@ -4984,8 +4997,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4993,8 +5005,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: true, }), @@ -5024,8 +5035,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }, endBinding: expect.objectContaining({ elementId: rect2.id, @@ -5033,8 +5043,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: false, }), @@ -5077,13 +5086,11 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: 0, - gap: 1, + fixedPoint: expect.arrayContaining([1, 0.5001]), }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: -0, - gap: 1, + fixedPoint: expect.arrayContaining([0, 0.5001]), }), isDeleted: true, }), @@ -5125,13 +5132,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 1c9b7a53a..f95938afb 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -105,9 +105,8 @@ describe("library", () => { type: "arrow", endBinding: { elementId: "rectangle1", - focus: -1, - gap: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }); diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 095db38a0..bb3052574 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,16 +1,12 @@ import React from "react"; import { vi } from "vitest"; - -import { bindOrUnbindLinearElement } from "@excalidraw/element"; - import { KEYS, reseed } from "@excalidraw/common"; - +import { bindBindingElement } from "@excalidraw/element"; import "@excalidraw/utils/test-utils"; import type { - ExcalidrawLinearElement, + ExcalidrawArrowElement, NonDeleted, - ExcalidrawRectangleElement, } from "@excalidraw/element/types"; import { Excalidraw } from "../index"; @@ -83,12 +79,21 @@ describe("move element", () => { 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 }); + act(() => { // bind line to two rectangles - bindOrUnbindLinearElement( - arrow.get() as NonDeleted, - rectA.get() as ExcalidrawRectangleElement, - rectB.get() as ExcalidrawRectangleElement, + bindBindingElement( + arrow.get() as NonDeleted, + rectA.get(), + "orbit", + "start", + h.app.scene, + ); + bindBindingElement( + arrow.get() as NonDeleted, + rectB.get(), + "orbit", + "end", h.app.scene, ); }); @@ -105,8 +110,8 @@ 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]).toEqual([110, 50]); - expect([arrow.width, arrow.height]).toEqual([80, 80]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[105, 45]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[90, 90]], 0); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); @@ -124,8 +129,8 @@ 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([[110, 50]]); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[105, 45]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[91, 91]], 0); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index d4b5185ba..929ee797f 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -363,7 +363,6 @@ describe("regression tests", () => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z); - Keyboard.keyPress(KEYS.Z); }); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2); Keyboard.withModifierKeys({ ctrl: true }, () => { diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 38079db8f..dfd20767f 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -35,8 +35,8 @@ 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(110.7, 1); - expect(arrow.height).toBeCloseTo(0); + expect(arrow.width).toBeCloseTo(81.75, 1); + expect(arrow.height).toBeCloseTo(62.3, 1); }); test("unselected bound arrows update when rotating their target elements", async () => { @@ -72,13 +72,13 @@ test("unselected bound arrows update when rotating their target elements", async expect(ellipseArrow.x).toEqual(0); expect(ellipseArrow.y).toEqual(0); expect(ellipseArrow.points[0]).toEqual([0, 0]); - expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1); - expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1); + expect(ellipseArrow.points[1][0]).toBeCloseTo(16.52, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(216.57, 1); expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.x).toEqual(360); expect(textArrow.y).toEqual(300); expect(textArrow.points[0]).toEqual([0, 0]); - expect(textArrow.points[1][0]).toBeCloseTo(-94, 0); - expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0); + expect(textArrow.points[1][0]).toBeCloseTo(-63, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-146, 0); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 10f4f7ad9..19e3b9a48 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -487,7 +487,12 @@ describe("tool locking & selection", () => { expect(h.state.activeTool.locked).toBe(true); for (const { value } of Object.values(SHAPES)) { - if (value !== "image" && value !== "selection" && value !== "eraser") { + if ( + value !== "image" && + value !== "selection" && + value !== "eraser" && + value !== "arrow" + ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 5f62999e0..ae8ae549c 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -217,6 +217,7 @@ export type InteractiveCanvasAppState = Readonly< selectedGroupIds: AppState["selectedGroupIds"]; selectedLinearElement: AppState["selectedLinearElement"]; multiElement: AppState["multiElement"]; + newElement: AppState["newElement"]; isBindingEnabled: AppState["isBindingEnabled"]; suggestedBindings: AppState["suggestedBindings"]; isRotating: AppState["isRotating"]; @@ -442,6 +443,7 @@ export interface AppState { // as elements are unlocked, we remove the groupId from the elements // and also remove groupId from this map lockedMultiSelections: { [groupId: string]: true }; + bindMode: "orbit" | "inside" | "skip"; } export type SearchMatch = { diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index fa11abd46..7be0f7224 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -21,20 +21,9 @@ export function curve( return [a, b, c, d] as Curve; } -function gradient( - f: (t: number, s: number) => number, - t0: number, - s0: number, - delta: number = 1e-6, -): number[] { - return [ - (f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta), - (f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta), - ]; -} - -function solve( - f: (t: number, s: number) => [number, number], +function solveWithAnalyticalJacobian( + curve: Curve, + lineSegment: LineSegment, t0: number, s0: number, tolerance: number = 1e-3, @@ -48,33 +37,75 @@ function solve( return null; } - const y0 = f(t0, s0); - const jacobian = [ - gradient((t, s) => f(t, s)[0], t0, s0), - gradient((t, s) => f(t, s)[1], t0, s0), - ]; - const b = [[-y0[0]], [-y0[1]]]; - const det = - jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0]; + // Compute bezier point at parameter t0 + const bt = 1 - t0; + const bt2 = bt * bt; + const bt3 = bt2 * bt; + const t0_2 = t0 * t0; + const t0_3 = t0_2 * t0; - if (det === 0) { + const bezierX = + bt3 * curve[0][0] + + 3 * bt2 * t0 * curve[1][0] + + 3 * bt * t0_2 * curve[2][0] + + t0_3 * curve[3][0]; + const bezierY = + bt3 * curve[0][1] + + 3 * bt2 * t0 * curve[1][1] + + 3 * bt * t0_2 * curve[2][1] + + t0_3 * curve[3][1]; + + // Compute line point at parameter s0 + const lineX = + lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]); + const lineY = + lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]); + + // Function values + const fx = bezierX - lineX; + const fy = bezierY - lineY; + + error = Math.abs(fx) + Math.abs(fy); + + if (error < tolerance) { + break; + } + + // Analytical derivatives + const dfx_dt = + -3 * bt2 * curve[0][0] + + 3 * bt2 * curve[1][0] - + 6 * bt * t0 * curve[1][0] - + 3 * t0_2 * curve[2][0] + + 6 * bt * t0 * curve[2][0] + + 3 * t0_2 * curve[3][0]; + + const dfy_dt = + -3 * bt2 * curve[0][1] + + 3 * bt2 * curve[1][1] - + 6 * bt * t0 * curve[1][1] - + 3 * t0_2 * curve[2][1] + + 6 * bt * t0 * curve[2][1] + + 3 * t0_2 * curve[3][1]; + + // Line derivatives + const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]); + const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]); + + // Jacobian determinant + const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt; + + if (Math.abs(det) < 1e-12) { return null; } - const iJ = [ - [jacobian[1][1] / det, -jacobian[0][1] / det], - [-jacobian[1][0] / det, jacobian[0][0] / det], - ]; - const h = [ - [iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]], - [iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]], - ]; + // Newton step + const invDet = 1 / det; + const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy); + const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy); - t0 = t0 + h[0][0]; - s0 = s0 + h[1][0]; - - const [tErr, sErr] = f(t0, s0); - error = Math.max(Math.abs(tErr), Math.abs(sErr)); + t0 += dt; + s0 += ds; iter += 1; } @@ -96,63 +127,49 @@ export const bezierEquation = ( t ** 3 * c[3][1], ); +const initial_guesses: [number, number][] = [ + [0.5, 0], + [0.2, 0], + [0.8, 0], +]; + +const calculate = ( + [t0, s0]: [number, number], + l: LineSegment, + c: Curve, +) => { + const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 3); + + if (!solution) { + return null; + } + + const [t, s] = solution; + + if (t < 0 || t > 1 || s < 0 || s > 1) { + return null; + } + + return bezierEquation(c, t); +}; + /** * Computes the intersection between a cubic spline and a line segment. */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, >(c: Curve, l: LineSegment): Point[] { - const line = (s: number) => - pointFrom( - l[0][0] + s * (l[1][0] - l[0][0]), - l[0][1] + s * (l[1][1] - l[0][1]), - ); - - const initial_guesses: [number, number][] = [ - [0.5, 0], - [0.2, 0], - [0.8, 0], - ]; - - const calculate = ([t0, s0]: [number, number]) => { - const solution = solve( - (t: number, s: number) => { - const bezier_point = bezierEquation(c, t); - const line_point = line(s); - - return [ - bezier_point[0] - line_point[0], - bezier_point[1] - line_point[1], - ]; - }, - t0, - s0, - ); - - if (!solution) { - return null; - } - - const [t, s] = solution; - - if (t < 0 || t > 1 || s < 0 || s > 1) { - return null; - } - - return bezierEquation(c, t); - }; - - let solution = calculate(initial_guesses[0]); + let solution = calculate(initial_guesses[0], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1]); + solution = calculate(initial_guesses[1], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2]); + solution = calculate(initial_guesses[2], l, c); if (solution) { return [solution]; } diff --git a/packages/math/tests/curve.test.ts b/packages/math/tests/curve.test.ts index 739562096..0d1f3001d 100644 --- a/packages/math/tests/curve.test.ts +++ b/packages/math/tests/curve.test.ts @@ -46,9 +46,11 @@ describe("Math curve", () => { pointFrom(10, 50), pointFrom(50, 50), ); - const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0)); + const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60)); - expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]); + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [9.99, 5.05], + ]); }); it("can be detected where the determinant is overly precise", () => { diff --git a/packages/utils/src/test-utils.ts b/packages/utils/src/test-utils.ts index 1dfd14cac..966a589ab 100644 --- a/packages/utils/src/test-utils.ts +++ b/packages/utils/src/test-utils.ts @@ -6,11 +6,11 @@ expect.extend({ throw new Error("expected and received are not point arrays"); } - const COMPARE = 1 / Math.pow(10, precision || 2); + const COMPARE = 1 / precision === 0 ? 1 : Math.pow(10, precision ?? 2); const pass = expected.every( (point, idx) => - Math.abs(received[idx]?.[0] - point[0]) < COMPARE && - Math.abs(received[idx]?.[1] - point[1]) < COMPARE, + Math.abs(received[idx][0] - point[0]) < COMPARE && + Math.abs(received[idx][1] - point[1]) < COMPARE, ); if (!pass) { diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 1c89411dd..5ff32b5ef 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -11,6 +11,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null,