diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 932743ddf..9cdd1dc72 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -669,8 +669,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 c797c6e8c..8ebb2857a 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -515,3 +515,5 @@ export enum UserIdleState { * the start and end points) */ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; + +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 fdfbb0823..413f65697 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, isPointInElement } 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,131 +126,91 @@ 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); - } - }); - return result; -}; - -export const bindOrUnbindLinearElement = ( - linearElement: NonDeleted, - startBindingElement: ExcalidrawBindableElement | null | "keep", - endBindingElement: ExcalidrawBindableElement | null | "keep", - scene: Scene, -): void => { - const bothEndBoundToTheSameElement = - linearElement.startBinding?.elementId === - linearElement.endBinding?.elementId && !!linearElement.startBinding; - 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, + 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 (!bothEndBoundToTheSameElement) { - 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, - ), + if (start.focusPoint) { + updates.set(0, { + point: + updateBoundPoint( + arrow, + "startBinding", + arrow.startBinding, + start.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[0], }); - }); + } + + 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 }; }; -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, - elementsMap, - ) - : 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 @@ -237,7 +219,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; } @@ -246,119 +233,528 @@ 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: + globalBindMode === "inside" + ? arrowOriginalStartPoint ?? center + : center, + }, + end: { mode: null }, + }; + } + + // Inside -> outside binding + if (arrow.startBinding && arrow.startBinding.elementId !== hovered?.id) { + const otherElement = elementsMap.get(arrow.startBinding.elementId); + invariant(otherElement, "Other element must be in the elements map"); + + const center = pointFrom( + otherElement.x + otherElement.width / 2, + otherElement.y + otherElement.height / 2, + ); + const otherIsInsideBinding = + !!appState.selectedLinearElement?.pointerDownState.arrowStartIsInside; + + // We need to "jump" the start point out with the detached + // focus point of the center of the bound element + const other: BindingStrategy = { + mode: otherIsInsideBinding ? "inside" : "orbit", + element: otherElement as ExcalidrawBindableElement, + focusPoint: otherIsInsideBinding + ? arrowOriginalStartPoint ?? center + : center, + }; + let current: BindingStrategy; + + // We are hovering another element with the end point + if (hovered) { + const isInsideBinding = globalBindMode === "inside"; + current = { + mode: isInsideBinding ? "inside" : "orbit", + element: hovered, + focusPoint: isInsideBinding + ? point + : pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ), + }; + } 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"], @@ -370,9 +766,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 => @@ -390,411 +790,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", - elementsMap, - ) - ) { - 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; - if (isElbowArrow(linearElement)) { + let binding: FixedPointBinding; + + if (isElbowArrow(arrow)) { binding = { elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - hoveredElement, - ), + mode: "orbit", ...calculateFixedPointForElbowArrowBinding( - linearElement, + arrow, hoveredElement, startOrEnd, elementsMap, ), }; } else { - // For non-elbow arrows, check if the endpoint is inside the shape - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - startOrEnd === "start" ? 0 : -1, - elementsMap, - ); - - if (isPointInElement(edgePoint, hoveredElement, elementsMap)) { - // Use FixedPoint binding when the arrow endpoint is inside the shape - binding = { - elementId: hoveredElement.id, - focus: 0, - gap: 0, - ...calculateFixedPointForNonElbowArrowBinding( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - }; - } else { - // Use traditional focus/gap binding when the endpoint is outside the shape - binding = { - elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - hoveredElement, - ), - }; - } + 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", - elementsMap: ElementsMap, -): boolean => { - const otherBinding = - linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; - - // Only prevent binding if opposite end is bound to the same element - if ( - otherBinding?.elementId !== bindableElement.id || - !isLinearElementSimple(linearElement) - ) { - return false; - } - - // For non-elbow arrows, allow FixedPoint binding even when both ends bind to the same element - if (!isElbowArrow(linearElement)) { - const currentEndPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - startOrEnd === "start" ? 0 : -1, - elementsMap, - ); - - // If current end would use FixedPoint binding, allow it - if (isPointInElement(currentEndPoint, bindableElement, elementsMap)) { - return false; - } - } - - // Prevent traditional focus/gap binding when both ends would bind to the same element - return true; -}; - -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 @@ -804,7 +939,6 @@ export const updateBoundElements = ( scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { @@ -812,7 +946,7 @@ export const updateBoundElements = ( return; } - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -826,7 +960,7 @@ export const updateBoundElements = ( } boundElementsVisitor(elementsMap, changedElement, (element) => { - if (!isLinearElement(element) || element.isDeleted) { + if (!isArrowElement(element) || element.isDeleted) { return; } @@ -840,7 +974,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; @@ -850,22 +987,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; } @@ -887,7 +1010,7 @@ export const updateBoundElements = ( const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + element[bindingProp], bindableElement, elementsMap, ); @@ -907,12 +1030,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); @@ -925,14 +1045,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, @@ -942,7 +1062,7 @@ export const updateBindings = ( }; const doesNeedUpdate = ( - boundElement: NonDeleted, + boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, ) => { return ( @@ -972,13 +1092,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)), @@ -988,75 +1111,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, @@ -1064,25 +1178,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 ( @@ -1093,7 +1213,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 = ( @@ -1192,7 +1357,20 @@ 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); + if (pointDistance(p, center) < extent * 0.05) { + return pointFrom(center[0], center[1]); + } + return p; +}; + +const snapToMid = ( element: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, @@ -1299,137 +1477,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 (isFixedPointBinding(binding)) { - const fixedPoint = - normalizeFixedPoint(binding.fixedPoint) ?? - (isElbowArrow(linearElement) - ? calculateFixedPointForElbowArrowBinding( - linearElement, - bindableElement, - startOrEnd === "startBinding" ? "start" : "end", - elementsMap, - ).fixedPoint - : calculateFixedPointForNonElbowArrowBinding( - 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, ); }; @@ -1473,16 +1556,19 @@ export const calculateFixedPointForElbowArrowBinding = ( }; export const calculateFixedPointForNonElbowArrowBinding = ( - linearElement: NonDeleted, + linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + focusPoint?: GlobalPoint, ): { fixedPoint: FixedPoint } => { - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - startOrEnd === "start" ? 0 : -1, - elementsMap, - ); + const edgePoint = focusPoint + ? focusPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); // Convert the global point to element-local coordinates const elementCenter = pointFrom( @@ -1508,60 +1594,6 @@ export const calculateFixedPointForNonElbowArrowBinding = ( }; }; -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, - 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), - ); -}; - -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, - ), - ); -}; - export const fixDuplicatedBindingsAfterDuplication = ( duplicatedElements: ExcalidrawElement[], origIdToDuplicateId: Map, @@ -1675,324 +1707,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", @@ -2319,7 +2033,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 002185164..6cdeb7f63 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" }; @@ -2249,17 +2248,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..347d6a889 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, ); @@ -501,8 +508,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 +516,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 +551,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 +581,11 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + pointerDownState: { + ...editingLinearElement.pointerDownState, + arrowOriginalStartPoint: undefined, + arrowStartIsInside: false, + }, }; } @@ -853,7 +830,6 @@ export class LinearElementEditor { } { const appState = app.state; const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -871,6 +847,7 @@ export class LinearElementEditor { if (!element) { return ret; } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, @@ -878,6 +855,7 @@ export class LinearElementEditor { elementsMap, ); let segmentMidpointIndex = null; + if (segmentMidpoint) { segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, @@ -914,19 +892,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 +916,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 +954,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 +1019,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 +1103,6 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, - indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, ): GlobalPoint { @@ -1409,8 +1370,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 +1418,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 +1549,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 +1565,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 +1947,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.IMMEDIATELY, + }); + } + } + + 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..3bd038f1b 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -843,10 +843,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 +1382,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/__snapshots__/linearElementEditor.test.tsx.snap b/packages/element/tests/__snapshots__/linearElementEditor.test.tsx.snap index 67639e5bd..afd9e11a3 100644 --- a/packages/element/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/element/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -49,9 +49,3 @@ exports[`Test Linear Elements > Test bound text element > should wrap the bound "Online whiteboard collaboration made easy" `; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = ` -"Online whiteboard -collaboration made -easy" -`; diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index a3da1c66d..24bd7ea65 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,6 +22,8 @@ import { TEXT_EDITOR_SELECTOR, } from "../../excalidraw/tests/queries/dom"; +import type { ExcalidrawLinearElement, FixedPointBinding } from "../src/types"; + const { h } = window; const mouse = new Pointer("mouse"); @@ -71,8 +79,9 @@ describe("element binding", () => { expect(arrow.startBinding).toEqual({ elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + focus: 0, + gap: 0, + fixedPoint: expect.arrayContaining([1.1, 0]), }); // Move the end point to the overlapping binding position @@ -83,13 +92,15 @@ describe("element binding", () => { // Both the start and the end points should be bound expect(arrow.startBinding).toEqual({ elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + focus: 0, + gap: 0, + fixedPoint: expect.arrayContaining([1.1, 0]), }); expect(arrow.endBinding).toEqual({ elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + focus: 0, + gap: 0, + fixedPoint: expect.arrayContaining([1.1, 0]), }); }); @@ -195,9 +206,9 @@ describe("element binding", () => { // Sever connection expect(API.getSelectedElement().type).toBe("arrow"); Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding).toBe(null); + expect(arrow.endBinding).not.toBe(null); Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding).toBe(null); + expect(arrow.endBinding).not.toBe(null); }); it("should unbind on bound element deletion", () => { @@ -312,15 +323,13 @@ describe("element binding", () => { points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "text1", - focus: 0.2, - gap: 7, fixedPoint: [1, 0.5], + mode: "orbit", }, }); @@ -330,15 +339,13 @@ describe("element binding", () => { points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], startBinding: { elementId: "text1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [1, 0.5], + mode: "orbit", }, }); @@ -476,3 +483,346 @@ describe("element binding", () => { }); }); }); + +describe("Fixed-point arrow binding", () => { + beforeEach(async () => { + await render(); + }); + + it("should create fixed-point binding when both arrow endpoint 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(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); + + 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(260); + expect(arrow.y).toBe(110); + }); + + it("should create fixed-point binding when one of the arrow endpoint 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).toBe(150); + expect(arrow.height).toBe(150); + + // 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).toBe(300); + expect(arrow.height).toBe(150); + }); + + it("should maintain relative position when arrow start point is dragged outside and rectangle is moved", () => { + // 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 both endpoints inside the filled rectangle, creating same-element binding + UI.clickTool("arrow"); + mouse.downAt(120, 120); + mouse.moveTo(180, 180); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Both ends should be bound to the same rectangle + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + mouse.reset(); + + // Select the arrow and drag the start point outside the rectangle + mouse.downAt(120, 120); + mouse.moveTo(50, 50); // Move start point outside rectangle + mouse.up(); + + mouse.reset(); + + // Move the rectangle by dragging it + mouse.downAt(150, 110); + mouse.moveTo(300, 300); + mouse.up(); + + expect(arrow.x).toBe(50); + expect(arrow.y).toBe(50); + expect(arrow.width).toBeCloseTo(280, 0); + expect(arrow.height).toBeCloseTo(320, 0); + }); + + it("should move inner points when arrow is bound to same element on both ends", () => { + // Create one rectangle as binding target + const rect = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 200, + height: 100, + fillStyle: "solid", + backgroundColor: "#a5d8ff", + }); + + // Create a non-elbowed arrow with inner points bound to the same element on both ends + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 75, + width: 100, + height: 50, + points: [ + pointFrom(0, 0), // start point + pointFrom(25, -25), // first inner point + pointFrom(75, 25), // second inner point + pointFrom(100, 0), // end point + ], + startBinding: { + elementId: rect.id, + fixedPoint: [0.25, 0.5], + mode: "orbit", + }, + endBinding: { + elementId: rect.id, + fixedPoint: [0.75, 0.5], + mode: "orbit", + }, + }); + + API.setElements([rect, arrow]); + + // Store original inner point positions (local coordinates) + const originalInnerPoint1 = [...arrow.points[1]]; + const originalInnerPoint2 = [...arrow.points[2]]; + + // Move the rectangle + mouse.reset(); + mouse.downAt(150, 100); // Click on the rectangle + mouse.moveTo(300, 200); // Move it down and to the right + mouse.up(); + + // Verify that inner points moved with the arrow (same local coordinates) + // When both ends are bound to the same element, inner points should maintain + // their local coordinates relative to the arrow's origin + 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]); + }); + + it("should NOT move inner points when arrow is bound to different elements", () => { + // 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]); + }); +}); + +describe("line segment extension binding", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should use point binding when extended segment intersects element", () => { + // Create a rectangle that will be intersected by the extended arrow segment + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 100, + height: 100, + }); + + API.setElements([rect]); + + // Draw an arrow that points at the rectangle (extended segment will intersect) + UI.clickTool("arrow"); + mouse.downAt(0, 0); // Start point + mouse.moveTo(120, 95); // End point - arrow direction points toward rectangle + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Should create a normal point binding since the extended line segment + // from the last arrow segment intersects the rectangle + expect(arrow.endBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding).toHaveProperty("focus"); + expect(arrow.endBinding).toHaveProperty("gap"); + }); + + it("should use fixed point binding when extended segment misses 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); + }); +}); 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..131b3defa 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 { @@ -189,8 +184,8 @@ describe("elbow arrow routing", () => { scene.insertElement(rectangle2); scene.insertElement(arrow); - bindLinearElement(arrow, rectangle1, "start", scene); - bindLinearElement(arrow, rectangle2, "end", scene); + bindBindingElement(arrow, rectangle1, "orbit", "start", scene); + bindBindingElement(arrow, rectangle2, "orbit", "end", scene); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index 1d0b6ac0b..b3feb47c4 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", { diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 80a9eedaa..f1982f4dc 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 a9281ce84..b788f9621 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 9baeb0b6f..0d258f5bf 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,11 +1,11 @@ import { pointFrom } from "@excalidraw/math"; +import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import { - maybeBindLinearElement, - bindOrUnbindLinearElement, - isBindingEnabled, -} from "@excalidraw/element/binding"; -import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; + getHoveredElementForBinding, + isValidPolygon, + LinearElementEditor, +} from "@excalidraw/element"; import { isBindingElement, @@ -17,7 +17,7 @@ import { import { KEYS, arrayToMap, - tupleToCoors, + invariant, updateActiveTool, } from "@excalidraw/common"; import { isPathALoop } from "@excalidraw/element"; @@ -26,11 +26,13 @@ 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 { + ExcalidrawArrowElement, ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, + PointsPositionUpdates, } from "@excalidraw/element/types"; import { t } from "../i18n"; @@ -42,20 +44,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, @@ -63,51 +82,84 @@ 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, + }); 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.filter((el) => el.id !== element!.id); } + return { elements: newElements, appState: { + ...appState, selectedLinearElement: { ...linearElementEditor, selectedPointsIndices: null, }, suggestedBindings: [], + newElement: null, + multiElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } } - if (appState.selectedLinearElement?.isEditing) { - const { elementId, startBindingElement, endBindingElement } = - appState.selectedLinearElement; + if (appState.selectedLinearElement?.isEditing && !appState.newElement) { + const { elementId } = appState.selectedLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); + const updates = + appState.selectedLinearElement?.pointerDownState.prevSelectedPointsIndices?.reduce( + (updates, index) => { + updates.set(index, { + point: element.points[index], + draggedPoints: true, + }); + + return updates; + }, + new Map(), + ) ?? new Map(); + const allPointsSelected = + appState.selectedLinearElement?.pointerDownState + .prevSelectedPointsIndices?.length === element.points.length; + + // Dragging the entire arrow doesn't allow binding. + if (!allPointsSelected) { + bindOrUnbindBindingElement(element, updates, scene, appState); + } } + if (isLineElement(element) && !isValidPolygon(element.points)) { scene.mutateElement(element, { polygon: false, @@ -117,7 +169,7 @@ export const actionFinalize = register({ return { elements: element.points.length < 2 || isInvisiblySmallElement(element) - ? elements.filter((el) => el.id !== element.id) + ? newElements.filter((el) => el.id !== element.id) : undefined, appState: { ...appState, @@ -133,8 +185,6 @@ export const actionFinalize = register({ } } - let newElements = elements; - if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -158,11 +208,26 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if (appState.multiElement && element.type !== "freedraw") { - const { points, lastCommittedPoint } = element; + if ( + appState.multiElement && + element.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { + const { x: rx, y: ry, points, lastCommittedPoint } = element; + const lastGlobalPoint = pointFrom( + rx + points[points.length - 1][0], + ry + points[points.length - 1][1], + ); + const hoveredElementForBinding = getHoveredElementForBinding( + lastGlobalPoint, + app.scene.getNonDeletedElements(), + elementsMap, + app.state.zoom, + ); if ( - !lastCommittedPoint || - points[points.length - 1] !== lastCommittedPoint + !hoveredElementForBinding && + (!lastCommittedPoint || + points[points.length - 1] !== lastCommittedPoint) ) { scene.mutateElement(element, { points: element.points.slice(0, -1), @@ -206,25 +271,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); - } } } @@ -250,6 +296,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: { @@ -277,11 +341,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 548df6f9d..4fe7293ba 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -101,20 +101,22 @@ import { CLASSES, Emitter, MINIMUM_ARROW_SIZE, + 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, @@ -233,9 +235,14 @@ import { hitElementBoundingBox, isLineElement, isSimpleArrow, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + getBindingStrategyForDraggingBindingElementEndpoints, + getStartGlobalEndLocalPointsForSimpleArrowBinding, + snapToCenter, } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, @@ -259,6 +266,7 @@ import type { MagicGenerationData, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -569,7 +577,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; @@ -603,6 +610,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 = @@ -2480,6 +2489,47 @@ class App extends React.Component { return this.setState(...args); }, }, + watchState: { + configurable: true, + value: ( + callback: + | (( + prevState: Parameters, + nextState: Parameters | null, + ) => void) + | undefined, + ) => { + if (callback) { + (window as any).__originalSetState = this.setState; + this.setState = new Proxy(this.setState, { + apply: (target, thisArg, [state, cb]) => { + const prevState = thisArg.state; + let newState: Parameters | null = null; + + // Log state change for debugging + if (typeof state === "function") { + newState = state(prevState, this.props); + } else if (state) { + newState = state; + } + + try { + callback(prevState, newState); + } catch (error) { + console.warn("Error in watchState callback:", error); + } + + if (newState) { + target.bind(thisArg)(newState as any, cb); + } + }, + }); + } else if ((window as any).__originalSetState) { + this.setState = (window as any).__originalSetState; + delete (window as any).__originalSetState; + } + }, + }, app: { configurable: true, value: this, @@ -4321,6 +4371,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; } @@ -4330,6 +4393,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 }); } @@ -4347,7 +4414,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 && @@ -4405,7 +4474,7 @@ class App extends React.Component { }); this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( + suggestedBindings: getSuggestedBindingsForBindingElements( selectedElements.filter( (element) => element.id !== elbowArrow?.id || step !== 0, ), @@ -4616,17 +4685,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: [] }); } @@ -5770,6 +5917,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5861,15 +6010,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 && @@ -5884,18 +6032,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)) { @@ -5904,24 +6040,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); @@ -5967,17 +6100,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; @@ -5997,17 +6128,118 @@ 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.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, @@ -6186,7 +6418,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) ) { @@ -6303,7 +6535,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) ) { @@ -6312,7 +6544,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) ) { @@ -6671,6 +6903,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, @@ -6774,6 +7022,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(); }; @@ -7524,7 +7781,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, @@ -7719,20 +7979,37 @@ class App extends React.Component { } const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const lastGlobalPoint = pointFrom( + rx + multiElement.points[multiElement.points.length - 1][0], + ry + multiElement.points[multiElement.points.length - 1][1], + ); + const hoveredElementForBinding = getHoveredElementForBinding( + 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 + 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; } @@ -7821,35 +8098,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 (isSimpleArrow(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, + }; }); } }; @@ -8143,26 +8484,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; @@ -8222,16 +8543,114 @@ 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; + + this.setState(newState); + } + } + + 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); @@ -8446,7 +8865,7 @@ class App extends React.Component { !isElbowArrow(selectedElements[0]) ) { this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( + suggestedBindings: getSuggestedBindingsForBindingElements( selectedElements, this.scene.getNonDeletedElementsMap(), this.state.zoom, @@ -8672,34 +9091,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 }, ); @@ -8712,12 +9174,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, ), }); } @@ -8871,6 +9332,8 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + this.removePointer(childEvent); if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); @@ -8953,10 +9416,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 && @@ -8977,7 +9445,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 !== @@ -8991,6 +9462,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, @@ -9134,7 +9609,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 }, ); @@ -9145,10 +9620,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, @@ -9749,15 +10221,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") { @@ -10185,20 +10651,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: @@ -10692,12 +11153,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", @@ -10823,7 +11279,7 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - const suggestedBindings = getSuggestedBindingsForArrows( + const suggestedBindings = getSuggestedBindingsForBindingElements( selectedElements, this.scene.getNonDeletedElementsMap(), this.state.zoom, @@ -11147,6 +11603,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11166,6 +11624,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/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 bd9c4f9a1..3e1092922 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -92,8 +92,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s 0.04, 0.4633333333333333, ], - "focus": 0, - "gap": 0, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -122,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", @@ -148,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, @@ -182,8 +187,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s 0.9357142857142857, 0.5001, ], - "focus": 0, - "gap": 0, + "mode": "orbit", }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -342,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, @@ -372,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", @@ -429,376 +439,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t } `; -exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id40", - "type": "text", - }, - ], - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id42", - "focus": -0, - "gap": 1, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 0, - "id": Any, - "index": "a0", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - 99, - 0, - ], - ], - "roughness": 1, - "roundness": null, - "seed": Any, - "startArrowhead": null, - "startBinding": { - "elementId": "id41", - "focus": 0, - "gap": 1, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 4, - "versionNonce": Any, - "width": 100, - "x": 255.5, - "y": 239, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` -{ - "angle": 0, - "autoResize": true, - "backgroundColor": "transparent", - "boundElements": null, - "containerId": "id39", - "customData": undefined, - "fillStyle": "solid", - "fontFamily": 5, - "fontSize": 20, - "frameId": null, - "groupIds": [], - "height": 25, - "id": Any, - "index": "a1", - "isDeleted": false, - "lineHeight": 1.25, - "link": null, - "locked": false, - "opacity": 100, - "originalText": "HELLO WORLD!!", - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "text": "HELLO WORLD!!", - "textAlign": "center", - "type": "text", - "updated": 1, - "version": 3, - "versionNonce": Any, - "verticalAlign": "middle", - "width": 130, - "x": 240, - "y": 226.5, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id39", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": Any, - "index": "a2", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 3, - "versionNonce": Any, - "width": 100, - "x": 155, - "y": 189, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id39", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": Any, - "index": "a3", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "ellipse", - "updated": 1, - "version": 3, - "versionNonce": Any, - "width": 100, - "x": 355, - "y": 189, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id44", - "type": "text", - }, - ], - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id46", - "focus": -0, - "gap": 1, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 0, - "id": Any, - "index": "a0", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - 99, - 0, - ], - ], - "roughness": 1, - "roundness": null, - "seed": Any, - "startArrowhead": null, - "startBinding": { - "elementId": "id45", - "focus": 0, - "gap": 1, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 4, - "versionNonce": Any, - "width": 100, - "x": 255.5, - "y": 239, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` -{ - "angle": 0, - "autoResize": true, - "backgroundColor": "transparent", - "boundElements": null, - "containerId": "id43", - "customData": undefined, - "fillStyle": "solid", - "fontFamily": 5, - "fontSize": 20, - "frameId": null, - "groupIds": [], - "height": 25, - "id": Any, - "index": "a1", - "isDeleted": false, - "lineHeight": 1.25, - "link": null, - "locked": false, - "opacity": 100, - "originalText": "HELLO WORLD!!", - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "text": "HELLO WORLD!!", - "textAlign": "center", - "type": "text", - "updated": 1, - "version": 3, - "versionNonce": Any, - "verticalAlign": "middle", - "width": 130, - "x": 240, - "y": 226.5, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` -{ - "angle": 0, - "autoResize": true, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id43", - "type": "arrow", - }, - ], - "containerId": null, - "customData": undefined, - "fillStyle": "solid", - "fontFamily": 5, - "fontSize": 20, - "frameId": null, - "groupIds": [], - "height": 25, - "id": Any, - "index": "a2", - "isDeleted": false, - "lineHeight": 1.25, - "link": null, - "locked": false, - "opacity": 100, - "originalText": "HEYYYYY", - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "text": "HEYYYYY", - "textAlign": "left", - "type": "text", - "updated": 1, - "version": 3, - "versionNonce": Any, - "verticalAlign": "top", - "width": 70, - "x": 185, - "y": 226.5, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` -{ - "angle": 0, - "autoResize": true, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id43", - "type": "arrow", - }, - ], - "containerId": null, - "customData": undefined, - "fillStyle": "solid", - "fontFamily": 5, - "fontSize": 20, - "frameId": null, - "groupIds": [], - "height": 25, - "id": Any, - "index": "a3", - "isDeleted": false, - "lineHeight": 1.25, - "link": null, - "locked": false, - "opacity": 100, - "originalText": "WHATS UP ?", - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "text": "WHATS UP ?", - "textAlign": "left", - "type": "text", - "updated": 1, - "version": 3, - "versionNonce": Any, - "verticalAlign": "top", - "width": 100, - "x": 355, - "y": 226.5, -} -`; - exports[`Test Transform > should not allow duplicate ids 1`] = ` { "angle": 0, @@ -1484,8 +1124,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, @@ -1516,8 +1159,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", @@ -1551,8 +1197,7 @@ exports[`Test Transform > should transform the elements correctly when linear el 0.46387050630528887, 0.48466257668711654, ], - "focus": 0, - "gap": 0, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1579,8 +1224,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 70ef92837..047defac7 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -28,16 +28,10 @@ import { LinearElementEditor } from "@excalidraw/element"; import { bumpVersion } from "@excalidraw/element"; import { getContainerElement } from "@excalidraw/element"; import { detectLineHeight } from "@excalidraw/element"; -import { - isPointInElement, - calculateFixedPointForNonElbowArrowBinding, -} from "@excalidraw/element"; import { isArrowBoundToElement, isArrowElement, - isBindableElement, isElbowArrow, - isFixedPointBinding, isLinearElement, isLineElement, isTextElement, @@ -66,7 +60,6 @@ import type { FontFamilyValues, NonDeletedSceneElementsMap, OrderedExcalidrawElement, - PointBinding, StrokeRoundness, } from "@excalidraw/element/types"; @@ -128,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 = < @@ -525,87 +511,6 @@ const repairFrameMembership = ( } }; -/** - * Migrates old PointBinding to FixedPointBinding for non-elbow arrows - * when arrow endpoints are inside bindable shapes. - * - * NOTE mutates element. - */ -const migratePointBindingToFixedPoint = ( - element: Mutable, - elementsMap: Map>, -) => { - if (!isArrowElement(element) || isElbowArrow(element)) { - return; - } - - let shouldUpdateElement = false; - let newStartBinding: FixedPointBinding | PointBinding | null = - element.startBinding; - let newEndBinding: FixedPointBinding | PointBinding | null = - element.endBinding; - - // Check start binding - if (element.startBinding && !isFixedPointBinding(element.startBinding)) { - const boundElement = elementsMap.get(element.startBinding.elementId); - if (boundElement && isBindableElement(boundElement)) { - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - 0, - elementsMap, - ); - if (isPointInElement(edgePoint, boundElement, elementsMap)) { - const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( - element, - boundElement, - "start", - elementsMap, - ); - newStartBinding = { - elementId: element.startBinding.elementId, - focus: 0, - gap: 0, - fixedPoint, - }; - shouldUpdateElement = true; - } - } - } - - // Check end binding - if (element.endBinding && !isFixedPointBinding(element.endBinding)) { - const boundElement = elementsMap.get(element.endBinding.elementId); - if (boundElement && isBindableElement(boundElement)) { - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - -1, - elementsMap, - ); - if (isPointInElement(edgePoint, boundElement, elementsMap)) { - const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( - element, - boundElement, - "end", - elementsMap, - ); - newEndBinding = { - elementId: element.endBinding.elementId, - focus: 0, - gap: 0, - fixedPoint, - }; - shouldUpdateElement = true; - } - } - } - - if (shouldUpdateElement) { - (element as Mutable).startBinding = - newStartBinding; - (element as Mutable).endBinding = newEndBinding; - } -}; - export const restoreElements = ( elements: ImportedDataState["elements"], /** NOTE doesn't serve for reconciliation */ @@ -685,9 +590,6 @@ export const restoreElements = ( (element as Mutable).endBinding = null; } } - - // Migrate old PointBinding to FixedPointBinding for non-elbow arrows - migratePointBindingToFixedPoint(element, restoredElementsMap); } // NOTE (mtolmacs): Temporary fix for extremely large arrows diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0d9fcf316..b1b1570e9 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -433,11 +433,11 @@ describe("Test Transform", () => { startBinding: { elementId: rectangle.id, focus: 0, - gap: 1, + gap: 0, }, endBinding: { elementId: ellipse.id, - focus: -0, + focus: 0, }, }); @@ -518,11 +518,11 @@ describe("Test Transform", () => { startBinding: { elementId: text2.id, focus: 0, - gap: 1, + gap: 0, }, endBinding: { elementId: text3.id, - focus: -0, + focus: 0, }, }); 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/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 e7c3c68d3..f2cbe8f50 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 75ee66937..f78db0491 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, @@ -34,6 +35,70 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id4", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": 493213705, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -118,7 +183,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 +207,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 18, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -148,7 +218,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, @@ -167,7 +242,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -183,18 +258,17 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "elbowed": false, "endArrowhead": "arrow", "endBinding": { - "elementId": "id15", + "elementId": "id1", "fixedPoint": [ - "0.50000", - 1, + "-0.05000", + "0.50997", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 150, + "height": "0.04737", "id": "id4", "index": "a2", "isDeleted": false, @@ -208,8 +282,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "124.00500", - 150, + 90, + "0.04737", ], ], "roughness": 1, @@ -217,245 +291,31 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 35, - "width": "124.00500", - "x": 1, - "y": 0, + "version": 9, + "width": 90, + "x": 5, + "y": "0.95000", } `; -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": 14, - "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`] = `10`; -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] redo stack 1`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": {}, - "inserted": {}, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": { - "id0": { - "deleted": { - "version": 17, - }, - "inserted": { - "version": 15, - }, - }, - "id1": { - "deleted": { - "boundElements": [], - "version": 9, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 8, - }, - }, - "id15": { - "deleted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 12, - }, - "inserted": { - "boundElements": [], - "version": 11, - }, - }, - "id4": { - "deleted": { - "endBinding": { - "elementId": "id15", - "fixedPoint": [ - "0.50000", - 1, - ], - "focus": 0, - "gap": 1, - }, - "height": "104.34908", - "points": [ - [ - 0, - 0, - ], - [ - "124.00500", - "104.34908", - ], - ], - "startBinding": { - "elementId": "id0", - "focus": "0.02970", - "gap": 1, - }, - "version": 33, - }, - "inserted": { - "endBinding": { - "elementId": "id1", - "focus": "-0.02000", - "gap": 1, - }, - "height": "0.00849", - "points": [ - [ - 0, - 0, - ], - [ - "98.00000", - "-0.00849", - ], - ], - "startBinding": { - "elementId": "id0", - "focus": "0.02000", - "gap": 1, - }, - "version": 30, - }, - }, - }, - }, - "id": "id22", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": {}, - "inserted": {}, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": { - "id0": { - "deleted": { - "boundElements": [], - "version": 18, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 17, - }, - }, - "id15": { - "deleted": { - "version": 14, - }, - "inserted": { - "version": 12, - }, - }, - "id4": { - "deleted": { - "height": 150, - "points": [ - [ - 0, - 0, - ], - [ - "124.00500", - 150, - ], - ], - "startBinding": null, - "version": 35, - "y": 0, - }, - "inserted": { - "height": "104.34908", - "points": [ - [ - 0, - 0, - ], - [ - "124.00500", - "104.34908", - ], - ], - "startBinding": { - "elementId": "id0", - "focus": "0.02970", - "gap": 1, - }, - "version": 33, - "y": "45.65092", - }, - }, - }, - }, - "id": "id23", - }, -] -`; +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`] = `[]`; 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] undo stack 1`] = ` [ @@ -591,26 +451,283 @@ 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": 5, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 3, + "version": 4, + }, + }, + }, + "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": 6, + "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": 5, + "width": 100, + "x": 0, + "y": 0, + }, + }, + }, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, + "version": 7, + }, + "inserted": { + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 6, + }, + }, + }, + }, + "id": "id11", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.04737", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "0.04737", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, + "version": 8, + "width": 90, + "y": "0.95000", + }, + "inserted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, + "version": 7, + "width": 95, + "y": "0.95000", + }, + }, + }, + }, + "id": "id14", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id1": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + "inserted": { + "boundElements": [], + "version": 2, + }, + }, + "id4": { + "deleted": { + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.05000", + "0.50997", + ], + "mode": "orbit", + }, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, + "version": 9, + }, + "inserted": { + "endBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50950", + ], + "mode": "orbit", + }, + "version": 8, + }, + }, + }, + }, + "id": "id16", + }, ] `; @@ -625,6 +742,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -648,6 +766,70 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id4", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": 493213705, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -732,7 +914,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 +938,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 19, + "version": 3, "width": 100, - "x": 150, + "x": -100, "y": -50, } `; @@ -762,7 +949,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, @@ -781,9 +973,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 3, "width": 100, - "x": 150, + "x": 100, "y": -50, } `; @@ -796,11 +988,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": null, + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.05000", + "0.50010", + ], + "mode": "orbit", + }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00947", "id": "id4", "index": "a2", "isDeleted": false, @@ -814,8 +1013,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 0, - 0, + 90, + "-0.00947", ], ], "roughness": 1, @@ -823,123 +1022,31 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 31, - "width": 0, - "x": 149, - "y": 0, + "version": 11, + "width": 90, + "x": 5, + "y": "0.01000", } `; 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`] = `12`; -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`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": {}, - "inserted": {}, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": { - "id0": { - "deleted": { - "version": 18, - }, - "inserted": { - "version": 16, - }, - }, - "id1": { - "deleted": { - "boundElements": [], - "version": 9, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 8, - }, - }, - "id4": { - "deleted": { - "endBinding": null, - "version": 30, - }, - "inserted": { - "endBinding": { - "elementId": "id1", - "focus": -0, - "gap": 1, - }, - "version": 28, - }, - }, - }, - }, - "id": "id21", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": {}, - "inserted": {}, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": { - "id0": { - "deleted": { - "boundElements": [], - "version": 19, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 18, - }, - }, - "id4": { - "deleted": { - "startBinding": null, - "version": 31, - }, - "inserted": { - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, - "version": 30, - }, - }, - }, - }, - "id": "id22", - }, -] -`; +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`] = `[]`; 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] undo stack 1`] = ` [ @@ -1075,26 +1182,409 @@ 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": 5, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 3, + "version": 4, + }, + }, + }, + "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": 6, + "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": 5, + "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": 7, + "y": 0, + }, + "inserted": { + "height": "0.95000", + "points": [ + [ + 0, + 0, + ], + [ + 95, + "-0.95000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 6, + "y": "0.95000", + }, + }, + }, + }, + "id": "id11", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 8, + }, + "inserted": { + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "version": 7, + }, + }, + }, + }, + "id": "id13", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.93737", + "points": [ + [ + 0, + 0, + ], + [ + "90.00000", + "0.93737", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 9, + "width": "90.00000", + "y": "0.01000", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 95, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 8, + "width": 95, + "y": 0, + }, + }, + }, + }, + "id": "id16", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": "0.00947", + "points": [ + [ + 0, + 0, + ], + [ + 90, + "-0.00947", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 10, + "width": 90, + }, + "inserted": { + "height": "0.93737", + "points": [ + [ + 0, + 0, + ], + [ + "90.00000", + "0.93737", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 9, + "width": "90.00000", + }, + }, + }, + }, + "id": "id18", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id1": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + "inserted": { + "boundElements": [], + "version": 2, + }, + }, + "id4": { + "deleted": { + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.05000", + "0.50010", + ], + "mode": "orbit", + }, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 11, + }, + "inserted": { + "endBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.05000", + "0.50010", + ], + "mode": "orbit", + }, + "version": 10, + }, + }, + }, + }, + "id": "id20", + }, ] `; @@ -1109,6 +1599,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 +1716,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": "49.99000", + "height": "31.25668", "id": "id4", "index": "Zz", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1245,8 +1736,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "150.01000", - "49.99000", + 90, + "31.25668", ], ], "roughness": 1, @@ -1258,18 +1749,17 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": "150.01000", - "x": 0, - "y": "0.01000", + "version": 7, + "width": 90, + "x": 5, + "y": "1.67603", } `; @@ -1301,7 +1791,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 5, "width": 100, "x": -100, "y": -50, @@ -1336,7 +1826,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 5, "width": 100, "x": 100, "y": -50, @@ -1345,7 +1835,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added arrow when it's bindable elements are added through the history > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added arrow when it's bindable elements are added through the history > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added arrow when it's bindable elements are added through the history > [end of test] number of renders 1`] = `7`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added arrow when it's bindable elements are added through the history > [end of test] redo stack 1`] = `[]`; @@ -1382,14 +1872,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 7, + "version": 5, "width": 100, "x": -100, "y": -50, }, "inserted": { "isDeleted": true, - "version": 6, + "version": 4, }, }, "id1": { @@ -1413,14 +1903,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 7, + "version": 5, "width": 100, "x": 100, "y": -50, }, "inserted": { "isDeleted": true, - "version": 6, + "version": 4, }, }, }, @@ -1433,8 +1923,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,20 +1931,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, - "version": 11, + "version": 7, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 8, + "version": 4, }, }, }, }, - "id": "id8", + "id": "id6", }, ] `; @@ -1471,6 +1959,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,16 +2076,15 @@ 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": "49.99000", + "height": 100, "id": "id5", "index": "a0", - "isDeleted": false, + "isDeleted": true, "lastCommittedPoint": null, "link": null, "locked": false, @@ -1607,8 +2095,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "249.99000", - "-49.99000", + 100, + 100, ], ], "roughness": 1, @@ -1620,18 +2108,17 @@ 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", "updated": 1, - "version": 11, - "width": "249.99000", - "x": "-49.99000", - "y": 50, + "version": 4, + "width": 100, + "x": 0, + "y": 0, } `; @@ -1639,12 +2126,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id5", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -1663,7 +2145,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 12, + "version": 5, "width": 100, "x": -100, "y": -50, @@ -1674,12 +2156,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id5", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -1698,7 +2175,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 11, + "version": 4, "width": 100, "x": 100, "y": -50, @@ -1707,11 +2184,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] number of renders 1`] = `11`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] number of renders 1`] = `8`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] redo stack 1`] = `[]`; - -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] undo stack 1`] = ` +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] redo stack 1`] = ` [ { "appState": AppStateDelta { @@ -1721,10 +2196,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, }, "elements": { - "added": {}, - "removed": { + "added": { "id5": { "deleted": { + "isDeleted": true, + "version": 4, + }, + "inserted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, @@ -1737,13 +2215,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": "49.99000", + "height": 100, "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1756,8 +2233,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "249.99000", - "-49.99000", + 100, + 100, ], ], "roughness": 1, @@ -1769,62 +2246,60 @@ 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": "249.99000", - "x": "-49.99000", - "y": 50, - }, - "inserted": { - "isDeleted": true, - "version": 8, + "version": 3, + "width": 100, + "x": 0, + "y": 0, }, }, }, + "removed": {}, "updated": { "id0": { "deleted": { + "boundElements": [], + "version": 5, + }, + "inserted": { "boundElements": [ { "id": "id5", "type": "arrow", }, ], - "version": 12, - }, - "inserted": { - "boundElements": [], - "version": 9, + "version": 4, }, }, "id1": { "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { "boundElements": [ { "id": "id5", "type": "arrow", }, ], - "version": 11, - }, - "inserted": { - "boundElements": [], - "version": 8, + "version": 3, }, }, }, }, - "id": "id11", + "id": "id8", }, ] `; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind remotely added bindable elements when it's arrow is added through the history > [end of test] undo stack 1`] = `[]`; + exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should unbind remotely deleted bindable elements from arrow when the arrow is added through the history > [end of test] appState 1`] = ` { "activeEmbeddable": null, @@ -1836,6 +2311,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 +2573,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2120,6 +2597,70 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id4", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": 493213705, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -2169,9 +2710,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "scrollX": 0, "scrollY": 0, "searchMatches": null, - "selectedElementIds": { - "id4": true, - }, + "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectionElement": null, @@ -2202,12 +2741,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2226,7 +2760,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -2237,12 +2771,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2261,10 +2790,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 4, "width": 100, - "x": 500, - "y": -500, + "x": 100, + "y": -50, } `; @@ -2278,16 +2807,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": "0.00633", "id": "id4", "index": "a2", - "isDeleted": false, + "isDeleted": true, "lastCommittedPoint": null, "link": null, "locked": false, @@ -2298,8 +2830,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -2309,26 +2841,151 @@ 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": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 8, + "width": "95.00000", + "x": 0, + "y": 0, } `; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `7`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] redo stack 1`] = `[]`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] redo stack 1`] = ` +[ + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedElementIds": {}, + "selectedLinearElementId": null, + "selectedLinearElementIsEditing": null, + }, + "inserted": { + "selectedElementIds": { + "id4": true, + }, + "selectedLinearElementId": "id4", + "selectedLinearElementIsEditing": false, + }, + }, + }, + "elements": { + "added": { + "id4": { + "deleted": { + "isDeleted": true, + "version": 8, + }, + "inserted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": "0.00633", + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + "95.00000", + "0.00633", + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "version": 7, + "width": "95.00000", + "x": 0, + "y": 0, + }, + }, + }, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + }, + "id1": { + "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + }, + }, + }, + "id": "id7", + }, +] +`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] undo stack 1`] = ` [ @@ -2537,6 +3194,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2797,6 +3455,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3114,6 +3773,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3406,6 +4066,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3690,6 +4351,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3923,6 +4585,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4178,6 +4841,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4447,6 +5111,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4674,6 +5339,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4901,6 +5567,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5146,6 +5813,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5400,6 +6068,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5656,6 +6325,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5916,6 +6586,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6277,6 +6948,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6649,6 +7321,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6959,6 +7632,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6982,6 +7656,37 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id0", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -7220,6 +7925,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7416,6 +8122,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7766,6 +8473,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8116,6 +8824,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, @@ -8520,6 +9229,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, @@ -8805,6 +9515,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, @@ -9067,6 +9778,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9330,6 +10042,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9560,6 +10273,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9855,6 +10569,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9878,6 +10593,37 @@ exports[`history > multiplayer undo/redo > should override remotely added points "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id0", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -10202,6 +10948,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10425,6 +11172,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, @@ -10601,8 +11349,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", @@ -10640,8 +11387,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", @@ -10689,8 +11435,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", @@ -10727,8 +11472,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", @@ -10871,6 +11615,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11129,6 +11874,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11362,6 +12108,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, @@ -11597,6 +12344,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11998,6 +12746,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, @@ -12206,6 +12955,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, @@ -12414,6 +13164,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, @@ -12636,6 +13387,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, @@ -12858,6 +13610,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, @@ -13101,6 +13854,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13336,6 +14090,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, @@ -13571,6 +14326,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13816,6 +14572,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, @@ -14145,6 +14902,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14313,6 +15071,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, @@ -14595,6 +15354,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, @@ -14856,6 +15616,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15007,6 +15768,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15287,6 +16049,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15447,6 +16210,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15470,6 +16234,74 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id13", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + { + "id": "id13", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": 941653321, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -15582,7 +16414,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -15620,7 +16452,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 6, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -15656,7 +16488,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -15673,13 +16505,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "id": "id13", "index": "a3", "isDeleted": false, @@ -15693,8 +16528,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -15704,24 +16539,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of renders 1`] = `12`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of renders 1`] = `10`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] redo stack 1`] = ` [ @@ -16053,13 +16891,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "index": "a3", "isDeleted": false, "lastCommittedPoint": null, @@ -16072,8 +16913,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -16083,21 +16924,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, + "version": 7, + "width": "95.00000", "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -16150,6 +16994,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16173,6 +17018,74 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id13", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + { + "id": "id13", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": 941653321, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -16285,7 +17198,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -16323,7 +17236,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -16359,7 +17272,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -16376,13 +17289,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "id": "id13", "index": "a3", "isDeleted": false, @@ -16396,8 +17312,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -16407,24 +17323,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of renders 1`] = `12`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of renders 1`] = `10`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] redo stack 1`] = `[]`; @@ -16678,13 +17597,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "index": "a3", "isDeleted": false, "lastCommittedPoint": null, @@ -16697,8 +17619,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -16708,21 +17630,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -16735,19 +17660,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 8, + "version": 4, }, "inserted": { "boundElements": [], - "version": 5, - }, - }, - "id1": { - "deleted": { - "version": 8, - }, - "inserted": { - "version": 6, + "version": 3, }, }, "id2": { @@ -16758,16 +17675,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 7, + "version": 3, }, "inserted": { "boundElements": [], - "version": 4, + "version": 2, }, }, }, }, - "id": "id17", + "id": "id15", }, ] `; @@ -16783,6 +17700,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16806,6 +17724,74 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id13", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + { + "id": "id13", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": 941653321, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -16918,7 +17904,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 12, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -16956,7 +17942,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 12, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -16992,7 +17978,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -17009,13 +17995,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "id": "id13", "index": "a3", "isDeleted": false, @@ -17029,8 +18018,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -17040,24 +18029,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of renders 1`] = `20`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of renders 1`] = `10`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] redo stack 1`] = `[]`; @@ -17094,14 +18086,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 8, + "version": 2, "width": 100, "x": -100, "y": -50, }, "inserted": { "isDeleted": true, - "version": 7, + "version": 1, }, }, "id1": { @@ -17133,7 +18125,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "text": "ola", "textAlign": "left", "type": "text", - "version": 9, + "version": 2, "verticalAlign": "top", "width": 100, "x": -200, @@ -17141,7 +18133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "inserted": { "isDeleted": true, - "version": 8, + "version": 1, }, }, "id2": { @@ -17165,20 +18157,20 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "version": 6, + "version": 2, "width": 100, "x": 100, "y": -50, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 1, }, }, }, "updated": {}, }, - "id": "id21", + "id": "id4", }, { "appState": AppStateDelta { @@ -17198,7 +18190,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id22", + "id": "id7", }, { "appState": AppStateDelta { @@ -17218,7 +18210,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id23", + "id": "id10", }, { "appState": AppStateDelta { @@ -17245,11 +18237,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "text", }, ], - "version": 9, + "version": 3, }, "inserted": { "boundElements": [], - "version": 8, + "version": 2, }, }, "id1": { @@ -17257,7 +18249,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": "id0", "height": 25, "textAlign": "center", - "version": 10, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17267,7 +18259,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": null, "height": 100, "textAlign": "left", - "version": 9, + "version": 2, "verticalAlign": "top", "width": 100, "x": -200, @@ -17276,7 +18268,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, }, }, - "id": "id24", + "id": "id12", }, { "appState": AppStateDelta { @@ -17311,13 +18303,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "index": "a3", "isDeleted": false, "lastCommittedPoint": null, @@ -17330,8 +18325,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -17341,21 +18336,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -17368,19 +18366,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 12, + "version": 4, }, "inserted": { "boundElements": [], - "version": 9, - }, - }, - "id1": { - "deleted": { - "version": 12, - }, - "inserted": { - "version": 10, + "version": 3, }, }, "id2": { @@ -17391,16 +18381,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 9, + "version": 3, }, "inserted": { "boundElements": [], - "version": 6, + "version": 2, }, }, }, }, - "id": "id25", + "id": "id15", }, ] `; @@ -17416,6 +18406,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17439,6 +18430,74 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id13", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + { + "id": "id13", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": 941653321, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -17483,13 +18542,15 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "penDetected": false, "penMode": false, - "previousSelectedElementIds": {}, + "previousSelectedElementIds": { + "id0": true, + }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, "selectedElementIds": { - "id0": true, + "id13": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -17522,14 +18583,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", }, + { + "id": "id13", + "type": "arrow", + }, ], "customData": undefined, "fillStyle": "solid", @@ -17549,7 +18610,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -17587,7 +18648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17623,7 +18684,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -17640,13 +18701,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "id": "id13", "index": "a3", "isDeleted": false, @@ -17660,8 +18724,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -17671,93 +18735,29 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `14`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `10`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id0": true, - }, - }, - "inserted": { - "selectedElementIds": {}, - }, - }, - }, - "elements": { - "added": {}, - "removed": { - "id0": { - "deleted": { - "isDeleted": false, - "version": 8, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - "id1": { - "deleted": { - "isDeleted": false, - "version": 8, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - }, - "updated": { - "id13": { - "deleted": { - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, - "version": 10, - }, - "inserted": { - "startBinding": null, - "version": 7, - }, - }, - "id2": { - "deleted": { - "version": 5, - }, - "inserted": { - "version": 3, - }, - }, - }, - }, - "id": "id21", - }, -] -`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = `[]`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] undo stack 1`] = ` [ @@ -18009,13 +19009,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "index": "a3", "isDeleted": false, "lastCommittedPoint": null, @@ -18028,8 +19031,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -18039,21 +19042,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, + "version": 7, + "width": "95.00000", "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -18133,6 +19139,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18156,6 +19163,74 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id13", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id1", + "type": "text", + }, + { + "id": "id13", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 100, + "id": "id0", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": 1, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": 941653321, + "width": 100, + "x": -100, + "y": -50, + }, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -18208,8 +19283,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "scrollY": 0, "searchMatches": null, "selectedElementIds": { - "id0": true, - "id2": true, + "id13": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -18242,14 +19316,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", }, + { + "id": "id13", + "type": "arrow", + }, ], "customData": undefined, "fillStyle": "solid", @@ -18269,7 +19343,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 8, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -18307,7 +19381,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 8, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -18343,7 +19417,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -18360,13 +19434,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "id": "id13", "index": "a3", "isDeleted": false, @@ -18380,8 +19457,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -18391,102 +19468,29 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": 98, - "x": 1, + "version": 7, + "width": "95.00000", + "x": 0, "y": 0, } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `15`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `10`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id0": true, - "id2": true, - }, - }, - "inserted": { - "selectedElementIds": {}, - }, - }, - }, - "elements": { - "added": {}, - "removed": { - "id0": { - "deleted": { - "isDeleted": false, - "version": 8, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - "id1": { - "deleted": { - "isDeleted": false, - "version": 8, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - "id2": { - "deleted": { - "isDeleted": false, - "version": 5, - }, - "inserted": { - "isDeleted": true, - "version": 4, - }, - }, - }, - "updated": { - "id13": { - "deleted": { - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, - "version": 11, - }, - "inserted": { - "endBinding": null, - "startBinding": null, - "version": 8, - }, - }, - }, - }, - "id": "id24", - }, -] -`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = `[]`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] undo stack 1`] = ` [ @@ -18738,13 +19742,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00633", "index": "a3", "isDeleted": false, "lastCommittedPoint": null, @@ -18757,8 +19764,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "95.00000", + "0.00633", ], ], "roughness": 1, @@ -18768,21 +19775,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "inside", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, + "version": 7, + "width": "95.00000", "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -18882,6 +19892,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19360,6 +20371,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19869,6 +20881,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20326,6 +21339,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20349,6 +21363,37 @@ exports[`history > singleplayer undo/redo > should support linear element creati "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id0", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 0, + 0, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -20441,7 +21486,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 20, + "height": 10, "id": "id0", "index": "a0", "isDeleted": false, @@ -20463,7 +21508,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati ], [ 20, - 20, + 0, ], ], "roughness": 1, @@ -20477,7 +21522,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 10, "width": 20, "x": 0, "y": 0, @@ -20486,9 +21531,121 @@ 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`] = `12`; -exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = `[]`; +exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = ` +[ + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElementIsEditing": true, + }, + "inserted": { + "selectedLinearElementIsEditing": false, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id14", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "height": 10, + "points": [ + [ + 0, + 0, + ], + [ + 10, + 10, + ], + [ + 20, + 0, + ], + ], + "version": 10, + }, + "inserted": { + "height": 20, + "points": [ + [ + 0, + 0, + ], + [ + 10, + 10, + ], + [ + 20, + 20, + ], + ], + "version": 9, + }, + }, + }, + }, + "id": "id18", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElementIsEditing": false, + }, + "inserted": { + "selectedLinearElementIsEditing": true, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id19", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElementId": null, + "selectedLinearElementIsEditing": null, + }, + "inserted": { + "selectedLinearElementId": "id0", + "selectedLinearElementIsEditing": false, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, + "id": "id20", + }, +] +`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] undo stack 1`] = ` [ @@ -20550,20 +21707,20 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 6, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 12, + "version": 5, }, }, }, "updated": {}, }, - "id": "id23", + "id": "id2", }, { "appState": AppStateDelta { @@ -20596,7 +21753,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 8, "width": 20, }, "inserted": { @@ -20614,7 +21771,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 10, ], ], - "version": 13, + "version": 6, "width": 10, }, }, diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 52614ed5f..556a41c35 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -95,135 +95,3 @@ exports[`move element > rectangle 5`] = ` "y": 40, } `; - -exports[`move element > rectangles with binding arrow 5`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id6", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": "id0", - "index": "a0", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": 1278240551, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 4, - "versionNonce": 1006504105, - "width": 100, - "x": 0, - "y": 0, -} -`; - -exports[`move element > rectangles with binding arrow 6`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id6", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 300, - "id": "id3", - "index": "a1", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": 1116226695, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 7, - "versionNonce": 1984422985, - "width": 300, - "x": 201, - "y": 2, -} -`; - -exports[`move element > rectangles with binding arrow 7`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id3", - "focus": "-0.46667", - "gap": 10, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": "81.40630", - "id": "id6", - "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - "81.00000", - "81.40630", - ], - ], - "roughness": 1, - "roundness": { - "type": 2, - }, - "seed": 23633383, - "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": "-0.60000", - "gap": 10, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 11, - "versionNonce": 1573789895, - "width": "81.00000", - "x": "110.00000", - "y": 50, -} -`; diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index a895eb636..57c7b85e0 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, @@ -6140,6 +6161,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": null, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -6971,6 +6993,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 +7326,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 +7604,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 +7838,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 +8077,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 +8256,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 +8435,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 +8614,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, @@ -8608,6 +8638,37 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id0", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 10, + 10, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -8668,7 +8729,6 @@ 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, @@ -8690,7 +8750,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 +8872,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, @@ -8836,6 +8896,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": null, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -8896,7 +8957,6 @@ 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, @@ -8918,7 +8978,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 +9098,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 +9293,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, @@ -9256,6 +9317,37 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id0", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 10, + 10, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -9316,7 +9408,6 @@ 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, @@ -9338,7 +9429,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 +9551,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 +9730,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, @@ -9662,6 +9754,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": null, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -9722,7 +9815,6 @@ 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, @@ -9744,7 +9836,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 +9956,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 +10135,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 +10330,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 +10509,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 +11039,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 +11318,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 +11440,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 +11639,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 +11957,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 +12385,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 +13024,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 +13149,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 +13779,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 +14117,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 +14380,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 +14502,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, @@ -14419,6 +14526,37 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "defaultSidebarDockedPreference": false, "editingFrame": null, "editingGroupId": null, + "editingLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id6", + "hoverPointIndex": -1, + "isDragging": false, + "isEditing": false, + "lastUncommittedPoint": null, + "pointerDownState": { + "arrowOriginalStartPoint": [ + 130, + 10, + ], + "lastClickedIsEndPoint": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + "startBindingElement": null, + }, "editingTextElement": null, "elementsToHighlight": null, "errorMessage": null, @@ -14786,6 +14924,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 +15046,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 707fe4e48..7f47124bb 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1131,7 +1131,7 @@ describe("history", () => { 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` + 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({ @@ -1148,7 +1148,7 @@ describe("history", () => { 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?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.elements).toEqual([ expect.objectContaining({ @@ -1165,7 +1165,7 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(5); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1181,7 +1181,7 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(6); expect(API.getSelectedElements().length).toBe(0); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1197,7 +1197,7 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(5); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1213,7 +1213,7 @@ describe("history", () => { 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?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.elements).toEqual([ expect.objectContaining({ @@ -1638,13 +1638,15 @@ 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]), + focus: 0, + gap: 0, }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + focus: 0, + gap: 0, }); expect(rect1.boundElements).toStrictEqual([ { id: text.id, type: "text" }, @@ -1661,13 +1663,15 @@ 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]), + focus: 0, + gap: 0, }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + focus: 0, + gap: 0, }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1684,13 +1688,15 @@ 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]), + focus: 0, + gap: 0, }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + focus: 0, + gap: 0, }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1715,13 +1721,15 @@ 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]), + focus: 0, + gap: 0, }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + focus: 0, + gap: 0, }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1738,13 +1746,15 @@ 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]), + focus: 0, + gap: 0, }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + focus: 0, + gap: 0, }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -2347,15 +2357,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, }, ], @@ -4752,9 +4760,8 @@ describe("history", () => { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }), remoteContainer, @@ -4841,15 +4848,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", }, }); @@ -4950,15 +4955,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, { @@ -5078,13 +5081,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, }), 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..b2a7d1569 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"; @@ -85,10 +81,18 @@ describe("move element", () => { 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", + "start", h.app.scene, ); }); @@ -124,8 +128,10 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([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([[50, 50]]); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([ + [301.02, 102.02], + ]); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); 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 e321b34cb..8fc3c0c17 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/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,