diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index b972e6e5b..67536cc4d 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -662,8 +662,8 @@ const ExcalidrawWrapper = () => { debugRenderer( debugCanvasRef.current, appState, + elements, window.devicePixelRatio, - () => forceRefresh((prev) => !prev), ); } }; diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 71e3885b1..9538495b2 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, 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,176 @@ 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) { + if ( + !elementsMap + .get(element.startBinding.elementId) + ?.boundElements?.find((e) => e.id === element.id) + ) { + return; + } + + _renderBinding( + context, + element.startBinding, + elementsMap, + zoom, + dim, + dim, + "red", + ); + } + + if (element.endBinding) { + if ( + !elementsMap + .get(element.endBinding.elementId) + ?.boundElements?.find((e) => e.id === element.id) + ) { + return; + } + _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 +289,8 @@ const render = ( const _debugRenderer = ( canvas: HTMLCanvasElement, appState: AppState, + elements: readonly OrderedExcalidrawElement[], scale: number, - refresh: () => void, ) => { const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( canvas, @@ -131,6 +313,7 @@ const _debugRenderer = ( ); renderOrigin(context, appState.zoom.value); + renderBindings(context, elements, appState.zoom.value); if ( window.visualDebug?.currentFrame && @@ -182,10 +365,10 @@ export const debugRenderer = throttleRAF( ( canvas: HTMLCanvasElement, appState: AppState, + elements: readonly OrderedExcalidrawElement[], scale: number, - refresh: () => void, ) => { - _debugRenderer(canvas, appState, scale, refresh); + _debugRenderer(canvas, appState, elements, scale); }, { trailing: true }, ); diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index aef2fda9f..dcec53190 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -524,3 +524,5 @@ export enum UserIdleState { export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const DOUBLE_TAP_POSITION_THRESHOLD = 35; + +export const BIND_MODE_TIMEOUT = 800; // ms diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 79f243f4f..9e28ce413 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -10,3 +10,4 @@ export * from "./random"; export * from "./url"; export * from "./utils"; export * from "./emitter"; +export * from "./visualdebug"; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 105496065..35638fc23 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,4 +1,5 @@ import { average } from "@excalidraw/math"; +import { isImageElement } from "@excalidraw/element"; import type { ExcalidrawBindableElement, @@ -566,8 +567,8 @@ export const isTransparent = (color: string) => { ); }; -export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) => - el.fillStyle !== "solid" || isTransparent(el.backgroundColor); +export const isAlwaysInsideBinding = (element: ExcalidrawBindableElement) => + isImageElement(element); export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] diff --git a/packages/utils/src/visualdebug.ts b/packages/common/src/visualdebug.ts similarity index 99% rename from packages/utils/src/visualdebug.ts rename to packages/common/src/visualdebug.ts index 961fa919f..9cdbbd7e9 100644 --- a/packages/utils/src/visualdebug.ts +++ b/packages/common/src/visualdebug.ts @@ -63,6 +63,8 @@ export const debugDrawLine = ( ); }; +export const testDebug = () => {}; + export const debugDrawPoint = ( p: GlobalPoint, opts?: { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 9d97801f2..07a5ea45b 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,11 +1,8 @@ import { KEYS, arrayToMap, - isBindingFallthroughEnabled, - tupleToCoors, invariant, - isDevEnv, - isTestEnv, + isAlwaysInsideBinding, } from "@excalidraw/common"; import { @@ -20,13 +17,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 +31,12 @@ import { getCenterForBounds, getElementBounds, } from "./bounds"; -import { intersectElementWithLineSegment } from "./collision"; +import { + getHoveredElementForBinding, + hitElementItself, + intersectElementWithLineSegment, + isPointInElement, +} from "./collision"; import { distanceToElement } from "./distance"; import { headingForPointFromElement, @@ -53,9 +52,6 @@ import { isBindableElement, isBoundToContainer, isElbowArrow, - isFixedPointBinding, - isFrameLikeElement, - isLinearElement, isRectanguloidElement, isTextElement, } from "./typeChecks"; @@ -71,8 +67,6 @@ import type { ExcalidrawBindableElement, ExcalidrawElement, NonDeleted, - ExcalidrawLinearElement, - PointBinding, NonDeletedExcalidrawElement, ElementsMap, NonDeletedSceneElementsMap, @@ -82,17 +76,35 @@ import type { FixedPoint, FixedPointBinding, PointsPositionUpdates, + Ordered, + BindMode, } from "./types"; -export type SuggestedBinding = - | NonDeleted - | SuggestedPointBinding; +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 type SuggestedPointBinding = [ - NonDeleted, - "start" | "end" | "both", - NonDeleted, -]; +export const FIXED_BINDING_DISTANCE = 5; + +export const getFixedBindingDistance = ( + element: ExcalidrawBindableElement, +): number => FIXED_BINDING_DISTANCE + element.strokeWidth / 2; export const shouldEnableBindingForPointerEvent = ( event: React.PointerEvent, @@ -104,633 +116,619 @@ export const isBindingEnabled = (appState: AppState): boolean => { return appState.isBindingEnabled; }; -export const FIXED_BINDING_DISTANCE = 5; -export const BINDING_HIGHLIGHT_THICKNESS = 10; - -const getNonDeletedElements = ( +export const bindOrUnbindBindingElement = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, scene: Scene, - ids: readonly ExcalidrawElement["id"][], -): NonDeleted[] => { - const result: NonDeleted[] = []; - ids.forEach((id) => { - const element = scene.getNonDeletedElement(id); - if (element != null) { - result.push(element); + appState: AppState, + opts?: { + newArrow: boolean; + }, +) => { + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + arrow, + draggingPoints, + scene.getNonDeletedElementsMap(), + scene.getNonDeletedElements(), + appState, + { + ...opts, + }, + ); + + bindOrUnbindBindingElementEdge(arrow, start, "start", scene); + bindOrUnbindBindingElementEdge(arrow, end, "end", scene); + if (start.focusPoint || end.focusPoint) { + // If the strategy dictates a focus point override, then + // update the arrow points to point to the focus point. + const updates: PointsPositionUpdates = new Map(); + + if (start.focusPoint) { + updates.set(0, { + point: + updateBoundPoint( + arrow, + "startBinding", + arrow.startBinding, + start.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[0], + }); } - }); - return result; + + if (end.focusPoint) { + updates.set(arrow.points.length - 1, { + point: + updateBoundPoint( + arrow, + "endBinding", + arrow.endBinding, + end.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[arrow.points.length - 1], + }); + } + + LinearElementEditor.movePoints(arrow, scene, updates); + } + + return { start, end }; }; -export const bindOrUnbindLinearElement = ( - linearElement: NonDeleted, - startBindingElement: ExcalidrawBindableElement | null | "keep", - endBindingElement: ExcalidrawBindableElement | null | "keep", - scene: Scene, -): void => { - const elementsMap = scene.getNonDeletedElementsMap(); - const boundToElementIds: Set = new Set(); - const unboundFromElementIds: Set = new Set(); - bindOrUnbindLinearElementEdge( - linearElement, - startBindingElement, - endBindingElement, - "start", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - bindOrUnbindLinearElementEdge( - linearElement, - endBindingElement, - startBindingElement, - "end", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - - const onlyUnbound = Array.from(unboundFromElementIds).filter( - (id) => !boundToElementIds.has(id), - ); - - getNonDeletedElements(scene, onlyUnbound).forEach((element) => { - scene.mutateElement(element, { - boundElements: element.boundElements?.filter( - (element) => - element.type !== "arrow" || element.id !== linearElement.id, - ), - }); - }); -}; - -const bindOrUnbindLinearElementEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement | null | "keep", - otherEdgeBindableElement: ExcalidrawBindableElement | null | "keep", +const bindOrUnbindBindingElementEdge = ( + arrow: NonDeleted, + { mode, element, focusPoint }: BindingStrategy, startOrEnd: "start" | "end", - // Is mutated - boundToElementIds: Set, - // Is mutated - unboundFromElementIds: Set, scene: Scene, - elementsMap: ElementsMap, ): void => { - // "keep" is for method chaining convenience, a "no-op", so just bail out - if (bindableElement === "keep") { - return; - } - - // null means break the bind, so nothing to consider here - if (bindableElement === null) { - const unbound = unbindLinearElement(linearElement, startOrEnd, scene); - if (unbound != null) { - unboundFromElementIds.add(unbound); - } - return; - } - - // While complext arrows can do anything, simple arrow with both ends trying - // to bind to the same bindable should not be allowed, start binding takes - // precedence - if (isLinearElementSimple(linearElement)) { - if ( - otherEdgeBindableElement == null || - (otherEdgeBindableElement === "keep" - ? // TODO: Refactor - Needlessly complex - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - bindableElement, - startOrEnd, - ) - : startOrEnd === "start" || - otherEdgeBindableElement.id !== bindableElement.id) - ) { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); - } - } else { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); + if (mode === null) { + // null means break the binding + unbindBindingElement(arrow, startOrEnd, scene); + } else if (mode !== undefined) { + bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint); } }; -const getOriginalBindingsIfStillCloseToArrowEnds = ( - linearElement: NonDeleted, +const bindingStrategyForElbowArrowEndpointDragging = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], -): (NonDeleted | null)[] => - (["start", "end"] as const).map((edge) => { - const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); - const elementId = - edge === "start" - ? linearElement.startBinding?.elementId - : linearElement.endBinding?.elementId; - if (elementId) { - const element = elementsMap.get(elementId); - if ( - isBindableElement(element) && - bindingBorderTest(element, coors, elementsMap, zoom) - ) { - return element; + elements: readonly Ordered[], +): { + start: BindingStrategy; + end: BindingStrategy; +} => { + invariant(draggingPoints.size === 1, "Bound elbow arrows cannot be moved"); + + const update = draggingPoints.entries().next().value; + + invariant( + update, + "There should be a position update for dragging an elbow arrow endpoint", + ); + + const [pointIdx, { point }] = update; + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + point, + elementsMap, + ); + const hit = getHoveredElementForBinding(globalPoint, elements, elementsMap); + + const current = hit + ? { + element: hit, + mode: "orbit" as const, + focusPoint: LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + pointIdx, + elementsMap, + ), + } + : { + mode: null, + }; + const other = { mode: undefined }; + + return pointIdx === 0 + ? { start: current, end: other } + : { start: other, end: current }; +}; + +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 }; + + const point = LinearElementEditor.getPointGlobalCoordinates( + arrow, + draggingPoints.get(startDragged ? startIdx : endIdx)!.point, + elementsMap, + ); + const hit = getHoveredElementForBinding(point, elements, elementsMap); + + // With new arrows this handles the binding at arrow creation + if (startDragged) { + if (hit) { + start = { + element: hit, + mode: "inside", + focusPoint: point, + }; + } else { + start = { mode: null }; + } + + return { start, end }; + } + + // With new arrows it represents the continuous dragging of the end point + if (endDragged) { + const origin = appState?.selectedLinearElement?.pointerDownState.origin; + + // Inside -> inside binding + if (hit && arrow.startBinding?.elementId === hit.id) { + const center = pointFrom( + hit.x + hit.width / 2, + hit.y + hit.height / 2, + ); + + return { + start: { + mode: "inside", + element: hit, + focusPoint: origin ?? center, + }, + end: { mode: "inside", element: hit, focusPoint: point }, + }; + } + + // Inside -> outside binding + if (arrow.startBinding && arrow.startBinding.elementId !== hit?.id) { + const otherElement = elementsMap.get( + arrow.startBinding.elementId, + ) as ExcalidrawBindableElement; + invariant(otherElement, "Other element must be in the elements map"); + + const otherIsInsideBinding = + !!appState.selectedLinearElement?.pointerDownState.arrowStartIsInside; + + const other: BindingStrategy = { + mode: otherIsInsideBinding ? "inside" : "orbit", + element: otherElement, + focusPoint: otherIsInsideBinding + ? origin ?? pointFrom(arrow.x, arrow.y) + : snapToCenter( + otherElement, + elementsMap, + origin ?? pointFrom(arrow.x, arrow.y), + ), + }; + + // We are hovering another element with the end point + let current: BindingStrategy; + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || globalBindMode === "skip"; + current = { + mode: isInsideBinding ? "inside" : "orbit", + element: hit, + focusPoint: isInsideBinding + ? point + : snapToCenter(hit, elementsMap, point), + }; + } else { + current = { mode: null }; + } + + return { + start: other, + end: current, + }; + } + + // No start binding + if (!arrow.startBinding) { + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || + globalBindMode === "skip" || + isAlwaysInsideBinding(hit); + + end = { + mode: isInsideBinding ? "inside" : "orbit", + element: hit, + 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[], + globalBindMode?: AppState["bindMode"], + opts?: { + appState?: AppState; + }, +): { current: BindingStrategy; other: BindingStrategy } => { + let current: BindingStrategy = { mode: undefined }; + let other: BindingStrategy = { mode: undefined }; + + const hit = getHoveredElementForBinding(point, elements, elementsMap); + + // 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" || + globalBindMode === "skip" || + (hit && isAlwaysInsideBinding(hit)) + ) { + current = hit + ? { + element: hit, + focusPoint: point, + mode: "inside", + } + : { mode: undefined }; + + return { current, other }; + } + + // Dragged point is outside of any bindable element + // so we break any existing binding + if (!hit) { + return { current: { mode: null }, other }; + } + + // The dragged point is inside the hovered bindable element + + // The opposite binding is on the same element + // eslint-disable-next-line no-lonely-if + if (oppositeBinding) { + if (oppositeBinding.elementId === hit.id) { + // The opposite binding is on the binding gap of the same element + if (oppositeBinding.mode === "orbit") { + current = { element: hit, 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: hit, 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: hit, + mode: "orbit", + focusPoint: snapToCenter(hit, elementsMap, point), + }; - 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", - 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"]; + return { current, other }; + } + } + // The opposite binding is on a different element or no binding + else { + current = { + element: hit, + mode: "orbit", + focusPoint: point, + }; } - 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; - - return [start, end]; + // Must return as only one endpoint is dragged, therefore + // the end binding strategy might accidentally gets overriden + return { current, other }; }; -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, - ); - - bindOrUnbindLinearElement(selectedElement, start, end, scene); - }); -}; - -export const getSuggestedBindingsForArrows = ( - selectedElements: NonDeleted[], +export const getBindingStrategyForDraggingBindingElementEndpoints = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, elementsMap: NonDeletedSceneElementsMap, - zoom: AppState["zoom"], -): SuggestedBinding[] => { - // HOT PATH: Bail out if selected elements list is too large - if (selectedElements.length > 50) { - return []; - } - - return ( - selectedElements - .filter(isLinearElement) - .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), - ) - .filter( - (element): element is NonDeleted => - element !== null, - ) - // Filter out bind candidates which are in the - // same selection / group with the arrow - // - // TODO: Is it worth turning the list into a set to avoid dupes? - .filter( - (element) => - selectedElements.filter((selected) => selected.id === element?.id) - .length === 0, - ) - ); -}; - -export const maybeSuggestBindingsForLinearElementAtCoords = ( - linearElement: NonDeleted, - /** scene coords */ - pointerCoords: { - x: number; - y: number; - }[], - 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>, - ), - ); - -export const maybeBindLinearElement = ( - linearElement: NonDeleted, + elements: readonly Ordered[], appState: AppState, - pointerCoords: { x: number; y: number }, - scene: Scene, -): void => { - const elements = scene.getNonDeletedElements(); - const elementsMap = scene.getNonDeletedElementsMap(); + 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); - if (appState.startBoundElement != null) { - bindLinearElement( - linearElement, - appState.startBoundElement, - "start", - scene, + 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 + if (isElbowArrow(arrow)) { + return bindingStrategyForElbowArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, ); } - const hoveredElement = getHoveredElementForBinding( - pointerCoords, - elements, - elementsMap, - appState.zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); + // Handle new arrow creation separately, as it is special + if (opts?.newArrow) { + const { start, end } = bindingStrategyForNewSimpleArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + startDragged, + endDragged, + startIdx, + endIdx, + appState, + globalBindMode, + ); - if (hoveredElement !== null) { - if ( - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - hoveredElement, - "end", - ) - ) { - bindLinearElement(linearElement, hoveredElement, "end", scene); - } + return { start, end }; } + + // 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, + globalBindMode, + { appState }, + ); + + 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, + globalBindMode, + { appState }, + ); + + return { start: other, end: current }; + } + + return { start, end }; }; -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, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", +export const bindOrUnbindBindingElements = ( + selectedArrows: NonDeleted[], scene: Scene, + appState: AppState, ): void => { - if (!isArrowElement(linearElement)) { - return; + selectedArrows.forEach((arrow) => { + bindOrUnbindBindingElement( + arrow, + new Map(), // No dragging points in this case + scene, + appState, + ); + }); +}; + +export const maybeSuggestBindingsForBindingElementAtCoords = ( + linearElement: NonDeleted, + startOrEndOrBoth: "start" | "end" | "both", + scene: Scene, + pointerCoords: GlobalPoint, +): AppState["suggestedBinding"] => { + const startCoords = + startOrEndOrBoth === "start" + ? pointerCoords + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + 0, + scene.getNonDeletedElementsMap(), + ); + const endCoords = + startOrEndOrBoth === "end" + ? pointerCoords + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + -1, + scene.getNonDeletedElementsMap(), + ); + const startHovered = getHoveredElementForBinding( + startCoords, + scene.getNonDeletedElements(), + scene.getNonDeletedElementsMap(), + ); + const endHovered = getHoveredElementForBinding( + endCoords, + scene.getNonDeletedElements(), + scene.getNonDeletedElementsMap(), + ); + + let suggestedBinding: AppState["suggestedBinding"] = null; + + 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) { + suggestedBinding = startHovered; + } + } else if (startOrEndOrBoth === "start" && startHovered != null) { + suggestedBinding = startHovered; + } else if (startOrEndOrBoth === "end" && endHovered != null) { + suggestedBinding = endHovered; } - let binding: PointBinding | FixedPointBinding = { - elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - scene.getNonDeletedElementsMap(), - ), - hoveredElement, - ), - }; + return suggestedBinding; +}; - if (isElbowArrow(linearElement)) { +export const bindBindingElement = ( + arrow: NonDeleted, + hoveredElement: ExcalidrawBindableElement, + mode: BindMode, + startOrEnd: "start" | "end", + scene: Scene, + focusPoint?: GlobalPoint, +): void => { + const elementsMap = scene.getNonDeletedElementsMap(); + + let binding: FixedPointBinding; + + if (isElbowArrow(arrow)) { binding = { - ...binding, + elementId: hoveredElement.id, + mode: "orbit", ...calculateFixedPointForElbowArrowBinding( - linearElement, + arrow, hoveredElement, startOrEnd, - scene.getNonDeletedElementsMap(), + elementsMap, + ), + }; + } else { + binding = { + elementId: hoveredElement.id, + mode, + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + hoveredElement, + startOrEnd, + elementsMap, + focusPoint, ), }; } - scene.mutateElement(linearElement, { + scene.mutateElement(arrow, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, }); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); - if (!boundElementsMap.has(linearElement.id)) { + if (!boundElementsMap.has(arrow.id)) { scene.mutateElement(hoveredElement, { boundElements: (hoveredElement.boundElements || []).concat({ - id: linearElement.id, + id: arrow.id, type: "arrow", }), }); } }; -// Don't bind both ends of a simple segment -const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", -): boolean => { - const otherBinding = - linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; - return isLinearElementSimpleAndAlreadyBound( - linearElement, - otherBinding?.elementId, - bindableElement, - ); -}; - -export const isLinearElementSimpleAndAlreadyBound = ( - linearElement: NonDeleted, - alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, - bindableElement: ExcalidrawBindableElement, -): boolean => { - return ( - alreadyBoundToId === bindableElement.id && - isLinearElementSimple(linearElement) - ); -}; - -const isLinearElementSimple = ( - linearElement: NonDeleted, -): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement); - -const unbindLinearElement = ( - linearElement: NonDeleted, +export const unbindBindingElement = ( + arrow: NonDeleted, startOrEnd: "start" | "end", scene: Scene, ): ExcalidrawBindableElement["id"] | null => { const field = startOrEnd === "start" ? "startBinding" : "endBinding"; - const binding = linearElement[field]; + const binding = arrow[field]; + if (binding == null) { return null; } - scene.mutateElement(linearElement, { [field]: null }); - return binding.elementId; -}; -export const getHoveredElementForBinding = ( - pointerCoords: { - x: number; - y: number; - }, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, - considerAllElements?: boolean, -): NonDeleted | null => { - if (considerAllElements) { - let cullRest = false; - const candidateElements = getAllElementsAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - (fullShape || - !isBindingFallthroughEnabled( - element as ExcalidrawBindableElement, - )) && - // disable fullshape snapping for frame elements so we - // can bind to frame children - !isFrameLikeElement(element), - ), - ).filter((element) => { - if (cullRest) { - return false; - } - - if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) { - cullRest = true; - } - - return true; - }) as NonDeleted[] | null; - - // Return early if there are no candidates or just one candidate - if (!candidateElements || candidateElements.length === 0) { - return null; - } - - if (candidateElements.length === 1) { - return candidateElements[0] as NonDeleted; - } - - // Prefer the shape with the border being tested (if any) - const borderTestElements = candidateElements.filter((element) => - bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), - ); - if (borderTestElements.length === 1) { - return borderTestElements[0]; - } - - // Prefer smaller shapes - return candidateElements - .sort( - (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), - ) - .pop() as NonDeleted; - } - - const hoveredElement = getElementAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element), + const oppositeBinding = + arrow[startOrEnd === "start" ? "endBinding" : "startBinding"]; + if (!oppositeBinding || oppositeBinding.elementId !== binding.elementId) { + // Only remove the record on the bound element if the other + // end is not bound to the same element + const boundElement = scene + .getNonDeletedElementsMap() + .get(binding.elementId) as ExcalidrawBindableElement; + scene.mutateElement(boundElement, { + boundElements: boundElement.boundElements?.filter( + (element) => element.id !== arrow.id, ), - ); - - return hoveredElement as NonDeleted | null; -}; - -const getElementAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - let hitElement = null; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - // because array is ordered from lower z-index to highest and we want element z-index - // with higher z-index - for (let index = elements.length - 1; index >= 0; --index) { - const element = elements[index]; - if (element.isDeleted) { - continue; - } - if (isAtPositionFn(element)) { - hitElement = element; - break; - } + }); } - return hitElement; -}; + scene.mutateElement(arrow, { [field]: null }); -const getAllElementsAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - const elementsAtPosition: NonDeletedExcalidrawElement[] = []; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - // because array is ordered from lower z-index to highest and we want element z-index - // with higher z-index - for (let index = elements.length - 1; index >= 0; --index) { - const element = elements[index]; - if (element.isDeleted) { - continue; - } - - if (isAtPositionFn(element)) { - elementsAtPosition.push(element); - } - } - - return elementsAtPosition; -}; - -const calculateFocusAndGap = ( - linearElement: NonDeleted, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { focus: number; gap: number } => { - const direction = startOrEnd === "start" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - const adjacentPointIndex = edgePointIndex - direction; - - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - - return { - focus: determineFocusDistance( - hoveredElement, - elementsMap, - adjacentPoint, - edgePoint, - ), - gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)), - }; + return binding.elementId; }; // Supports translating, rotating and scaling `changedElement` with bound @@ -740,7 +738,6 @@ export const updateBoundElements = ( scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { @@ -748,7 +745,7 @@ export const updateBoundElements = ( return; } - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -762,7 +759,7 @@ export const updateBoundElements = ( } boundElementsVisitor(elementsMap, changedElement, (element) => { - if (!isLinearElement(element) || element.isDeleted) { + if (!isArrowElement(element) || element.isDeleted) { return; } @@ -776,7 +773,10 @@ export const updateBoundElements = ( ? elementsMap.get(element.startBinding.elementId) : null; const endBindingElement = element.endBinding - ? elementsMap.get(element.endBinding.elementId) + ? // PERF: If the arrow is bound to the same element on both ends. + startBindingElement?.id === element.endBinding.elementId + ? startBindingElement + : elementsMap.get(element.endBinding.elementId) : null; let startBounds: Bounds | null = null; @@ -786,22 +786,8 @@ export const updateBoundElements = ( endBounds = getElementBounds(endBindingElement, elementsMap); } - const bindings = { - startBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.startBinding, - newSize, - ), - endBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.endBinding, - newSize, - ), - }; - // `linearElement` is being moved/scaled already, just update the binding if (simultaneouslyUpdatedElementIds.has(element.id)) { - scene.mutateElement(element, bindings); return; } @@ -823,7 +809,7 @@ export const updateBoundElements = ( const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + element[bindingProp], bindableElement, elementsMap, ); @@ -843,12 +829,9 @@ export const updateBoundElements = ( ); LinearElementEditor.movePoints(element, scene, new Map(updates), { - ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } - : {}), - ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } - : {}), + moveMidPointsWithElement: + !!startBindingElement && + startBindingElement?.id === endBindingElement?.id, }); const boundText = getBoundTextElement(element, elementsMap); @@ -861,14 +844,14 @@ export const updateBoundElements = ( export const updateBindings = ( latestElement: ExcalidrawElement, scene: Scene, + appState: AppState, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; - zoom?: AppState["zoom"]; }, ) => { - if (isLinearElement(latestElement)) { - bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom); + if (isArrowElement(latestElement)) { + bindOrUnbindBindingElement(latestElement, new Map(), scene, appState); } else { updateBoundElements(latestElement, scene, { ...options, @@ -878,7 +861,7 @@ export const updateBindings = ( }; const doesNeedUpdate = ( - boundElement: NonDeleted, + boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, ) => { return ( @@ -900,7 +883,6 @@ export const getHeadingForElbowArrowSnap = ( aabb: Bounds | undefined | null, origPoint: GlobalPoint, elementsMap: ElementsMap, - zoom?: AppState["zoom"], ): Heading => { const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); @@ -908,12 +890,9 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding( - origPoint, - bindableElement, - elementsMap, - zoom, - ); + const d = distanceToElement(bindableElement, elementsMap, origPoint); + + const distance = d > 0 ? null : d; if (!distance) { return vectorToHeading( @@ -924,101 +903,98 @@ 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, intersector, - FIXED_BINDING_DISTANCE, + getFixedBindingDistance(bindableElement), ).sort(pointDistanceSq)[0]; } else { - intersection = intersectElementWithLineSegment( - bindableElement, - elementsMap, + const halfVector = vectorScale( + vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), + pointDistance(edgePoint, adjacentPoint) + + Math.max(bindableElement.width, bindableElement.height) + + getFixedBindingDistance(bindableElement) * 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, + getFixedBindingDistance(bindableElement), + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - + pointDistanceSq(h, adjacentPoint), + )[0]; } if ( @@ -1029,7 +1005,7 @@ export const bindPointToSnapToElementOutline = ( return edgePoint; } - return elbowed ? intersection : edgePoint; + return intersection; }; export const avoidRectangularCorner = ( @@ -1042,15 +1018,15 @@ export const avoidRectangularCorner = ( if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { // Top left - if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { + if (nonRotatedPoint[1] - element.y > -getFixedBindingDistance(element)) { return pointRotateRads( - pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), + pointFrom(element.x - getFixedBindingDistance(element), element.y), center, element.angle, ); } return pointRotateRads( - pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE), + pointFrom(element.x, element.y - getFixedBindingDistance(element)), center, element.angle, ); @@ -1059,18 +1035,21 @@ export const avoidRectangularCorner = ( nonRotatedPoint[1] > element.y + element.height ) { // Bottom left - if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { + if (nonRotatedPoint[0] - element.x > -getFixedBindingDistance(element)) { return pointRotateRads( pointFrom( element.x, - element.y + element.height + FIXED_BINDING_DISTANCE, + element.y + element.height + getFixedBindingDistance(element), ), center, element.angle, ); } return pointRotateRads( - pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), + pointFrom( + element.x - getFixedBindingDistance(element), + element.y + element.height, + ), center, element.angle, ); @@ -1081,12 +1060,12 @@ export const avoidRectangularCorner = ( // Bottom right if ( nonRotatedPoint[0] - element.x < - element.width + FIXED_BINDING_DISTANCE + element.width + getFixedBindingDistance(element) ) { return pointRotateRads( pointFrom( element.x + element.width, - element.y + element.height + FIXED_BINDING_DISTANCE, + element.y + element.height + getFixedBindingDistance(element), ), center, element.angle, @@ -1094,7 +1073,7 @@ export const avoidRectangularCorner = ( } return pointRotateRads( pointFrom( - element.x + element.width + FIXED_BINDING_DISTANCE, + element.x + element.width + getFixedBindingDistance(element), element.y + element.height, ), center, @@ -1107,19 +1086,22 @@ export const avoidRectangularCorner = ( // Top right if ( nonRotatedPoint[0] - element.x < - element.width + FIXED_BINDING_DISTANCE + element.width + getFixedBindingDistance(element) ) { return pointRotateRads( pointFrom( element.x + element.width, - element.y - FIXED_BINDING_DISTANCE, + element.y - getFixedBindingDistance(element), ), center, element.angle, ); } return pointRotateRads( - pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), + pointFrom( + element.x + element.width + getFixedBindingDistance(element), + element.y, + ), center, element.angle, ); @@ -1128,7 +1110,29 @@ export const avoidRectangularCorner = ( return p; }; -export const snapToMid = ( +export const snapToCenter = ( + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + p: GlobalPoint, +): GlobalPoint => { + const percent = 0.8; + + const isPointDeepInside = isPointInElement( + p, + { + ...element, + x: element.x + (element.width * (1 - percent)) / 2, + y: element.y + (element.height * (1 - percent)) / 2, + width: element.width * percent, + height: element.height * percent, + }, + elementsMap, + ); + + return isPointDeepInside ? elementCenterPoint(element, elementsMap) : p; +}; + +const snapToMid = ( element: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, @@ -1143,6 +1147,11 @@ export const snapToMid = ( const verticalThreshold = clamp(tolerance * height, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80); + // Too close to the center makes it hard to resolve direction precisely + if (pointDistance(center, nonRotated) < getFixedBindingDistance(element)) { + return p; + } + if ( nonRotated[0] <= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1150,7 +1159,7 @@ export const snapToMid = ( ) { // LEFT return pointRotateRads( - pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x - getFixedBindingDistance(element), center[1]), center, angle, ); @@ -1161,7 +1170,7 @@ export const snapToMid = ( ) { // TOP return pointRotateRads( - pointFrom(center[0], y - FIXED_BINDING_DISTANCE), + pointFrom(center[0], y - getFixedBindingDistance(element)), center, angle, ); @@ -1172,7 +1181,7 @@ export const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x + width + getFixedBindingDistance(element), center[1]), center, angle, ); @@ -1183,12 +1192,12 @@ export const snapToMid = ( ) { // DOWN return pointRotateRads( - pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE), + pointFrom(center[0], y + height + getFixedBindingDistance(element)), center, angle, ); } else if (element.type === "diamond") { - const distance = FIXED_BINDING_DISTANCE; + const distance = getFixedBindingDistance(element); const topLeft = pointFrom( x + width / 4 - distance, y + height / 4 - distance, @@ -1235,130 +1244,68 @@ 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, + customIntersector?: LineSegment, ): LocalPoint | null => { if ( binding == null || // We only need to update the other end if this is a 2 point line element - (binding.elementId !== bindableElement.id && - linearElement.points.length > 2) + (binding.elementId !== bindableElement.id && arrow.points.length > 2) ) { return null; } - const direction = startOrEnd === "startBinding" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - - if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { - const fixedPoint = - normalizeFixedPoint(binding.fixedPoint) ?? - calculateFixedPointForElbowArrowBinding( - linearElement, - bindableElement, - startOrEnd === "startBinding" ? "start" : "end", - elementsMap, - ).fixedPoint; - const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); - const global = pointFrom( - bindableElement.x + fixedPoint[0] * bindableElement.width, - bindableElement.y + fixedPoint[1] * bindableElement.height, - ); - const rotatedGlobal = pointRotateRads( - global, - globalMidPoint, - bindableElement.angle, - ); - - return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - rotatedGlobal, - elementsMap, - ); - } - - const adjacentPointIndex = edgePointIndex - direction; - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - const focusPointAbsolute = determineFocusPoint( + const global = getGlobalFixedPointForBindableElement( + normalizeFixedPoint(binding.fixedPoint), bindableElement, elementsMap, - binding.focus, - adjacentPoint, ); + const pointIndex = + startOrEnd === "startBinding" ? 0 : arrow.points.length - 1; - 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" && bindableElement + ? bindPointToSnapToElementOutline( + { + ...arrow, + x: pointIndex === 0 ? global[0] : arrow.x, + y: pointIndex === 0 ? global[1] : arrow.y, + points: + pointIndex === 0 + ? [ + pointFrom(0, 0), + ...arrow.points + .slice(1) + .map((p) => + pointFrom( + p[0] - (global[0] - arrow.x), + p[1] - (global[1] - arrow.y), + ), + ), + ] + : [ + ...arrow.points.slice(0, -1), + pointFrom( + global[0] - arrow.x, + global[1] - arrow.y, + ), + ], + }, + bindableElement, + pointIndex === 0 ? "start" : "end", + elementsMap, + customIntersector, + ) + : global; return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - newEdgePoint, + arrow, + maybeOutlineGlobal, elementsMap, ); }; @@ -1401,58 +1348,43 @@ export const calculateFixedPointForElbowArrowBinding = ( }; }; -const maybeCalculateNewGapWhenScaling = ( - changedElement: ExcalidrawBindableElement, - currentBinding: PointBinding | null | undefined, - newSize: { width: number; height: number } | undefined, -): PointBinding | null | undefined => { - if (currentBinding == null || newSize == null) { - return currentBinding; - } - const { width: newWidth, height: newHeight } = newSize; - const { width, height } = changedElement; - const newGap = Math.max( - 1, - Math.min( - maxBindingGap(changedElement, newWidth, newHeight), - currentBinding.gap * - (newWidth < newHeight ? newWidth / width : newHeight / height), - ), - ); - - return { ...currentBinding, gap: newGap }; -}; - -const getEligibleElementForBindingElement = ( - linearElement: NonDeleted, +export const calculateFixedPointForNonElbowArrowBinding = ( + linearElement: NonDeleted, + hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], -): NonDeleted | null => { - return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - elements, - elementsMap, - zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); -}; + elementsMap: ElementsMap, + focusPoint?: GlobalPoint, +): { fixedPoint: FixedPoint } => { + const edgePoint = focusPoint + ? focusPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); -const getLinearElementEdgeCoors = ( - linearElement: NonDeleted, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { x: number; y: number } => { - const index = startOrEnd === "start" ? 0 : -1; - return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - index, - elementsMap, - ), + // Convert the global point to element-local coordinates + const elementCenter = pointFrom( + hoveredElement.x + hoveredElement.width / 2, + hoveredElement.y + hoveredElement.height / 2, ); + + // Rotate the point to account for element rotation + const nonRotatedPoint = pointRotateRads( + edgePoint, + elementCenter, + -hoveredElement.angle as Radians, + ); + + // Calculate the ratio relative to the element's bounds + const fixedPointX = + (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + const fixedPointY = + (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + + return { + fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), + }; }; export const fixDuplicatedBindingsAfterDuplication = ( @@ -1568,324 +1500,6 @@ const newBoundElements = ( return nextBoundElements; }; -export const bindingBorderTest = ( - element: NonDeleted, - { x, y }: { x: number; y: number }, - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, -): boolean => { - const p = pointFrom(x, y); - const threshold = maxBindingGap(element, element.width, element.height, zoom); - const shouldTestInside = - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element); - - // PERF: Run a cheap test to see if the binding element - // is even close to the element - const bounds = [ - x - threshold, - y - threshold, - x + threshold, - y + threshold, - ] as Bounds; - const elementBounds = getElementBounds(element, elementsMap); - if (!doBoundsIntersect(bounds, elementBounds)) { - return false; - } - - // Do the intersection test against the element since it's close enough - const intersections = intersectElementWithLineSegment( - element, - elementsMap, - lineSegment(elementCenterPoint(element, elementsMap), p), - ); - const distance = distanceToElement(element, elementsMap, p); - - return shouldTestInside - ? intersections.length === 0 || distance <= threshold - : intersections.length > 0 && distance <= threshold; -}; - -export const maxBindingGap = ( - element: ExcalidrawElement, - elementWidth: number, - elementHeight: number, - zoom?: AppState["zoom"], -): number => { - const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; - - // Aligns diamonds with rectangles - const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; - const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); - - return Math.max( - 16, - // bigger bindable boundary for bigger elements - Math.min(0.25 * smallerDimension, 32), - // keep in sync with the zoomed highlight - BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE, - ); -}; - -// The focus distance is the oriented ratio between the size of -// the `element` and the "focus image" of the element on which -// all focus points lie, so it's a number between -1 and 1. -// The line going through `a` and `b` is a tangent to the "focus image" -// of the element. -const determineFocusDistance = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // Point on the line, in absolute coordinates - a: GlobalPoint, - // Another point on the line, in absolute coordinates (closer to element) - b: GlobalPoint, -): number => { - const center = elementCenterPoint(element, elementsMap); - - if (pointsEqual(a, b)) { - return 0; - } - - const rotatedA = pointRotateRads(a, center, -element.angle as Radians); - const rotatedB = pointRotateRads(b, center, -element.angle as Radians); - const sign = - Math.sign( - vectorCross( - vectorFromPoint(rotatedB, a), - vectorFromPoint(rotatedB, center), - ), - ) * -1; - const rotatedInterceptor = lineSegment( - rotatedB, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(rotatedB, rotatedA)), - Math.max(element.width * 2, element.height * 2), - ), - rotatedB, - ), - ); - const axes = - element.type === "diamond" - ? [ - lineSegment( - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x, element.y + element.height / 2), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom(element.x, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x + element.width, element.y), - pointFrom(element.x, element.y + element.height), - ), - ]; - const interceptees = - element.type === "diamond" - ? [ - lineSegment( - pointFrom( - element.x + element.width / 2, - element.y - element.height, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x - element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom( - element.x - element.width, - element.y - element.height, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x + element.width * 2, - element.y - element.height, - ), - pointFrom( - element.x - element.width, - element.y + element.height * 2, - ), - ), - ]; - - const ordered = [ - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), - ] - .filter((p): p is GlobalPoint => p !== null) - .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b)) - .map( - (p, idx): number => - (sign * pointDistance(center, p)) / - (element.type === "diamond" - ? pointDistance(axes[idx][0], axes[idx][1]) / 2 - : Math.sqrt(element.width ** 2 + element.height ** 2) / 2), - ) - .sort((g, h) => Math.abs(g) - Math.abs(h)); - - const signedDistanceRatio = ordered[0] ?? 0; - - return signedDistanceRatio; -}; - -const determineFocusPoint = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // The oriented, relative distance from the center of `element` of the - // returned focusPoint - focus: number, - adjacentPoint: GlobalPoint, -): GlobalPoint => { - const center = elementCenterPoint(element, elementsMap); - - if (focus === 0) { - return center; - } - - const candidates = ( - element.type === "diamond" - ? [ - pointFrom(element.x, element.y + element.height / 2), - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ] - : [ - pointFrom(element.x, element.y), - pointFrom(element.x + element.width, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - pointFrom(element.x, element.y + element.height), - ] - ) - .map((p) => - pointFromVector( - vectorScale(vectorFromPoint(p, center), Math.abs(focus)), - center, - ), - ) - .map((p) => pointRotateRads(p, center, element.angle as Radians)); - - const selected = [ - vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) > 0 && // TOP - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) > 0 && // RIGHT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) > 0 && // BOTTOM - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) > 0 && // LEFT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0), - ]; - - const focusPoint = selected[0] - ? focus > 0 - ? candidates[1] - : candidates[0] - : selected[1] - ? focus > 0 - ? candidates[2] - : candidates[1] - : selected[2] - ? focus > 0 - ? candidates[3] - : candidates[2] - : focus > 0 - ? candidates[0] - : candidates[3]; - - return focusPoint; -}; - export const bindingProperties: Set = new Set([ "boundElements", "frameId", @@ -2212,7 +1826,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..57566e3c5 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, @@ -38,6 +38,8 @@ import { } from "./bounds"; import { hasBoundTextElement, + isBindableElement, + isFrameLikeElement, isFreeDrawElement, isIframeLikeElement, isImageElement, @@ -58,12 +60,17 @@ import { distanceToElement } from "./distance"; import type { ElementsMap, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawRectanguloidElement, + NonDeleted, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, } from "./types"; export const shouldTestInside = (element: ExcalidrawElement) => { @@ -94,6 +101,7 @@ export type HitTestArgs = { threshold: number; elementsMap: ElementsMap; frameNameBound?: FrameNameBounds | null; + overrideShouldTestInside?: boolean; }; export const hitElementItself = ({ @@ -102,6 +110,7 @@ export const hitElementItself = ({ threshold, elementsMap, frameNameBound = null, + overrideShouldTestInside = false, }: HitTestArgs) => { // Hit test against a frame's name const hitFrameName = frameNameBound @@ -134,7 +143,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 +204,82 @@ export const hitElementBoundText = ( return isPointInElement(point, boundTextElement, elementsMap); }; +const bindingBorderTest = ( + element: NonDeleted, + [x, y]: Readonly, + elementsMap: NonDeletedSceneElementsMap, + tolerance: number = 0, +): boolean => { + const p = pointFrom(x, y); + 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 t = Math.max(1, tolerance); + const bounds = [x - t, y - t, x + t, y + t] 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 <= tolerance + : intersections.length > 0 && distance <= t; +}; + +export const getHoveredElementForBinding = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + toleranceFn?: (element: ExcalidrawBindableElement) => number, +): 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, toleranceFn?.(element)) + ) { + 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; +}; + /** * 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..9e82953cc 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,33 @@ export const dragSelectedElements = ( updateBoundElements(element, scene, { simultaneouslyUpdated: Array.from(elementsToUpdate), }); + } else if ( + // NOTE: Add a little initial drag to the arrow dragging when the arrow + // is the single element being dragged to avoid accidentally unbinding + // the arrow when the user just wants to select it. + + elementsToUpdate.size > 1 || + Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > + DRAGGING_THRESHOLD || + (!element.startBinding && !element.endBinding) + ) { + updateElementCoords(pointerDownState, element, scene, adjustedOffset); + + const shouldUnbindStart = + element.startBinding && !isStartBoundElementSelected; + const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected; + if (shouldUnbindStart || shouldUnbindEnd) { + // NOTE: Moving the bound arrow should unbind it, otherwise we would + // have weird situations, like 0 lenght arrow when the user moves + // the arrow outside a filled shape suddenly forcing the arrow start + // and end point to jump "outside" the shape. + if (shouldUnbindStart) { + unbindBindingElement(element, "start", scene); + } + if (shouldUnbindEnd) { + unbindBindingElement(element, "end", scene); + } + } } }); }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index b988eb25b..d62f328a7 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,7 @@ import { FIXED_BINDING_DISTANCE, getHeadingForElbowArrowSnap, getGlobalFixedPointForBindableElement, - getHoveredElementForBinding, + getFixedBindingDistance, } from "./binding"; import { distanceToElement } from "./distance"; import { @@ -51,8 +50,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 +62,7 @@ import type { FixedPointBinding, FixedSegment, NonDeletedExcalidrawElement, + Ordered, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -1217,19 +1217,9 @@ const getElbowArrowData = ( if (options?.isDragging) { const elements = Array.from(elementsMap.values()); hoveredStartElement = - getHoveredElement( - origStartGlobalPoint, - elementsMap, - elements, - options?.zoom, - ) || null; + getHoveredElement(origStartGlobalPoint, elementsMap, elements) || null; hoveredEndElement = - getHoveredElement( - origEndGlobalPoint, - elementsMap, - elements, - options?.zoom, - ) || null; + getHoveredElement(origEndGlobalPoint, elementsMap, elements) || null; } else { hoveredStartElement = arrow.startBinding ? getBindableElementForId(arrow.startBinding.elementId, elementsMap) || @@ -1301,8 +1291,8 @@ const getElbowArrowData = ( offsetFromHeading( startHeading, arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, + ? getFixedBindingDistance(hoveredStartElement) * 6 + : getFixedBindingDistance(hoveredStartElement) * 2, 1, ), ) @@ -1314,8 +1304,8 @@ const getElbowArrowData = ( offsetFromHeading( endHeading, arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, + ? getFixedBindingDistance(hoveredEndElement) * 6 + : getFixedBindingDistance(hoveredEndElement) * 2, 1, ), ) @@ -2262,16 +2252,13 @@ const getBindPointHeading = ( const getHoveredElement = ( origPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], + elements: readonly Ordered[], ) => { return getHoveredElementForBinding( - tupleToCoors(origPoint), + origPoint, elements, elementsMap, - zoom, - true, - true, + (element) => getFixedBindingDistance(element) + 1, ); }; 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..431b5fe70 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"; @@ -19,13 +20,15 @@ import { shouldRotateWithDiscreteAngle, getGridPoint, invariant, - tupleToCoors, - viewportCoordsToSceneCoords, + isShallowEqual, } from "@excalidraw/common"; import { deconstructLinearOrFreeDrawElement, + distanceToElement, isPathALoop, + isPointInElement, + moveArrowAboveBindable, type Store, } from "@excalidraw/element"; @@ -40,13 +43,13 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; -import type { Mutable } from "@excalidraw/common/utility-types"; - import { - bindOrUnbindLinearElement, - getHoveredElementForBinding, + calculateFixedPointForNonElbowArrowBinding, + getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, - maybeSuggestBindingsForLinearElementAtCoords, + maybeSuggestBindingsForBindingElementAtCoords, + unbindBindingElement, + updateBoundPoint, } from "./binding"; import { getElementAbsoluteCoords, @@ -58,9 +61,10 @@ import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { + isArrowElement, isBindingElement, isElbowArrow, - isFixedPointBinding, + isSimpleArrow, } from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -76,8 +80,6 @@ import type { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, - PointBinding, - ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, @@ -85,6 +87,9 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, PointsPositionUpdates, + NonDeletedExcalidrawElement, + Ordered, + ExcalidrawBindableElement, } from "./types"; /** @@ -116,6 +121,12 @@ const getNormalizedPoints = ({ }; }; +type PointMoveOtherUpdates = { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + moveMidPointsWithElement?: boolean | null; +}; + export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -127,24 +138,19 @@ export class LinearElementEditor { prevSelectedPointsIndices: readonly number[] | null; /** index */ lastClickedPoint: number; - lastClickedIsEndPoint: boolean; - origin: Readonly<{ x: number; y: number }> | null; + origin: Readonly | null; segmentMidpoint: { value: GlobalPoint | null; index: number | null; added: boolean; }; + 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,12 +177,9 @@ 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, - lastClickedIsEndPoint: false, origin: null, segmentMidpoint: { @@ -184,6 +187,7 @@ export class LinearElementEditor { index: null, added: false, }, + arrowStartIsInside: false, }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; @@ -276,222 +280,307 @@ export class LinearElementEditor { }); } - /** - * @returns whether point was dragged - */ + static handlePointerMove( + event: PointerEvent, + app: AppClassProperties, + scenePointerX: number, + scenePointerY: number, + linearElementEditor: LinearElementEditor, + ): Pick | null { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const elements = app.scene.getNonDeletedElements(); + const { elementId } = linearElementEditor; + + const element = LinearElementEditor.getElement(elementId, elementsMap); + + invariant(element, "Element being dragged must exist in the scene"); + invariant(element.points.length > 1, "Element must have at least 2 points"); + + const idx = element.points.length - 1; + const point = element.points[idx]; + const pivotPoint = element.points[idx - 1]; + const customLineAngle = + linearElementEditor.customLineAngle ?? + determineCustomLinearAngle(pivotPoint, element.points[idx]); + + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if (shouldRotateWithDiscreteAngle(event)) { + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + pivotPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + customLineAngle, + ); + const target = pointFrom( + width + pivotPoint[0], + height + pivotPoint[1], + ); + + deltaX = target[0] - point[0]; + deltaY = target[1] - point[1]; + } else { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + deltaX = newDraggingPointPosition[0] - point[0]; + deltaY = newDraggingPointPosition[1] - point[1]; + } + + // Apply the point movement if needed + if (deltaX || deltaY) { + const { positions, updates } = pointDraggingUpdates( + [idx], + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, updates); + + // Move the arrow over the bindable object in terms of z-index + if (isBindingElement(element)) { + moveArrowAboveBindable( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[element.points.length - 1], + elementsMap, + ), + element, + elements, + elementsMap, + app.scene, + ); + } + } + + // Suggest bindings for first and last point if selected + let suggestedBinding: AppState["suggestedBinding"] = null; + if (isBindingElement(element, false)) { + suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords( + element, + "end", + app.scene, + pointFrom(scenePointerX, scenePointerY), + ); + } + + const newLinearElementEditor = { + ...linearElementEditor, + customLineAngle, + }; + + if ( + app.state.selectedLinearElement?.customLineAngle === customLineAngle && + (!suggestedBinding || + isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding)) + ) { + return null; + } + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; + } + static handlePointDragging( event: PointerEvent, app: AppClassProperties, scenePointerX: number, scenePointerY: number, linearElementEditor: LinearElementEditor, - ): Pick | null { - if (!linearElementEditor) { - return null; - } - const { elementId } = linearElementEditor; + ): Pick | null { const elementsMap = app.scene.getNonDeletedElementsMap(); + const elements = app.scene.getNonDeletedElements(); + const { elbowed, elementId, pointerDownState, selectedPointsIndices } = + linearElementEditor; + const { lastClickedPoint } = pointerDownState; const element = LinearElementEditor.getElement(elementId, elementsMap); - let customLineAngle = linearElementEditor.customLineAngle; - if (!element) { - return null; - } - if ( - isElbowArrow(element) && - !linearElementEditor.pointerDownState.lastClickedIsEndPoint && - linearElementEditor.pointerDownState.lastClickedPoint !== 0 - ) { - return null; - } + invariant(element, "Element being dragged must exist in the scene"); - const selectedPointsIndices = isElbowArrow(element) - ? [ - !!linearElementEditor.selectedPointsIndices?.includes(0) - ? 0 - : undefined, - !!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0) - ? element.points.length - 1 - : undefined, - ].filter((idx): idx is number => idx !== undefined) - : linearElementEditor.selectedPointsIndices; - const lastClickedPoint = isElbowArrow(element) - ? linearElementEditor.pointerDownState.lastClickedPoint > 0 - ? element.points.length - 1 - : 0 - : linearElementEditor.pointerDownState.lastClickedPoint; + invariant( + selectedPointsIndices, + "There must be selected points in order to drag them", + ); + + invariant( + lastClickedPoint > -1 && selectedPointsIndices.includes(lastClickedPoint), + "There must be a valid lastClickedPoint in order to drag it", + ); + + invariant(element.points.length > 1, "Element must have at least 2 points"); + + invariant( + !elbowed || + selectedPointsIndices?.filter( + (idx) => idx !== 0 && idx !== element.points.length - 1, + ).length === 0, + `Only start and end points can be selected for elbow arrows: ${JSON.stringify( + selectedPointsIndices, + )} end point ${element.points.length - 1}`, + ); // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; + // The adjacent point to the one dragged point + const pivotPoint = + element.points[lastClickedPoint === 0 ? 1 : lastClickedPoint - 1]; + const singlePointDragged = selectedPointsIndices.length === 1; + const customLineAngle = + linearElementEditor.customLineAngle ?? + determineCustomLinearAngle(pivotPoint, element.points[lastClickedPoint]); + const startIsSelected = selectedPointsIndices.includes(0); + const endIsSelected = selectedPointsIndices.includes( + element.points.length - 1, + ); - if (selectedPointsIndices && draggingPoint) { - if ( - shouldRotateWithDiscreteAngle(event) && - selectedPointsIndices.length === 1 && - element.points.length > 1 - ) { - const selectedIndex = selectedPointsIndices[0]; - const referencePoint = - element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; - customLineAngle = - linearElementEditor.customLineAngle ?? - Math.atan2( - element.points[selectedIndex][1] - referencePoint[1], - element.points[selectedIndex][0] - referencePoint[0], - ); - - const [width, height] = LinearElementEditor._getShiftLockedDelta( - element, - elementsMap, - referencePoint, - pointFrom(scenePointerX, scenePointerY), - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - customLineAngle, - ); - - LinearElementEditor.movePoints( - element, - app.scene, - new Map([ - [ - selectedIndex, - { - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - isDragging: selectedIndex === lastClickedPoint, - }, - ], - ]), - ); - } else { - const newDraggingPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - 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, - }, - ]; - }), - ), - ); - } - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement) { - handleBindTextResize(element, app.scene, false); - } - - // suggest bindings for first and last point if selected - let suggestedBindings: ExcalidrawBindableElement[] = []; - if (isBindingElement(element, false)) { - const firstSelectedIndex = selectedPointsIndices[0] === 0; - const lastSelectedIndex = - 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) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[0], - elementsMap, - ), - ), - ); - } - - if (lastSelectedIndex) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[ - selectedPointsIndices[selectedPointsIndices.length - 1] - ], - elementsMap, - ), - ), - ); - } - } - - if (coords.length) { - suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords( - element, - coords, - app.scene, - app.state.zoom, - ); - } - } - - const newLinearElementEditor = { - ...linearElementEditor, - selectedPointsIndices, - segmentMidPointHoveredCoords: - lastClickedPoint !== 0 && - lastClickedPoint !== element.points.length - 1 - ? this.getPointGlobalCoordinates( - element, - draggingPoint, - elementsMap, - ) - : null, - hoverPointIndex: - lastClickedPoint === 0 || - lastClickedPoint === element.points.length - 1 - ? lastClickedPoint - : -1, - isDragging: true, + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) { + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + pivotPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), customLineAngle, - }; + ); + const target = pointFrom( + width + pivotPoint[0], + height + pivotPoint[1], + ); - return { - ...app.state, - selectedLinearElement: newLinearElementEditor, - suggestedBindings, - }; + deltaX = target[0] - draggingPoint[0]; + deltaY = target[1] - draggingPoint[1]; + } else if ( + shouldAllowDraggingPoint( + element, + scenePointerX, + scenePointerY, + selectedPointsIndices, + elementsMap, + app, + ) + ) { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + deltaY = newDraggingPointPosition[1] - draggingPoint[1]; } - return null; + // Apply the point movement if needed + if (deltaX || deltaY) { + const { positions, updates } = pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, updates); + + // Move the arrow over the bindable object in terms of z-index + if (isBindingElement(element) && startIsSelected !== endIsSelected) { + moveArrowAboveBindable( + LinearElementEditor.getPointGlobalCoordinates( + element, + startIsSelected + ? element.points[0] + : element.points[element.points.length - 1], + elementsMap, + ), + element, + elements, + elementsMap, + app.scene, + ); + } + } + + // Attached text might need to update if arrow dimensions change + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + handleBindTextResize(element, app.scene, false); + } + + // Suggest bindings for first and last point if selected + let suggestedBinding: AppState["suggestedBinding"] = null; + if (isBindingElement(element, false)) { + if (isBindingEnabled(app.state) && (startIsSelected || endIsSelected)) { + suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords( + element, + startIsSelected && endIsSelected + ? "both" + : startIsSelected + ? "start" + : "end", + app.scene, + pointFrom(scenePointerX, scenePointerY), + ); + } + } + + // Update selected points for elbow arrows because elbow arrows add and + // remove points as they route + const newSelectedPointsIndices = elbowed + ? endIsSelected + ? [element.points.length - 1] + : [0] + : selectedPointsIndices; + + const newLastClickedPoint = elbowed + ? newSelectedPointsIndices[0] + : lastClickedPoint; + + const newSelectedMidPointHoveredCoords = + !startIsSelected && !endIsSelected + ? LinearElementEditor.getPointGlobalCoordinates( + element, + draggingPoint, + elementsMap, + ) + : null; + + const newHoverPointIndex = newLastClickedPoint; + + const newLinearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: newSelectedPointsIndices, + pointerDownState: { + ...linearElementEditor.pointerDownState, + lastClickedPoint: newLastClickedPoint, + }, + segmentMidPointHoveredCoords: newSelectedMidPointHoveredCoords, + hoverPointIndex: newHoverPointIndex, + isDragging: true, + customLineAngle, + }; + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; } static handlePointerUp( @@ -501,8 +590,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 +598,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 +633,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 +663,11 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + pointerDownState: { + ...editingLinearElement.pointerDownState, + origin: null, + arrowStartIsInside: false, + }, }; } @@ -853,7 +912,6 @@ export class LinearElementEditor { } { const appState = app.state; const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -871,6 +929,7 @@ export class LinearElementEditor { if (!element) { return ret; } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, @@ -878,6 +937,7 @@ export class LinearElementEditor { elementsMap, ); let segmentMidpointIndex = null; + if (segmentMidpoint) { segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, @@ -907,26 +967,22 @@ export class LinearElementEditor { pointerDownState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: -1, - lastClickedIsEndPoint: false, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: pointFrom(scenePointer.x, scenePointer.y), segmentMidpoint: { value: segmentMidpoint, 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 +997,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); @@ -987,13 +1028,15 @@ export class LinearElementEditor { pointerDownState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: clickedPointIndex, - lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: pointFrom(scenePointer.x, scenePointer.y), segmentMidpoint: { value: segmentMidpoint, index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), }, selectedPointsIndices: nextSelectedPointsIndices, pointerOffset: targetPoint @@ -1020,7 +1063,7 @@ export class LinearElementEditor { return pointsEqual(point1, point2); } - static handlePointerMove( + static handlePointerMoveInEditMode( event: React.PointerEvent, scenePointerX: number, scenePointerY: number, @@ -1056,7 +1099,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 +1183,6 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, - indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, ): GlobalPoint { @@ -1409,8 +1450,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 +1498,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, @@ -1508,7 +1559,7 @@ export class LinearElementEditor { const origin = linearElementEditor.pointerDownState.origin!; const dist = pointDistance( - pointFrom(origin.x, origin.y), + origin, pointFrom(pointerCoords.x, pointerCoords.y), ); if ( @@ -1578,8 +1629,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 +1645,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); @@ -1886,7 +1929,10 @@ export class LinearElementEditor { x: number, y: number, scene: Scene, - ): LinearElementEditor { + ): Pick< + LinearElementEditor, + "segmentMidPointHoveredCoords" | "pointerDownState" + > { const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement( linearElement.elementId, @@ -1984,3 +2030,261 @@ 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, + element: NonDeleted, + elements: readonly Ordered[], + app: AppClassProperties, +): { + positions: PointsPositionUpdates; + updates?: PointMoveOtherUpdates; +} => { + const naiveDraggingPoints = new Map( + selectedPointsIndices.map((pointIndex) => { + return [ + pointIndex, + { + point: pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ), + isDragging: true, + }, + ]; + }), + ); + + // Linear elements have no special logic + if (!isArrowElement(element) || isElbowArrow(element)) { + return { + positions: naiveDraggingPoints, + }; + } + + const startIsDragged = selectedPointsIndices.includes(0); + const endIsDragged = selectedPointsIndices.includes( + element.points.length - 1, + ); + + if (startIsDragged === endIsDragged) { + return { + positions: naiveDraggingPoints, + }; + } + + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + element, + naiveDraggingPoints, + elementsMap, + elements, + app.state, + { + newArrow: !!app.state.newElement, + }, + ); + + // Generate the next bindings for the arrow + const updates: PointMoveOtherUpdates = {}; + if (start.mode === null) { + updates.startBinding = null; + } else if (start.mode) { + updates.startBinding = { + elementId: start.element.id, + mode: start.mode, + ...calculateFixedPointForNonElbowArrowBinding( + element, + start.element, + "start", + elementsMap, + start.focusPoint, + ), + }; + } + if (end.mode === null) { + updates.endBinding = null; + } else if (end.mode) { + updates.endBinding = { + elementId: end.element.id, + mode: end.mode, + ...calculateFixedPointForNonElbowArrowBinding( + element, + end.element, + "end", + elementsMap, + end.focusPoint, + ), + }; + } + + // Simulate the updated arrow for the bind point calculation + const originalStartGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + 0, + elementsMap, + ); + const offsetStartGlobalPoint = startIsDragged + ? pointFrom( + originalStartGlobalPoint[0] + deltaX, + originalStartGlobalPoint[1] + deltaY, + ) + : originalStartGlobalPoint; + const offsetStartLocalPoint = LinearElementEditor.pointFromAbsoluteCoords( + element, + offsetStartGlobalPoint, + elementsMap, + ); + const offsetEndLocalPoint = endIsDragged + ? pointFrom( + element.points[element.points.length - 1][0] + deltaX, + element.points[element.points.length - 1][1] + deltaY, + ) + : element.points[element.points.length - 1]; + + const nextArrow = { + ...element, + points: [ + offsetStartLocalPoint, + ...element.points + .slice(1, -1) + .map((p) => + pointFrom( + p[0] + element.x - offsetStartGlobalPoint[0], + p[1] + element.y - offsetStartGlobalPoint[1], + ), + ), + offsetEndLocalPoint, + ], + startBinding: updates.startBinding ?? element.startBinding, + endBinding: updates.endBinding ?? element.endBinding, + }; + + // We need to use a custom intersector to ensure that if there is a big "jump" + // in the arrow's position, we can position it with outline avoidance + // pixel-perfectly and avoid "dancing" arrows. + const customIntersector = + start.focusPoint && end.focusPoint + ? lineSegment(start.focusPoint, end.focusPoint) + : undefined; + + // We need to update the non-dragged point too if bound, + // so we look up the old binding to trigger updateBoundPoint + const endBindable = nextArrow.endBinding + ? end.element ?? + (elementsMap.get( + nextArrow.endBinding.elementId, + )! as ExcalidrawBindableElement) + : null; + const endLocalPoint = endBindable + ? updateBoundPoint( + nextArrow, + "endBinding", + nextArrow.endBinding, + endBindable, + elementsMap, + customIntersector, + ) || nextArrow.points[nextArrow.points.length - 1] + : nextArrow.points[nextArrow.points.length - 1]; + + // We need to keep the simulated next arrow up-to-date, because + // updateBoundPoint looks at the opposite point + nextArrow.points[nextArrow.points.length - 1] = endLocalPoint; + + // We need to update the non-dragged point too if bound, + // so we look up the old binding to trigger updateBoundPoint + const startBindable = nextArrow.startBinding + ? start.element ?? + (elementsMap.get( + nextArrow.startBinding.elementId, + )! as ExcalidrawBindableElement) + : null; + + const startLocalPoint = startBindable + ? updateBoundPoint( + nextArrow, + "startBinding", + nextArrow.startBinding, + startBindable, + elementsMap, + customIntersector, + ) || nextArrow.points[0] + : nextArrow.points[0]; + + const indicesSet = new Set(selectedPointsIndices); + if (startBindable) { + indicesSet.add(0); + } + if (endBindable) { + indicesSet.add(element.points.length - 1); + } + const indices = Array.from(indicesSet); + + return { + updates: start.mode || end.mode ? updates : undefined, + positions: new Map( + indices.map((idx) => { + return [ + idx, + idx === 0 + ? { point: startLocalPoint, isDragging: true } + : idx === element.points.length - 1 + ? { point: endLocalPoint, isDragging: true } + : naiveDraggingPoints.get(idx)!, + ]; + }), + ), + }; +}; + +const shouldAllowDraggingPoint = ( + element: ExcalidrawLinearElement, + scenePointerX: number, + scenePointerY: number, + selectedPointsIndices: readonly number[], + elementsMap: Readonly, + app: AppClassProperties, +) => { + if (!isSimpleArrow(element)) { + return true; + } + + const scenePointer = pointFrom(scenePointerX, scenePointerY); + + // Do not allow dragging the bound arrow closer to the shape than + // the dragging threshold + let allowDrag = true; + + if (selectedPointsIndices.includes(0) && element.startBinding) { + const boundElement = elementsMap.get(element.startBinding.elementId)!; + const dist = distanceToElement(boundElement, elementsMap, scenePointer); + const inside = isPointInElement(scenePointer, boundElement, elementsMap); + allowDrag = allowDrag && (dist > DRAGGING_THRESHOLD || inside); + if (allowDrag) { + unbindBindingElement(element, "start", app.scene); + } + } + if ( + selectedPointsIndices.includes(element.points.length - 1) && + element.endBinding + ) { + const boundElement = elementsMap.get(element.endBinding.elementId)!; + const dist = distanceToElement(boundElement, elementsMap, scenePointer); + const inside = isPointInElement(scenePointer, boundElement, elementsMap); + allowDrag = allowDrag && (dist > DRAGGING_THRESHOLD || inside); + if (allowDrag) { + unbindBindingElement(element, "end", app.scene); + } + } + + return allowDrag; +}; + +const determineCustomLinearAngle = ( + pivotPoint: LocalPoint, + draggedPoint: LocalPoint, +) => + Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]); 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/renderElement.ts b/packages/element/src/renderElement.ts index 008d6afc4..843bd110b 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -269,7 +269,7 @@ const generateElementCanvas = ( context.filter = IMAGE_INVERT_FILTER; } - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); @@ -404,7 +404,6 @@ const drawElementOnCanvas = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, ) => { switch (element.type) { case "rectangle": @@ -603,6 +602,41 @@ const generateElementWithCanvas = ( return prevElementWithCanvas; }; +const drawElementHighlight = ( + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, +) => { + if (appState.suggestedBinding) { + const cx = + (appState.suggestedBinding.x + + appState.suggestedBinding.width / 2 + + appState.scrollX) * + window.devicePixelRatio; + const cy = + (appState.suggestedBinding.y + + appState.suggestedBinding.height / 2 + + appState.scrollY) * + window.devicePixelRatio; + context.save(); + + context.translate(cx, cy); + context.rotate(appState.suggestedBinding.angle); + context.translate(-cx, -cy); + context.translate( + appState.scrollX + appState.suggestedBinding.x, + appState.scrollY + appState.suggestedBinding.y, + ); + + const drawable = ShapeCache.generateBindableElementHighlight( + appState.suggestedBinding, + appState, + ); + rough.canvas(context.canvas).draw(drawable); + + context.restore(); + } +}; + const drawElementFromCanvas = ( elementWithCanvas: ExcalidrawElementWithCanvas, context: CanvasRenderingContext2D, @@ -610,88 +644,99 @@ const drawElementFromCanvas = ( appState: StaticCanvasAppState, allElementsMap: NonDeletedSceneElementsMap, ) => { - const element = elementWithCanvas.element; - const padding = getCanvasPadding(element); - const zoom = elementWithCanvas.scale; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); - const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; - const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; + const isHighlighted = + appState.suggestedBinding?.id === elementWithCanvas.element.id; + if ( + !isHighlighted || + ["image", "text"].includes(elementWithCanvas.element.type) + ) { + const element = elementWithCanvas.element; + const padding = getCanvasPadding(element); + const zoom = elementWithCanvas.scale; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); + const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; + const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; - context.save(); - context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); + context.save(); + context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); - const boundTextElement = getBoundTextElement(element, allElementsMap); + const boundTextElement = getBoundTextElement(element, allElementsMap); - if (isArrowElement(element) && boundTextElement) { - const offsetX = - (elementWithCanvas.boundTextCanvas.width - - elementWithCanvas.canvas!.width) / - 2; - const offsetY = - (elementWithCanvas.boundTextCanvas.height - - elementWithCanvas.canvas!.height) / - 2; - context.translate(cx, cy); - context.drawImage( - elementWithCanvas.boundTextCanvas, - (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, - (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, - elementWithCanvas.boundTextCanvas.width / zoom, - elementWithCanvas.boundTextCanvas.height / zoom, - ); - } else { - // we translate context to element center so that rotation and scale - // originates from the element center - context.translate(cx, cy); - - context.rotate(element.angle); - - if ( - "scale" in elementWithCanvas.element && - !isPendingImageElement(element, renderConfig) - ) { - context.scale( - elementWithCanvas.element.scale[0], - elementWithCanvas.element.scale[1], + if (isArrowElement(element) && boundTextElement) { + const offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; + context.translate(cx, cy); + context.drawImage( + elementWithCanvas.boundTextCanvas, + (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, + (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.height / zoom, ); - } + } else { + // we translate context to element center so that rotation and scale + // originates from the element center + context.translate(cx, cy); - // revert afterwards we don't have account for it during drawing - context.translate(-cx, -cy); + context.rotate(element.angle); - context.drawImage( - elementWithCanvas.canvas!, - (x1 + appState.scrollX) * window.devicePixelRatio - - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, - (y1 + appState.scrollY) * window.devicePixelRatio - - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, - elementWithCanvas.canvas!.width / elementWithCanvas.scale, - elementWithCanvas.canvas!.height / elementWithCanvas.scale, - ); + if ( + "scale" in elementWithCanvas.element && + !isPendingImageElement(element, renderConfig) + ) { + context.scale( + elementWithCanvas.element.scale[0], + elementWithCanvas.element.scale[1], + ); + } - if ( - import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX === - "true" && - hasBoundTextElement(element) - ) { - const textElement = getBoundTextElement( - element, - allElementsMap, - ) as ExcalidrawTextElementWithContainer; - const coords = getContainerCoords(element); - context.strokeStyle = "#c92a2a"; - context.lineWidth = 3; - context.strokeRect( - (coords.x + appState.scrollX) * window.devicePixelRatio, - (coords.y + appState.scrollY) * window.devicePixelRatio, - getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, - getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, + // revert afterwards we don't have account for it during drawing + context.translate(-cx, -cy); + + context.drawImage( + elementWithCanvas.canvas!, + (x1 + appState.scrollX) * window.devicePixelRatio - + (padding * elementWithCanvas.scale) / elementWithCanvas.scale, + (y1 + appState.scrollY) * window.devicePixelRatio - + (padding * elementWithCanvas.scale) / elementWithCanvas.scale, + elementWithCanvas.canvas!.width / elementWithCanvas.scale, + elementWithCanvas.canvas!.height / elementWithCanvas.scale, ); + + if ( + import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX === + "true" && + hasBoundTextElement(element) + ) { + const textElement = getBoundTextElement( + element, + allElementsMap, + ) as ExcalidrawTextElementWithContainer; + const coords = getContainerCoords(element); + context.strokeStyle = "#c92a2a"; + context.lineWidth = 3; + context.strokeRect( + (coords.x + appState.scrollX) * window.devicePixelRatio, + (coords.y + appState.scrollY) * window.devicePixelRatio, + getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, + getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, + ); + } } + context.restore(); + + // Clear the nested element we appended to the DOM } - context.restore(); - // Clear the nested element we appended to the DOM + if (isHighlighted) { + drawElementHighlight(context, appState); + } }; export const renderSelectionElement = ( @@ -744,6 +789,11 @@ export const renderElement = ( case "magicframe": case "frame": { if (appState.frameRendering.enabled && appState.frameRendering.outline) { + const isHighlighted = element.id === appState.suggestedBinding?.id; + const { + options: { stroke: highlightStroke }, + } = ShapeCache.generateBindableElementHighlight(element, appState); + context.save(); context.translate( element.x + appState.scrollX, @@ -752,12 +802,17 @@ export const renderElement = ( context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; - context.strokeStyle = FRAME_STYLE.strokeColor; + context.strokeStyle = isHighlighted + ? highlightStroke + : FRAME_STYLE.strokeColor; // TODO change later to only affect AI frames if (isMagicFrameElement(element)) { - context.strokeStyle = - appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264"; + context.strokeStyle = isHighlighted + ? highlightStroke + : appState.theme === THEME.LIGHT + ? "#7affd7" + : "#1d8264"; } if (FRAME_STYLE.radius && context.roundRect) { @@ -795,7 +850,7 @@ export const renderElement = ( context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); } else { const elementWithCanvas = generateElementWithCanvas( @@ -888,13 +943,7 @@ export const renderElement = ( tempCanvasContext.translate(-shiftX, -shiftY); - drawElementOnCanvas( - element, - tempRc, - tempCanvasContext, - renderConfig, - appState, - ); + drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); tempCanvasContext.translate(shiftX, shiftY); @@ -933,7 +982,7 @@ export const renderElement = ( } context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); } context.restore(); diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index 8cfd80785..bb9094a5d 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -20,7 +20,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types"; import type { Mutable } from "@excalidraw/common/utility-types"; -import { getArrowLocalFixedPoints, updateBoundElements } from "./binding"; +import { + getArrowLocalFixedPoints, + unbindBindingElement, + updateBoundElements, +} from "./binding"; import { getElementAbsoluteCoords, getCommonBounds, @@ -46,6 +50,7 @@ import { import { wrapText } from "./textWrapping"; import { isArrowElement, + isBindingElement, isBoundToContainer, isElbowArrow, isFrameLikeElement, @@ -74,7 +79,9 @@ import type { ExcalidrawImageElement, ElementsMap, ExcalidrawElbowArrowElement, + ExcalidrawArrowElement, } from "./types"; +import type { ElementUpdate } from "./mutateElement"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -220,7 +227,25 @@ const rotateSingleElement = ( } const boundTextElementId = getBoundTextElementId(element); - scene.mutateElement(element, { angle }); + let update: ElementUpdate = { + angle, + }; + + if (isBindingElement(element)) { + update = { + ...update, + } as ElementUpdate; + + if (element.startBinding) { + unbindBindingElement(element, "start", scene); + } + if (element.endBinding) { + unbindBindingElement(element, "end", scene); + } + } + + scene.mutateElement(element, update); + if (boundTextElementId) { const textElement = scene.getElement(boundTextElementId); @@ -394,6 +419,11 @@ const rotateMultipleElements = ( centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; } + const rotatedElementsMap = new Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement + >(elements.map((element) => [element.id, element])); + for (const element of elements) { if (!isFrameLikeElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -424,6 +454,19 @@ const rotateMultipleElements = ( simultaneouslyUpdated: elements, }); + if (isBindingElement(element)) { + if (element.startBinding) { + if (!rotatedElementsMap.has(element.startBinding.elementId)) { + unbindBindingElement(element, "start", scene); + } + } + if (element.endBinding) { + if (!rotatedElementsMap.has(element.endBinding.elementId)) { + unbindBindingElement(element, "end", scene); + } + } + } + const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { const { x, y } = computeBoundTextPosition( @@ -835,13 +878,32 @@ export const resizeSingleElement = ( Number.isFinite(newOrigin.x) && Number.isFinite(newOrigin.y) ) { - const updates = { + let updates: ElementUpdate = { ...newOrigin, width: Math.abs(nextWidth), height: Math.abs(nextHeight), ...rescaledPoints, }; + if (isBindingElement(latestElement)) { + if (latestElement.startBinding) { + updates = { + ...updates, + } as ElementUpdate; + + if (latestElement.startBinding) { + unbindBindingElement(latestElement, "start", scene); + } + } + + if (latestElement.endBinding) { + updates = { + ...updates, + endBinding: null, + } as ElementUpdate; + } + } + scene.mutateElement(latestElement, updates, { informMutation: shouldInformMutation, isDragging: false, @@ -859,10 +921,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); } }; @@ -1396,20 +1455,36 @@ export const resizeMultipleElements = ( } const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); + const resizedElementsMap = new Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement + >(elementsAndUpdates.map(({ element }) => [element.id, element])); for (const { 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 }, }); + if (isBindingElement(element)) { + if (element.startBinding) { + if (!resizedElementsMap.has(element.startBinding.elementId)) { + unbindBindingElement(element, "start", scene); + } + } + if (element.endBinding) { + if (!resizedElementsMap.has(element.endBinding.elementId)) { + unbindBindingElement(element, "end", scene); + } + } + } + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && boundTextFontSize) { scene.mutateElement(boundTextElement, { diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 7a8cd351a..457ecf046 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -21,6 +21,7 @@ import { assertNever, COLOR_PALETTE, LINE_POLYGON_POINT_MERGE_DISTANCE, + THEME, } from "@excalidraw/common"; import { RoughGenerator } from "roughjs/bin/generator"; @@ -32,6 +33,7 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import type { AppState, EmbedsValidationStatus, + InteractiveCanvasAppState, } from "@excalidraw/excalidraw/types"; import type { ElementShape, @@ -70,6 +72,7 @@ import type { ExcalidrawFreeDrawElement, ElementsMap, ExcalidrawLineElement, + ExcalidrawBindableElement, } from "./types"; import type { Drawable, Options } from "roughjs/bin/core"; @@ -105,6 +108,31 @@ export class ShapeCache { ShapeCache.cache = new WeakMap(); }; + public static generateBindableElementHighlight = < + T extends ExcalidrawBindableElement, + >( + element: T, + appState: Pick, + ) => { + let shape = + (ShapeCache.get(element) as Drawable | null) || + (ShapeCache.rg.rectangle(0, 0, element.width, element.height, { + roughness: 0, + strokeWidth: 2, + }) as Drawable); + + // Clone the shape from the cache + shape = { + ...shape, + options: { + ...shape.options, + stroke: appState.theme === THEME.DARK ? "#035da1" : "#6abdfc", + }, + }; + + return shape; + }; + /** * Generates & caches shape for element if not already cached, otherwise * returns cached shape. 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..4e05df0ad 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" | "skip"; -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..0bb0cda9c 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -1,18 +1,25 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import type { AppState } from "@excalidraw/excalidraw/types"; +import type { GlobalPoint } from "@excalidraw/math"; -import { isFrameLikeElement } from "./typeChecks"; - +import { isFrameLikeElement, isTextElement } from "./typeChecks"; import { getElementsInGroup } from "./groups"; - import { syncMovedIndices } from "./fractionalIndex"; - import { getSelectedElements } from "./selection"; +import { getBoundTextElement, getContainerElement } from "./textElement"; +import { getHoveredElementForBinding } from "./collision"; import type { Scene } from "./Scene"; - -import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; +import type { + ExcalidrawArrowElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, + OrderedExcalidrawElement, +} from "./types"; const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { return element.frameId === frameId || element.id === frameId; @@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = ( return allElements.slice(rangeStart, rangeEnd + 1); }; +/** + * Moves the arrow element above any bindable elements it intersects with or + * hovers over. + */ +export const moveArrowAboveBindable = ( + point: GlobalPoint, + arrow: ExcalidrawArrowElement, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, +): readonly OrderedExcalidrawElement[] => { + const hoveredElement = getHoveredElementForBinding( + point, + elements, + elementsMap, + ); + + if (!hoveredElement) { + return elements; + } + + const boundTextElement = getBoundTextElement(hoveredElement, elementsMap); + const containerElement = isTextElement(hoveredElement) + ? getContainerElement(hoveredElement, elementsMap) + : null; + + const bindableIds = [ + hoveredElement.id, + boundTextElement?.id, + containerElement?.id, + ].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id); + 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); + + scene.replaceAllElements(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..35e940d32 100644 --- a/packages/element/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/element/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -44,14 +44,3 @@ exports[`Test Linear Elements > Test bound text element > should resize and posi "Online whiteboard collaboration made easy" `; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = ` -"Online whiteboard -collaboration made easy" -`; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = ` -"Online whiteboard -collaboration made -easy" -`; diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index a3da1c66d..35892d1c5 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -8,7 +8,13 @@ import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; -import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; +import { + act, + fireEvent, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; + +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; import { getTransformHandles } from "../src/transformHandles"; import { @@ -16,463 +22,708 @@ import { TEXT_EDITOR_SELECTOR, } from "../../excalidraw/tests/queries/dom"; +import type { + ExcalidrawArrowElement, + ExcalidrawLinearElement, + FixedPointBinding, +} from "../src/types"; + const { h } = window; const mouse = new Pointer("mouse"); -describe("element binding", () => { - beforeEach(async () => { - await render(); - }); +describe("binding for simple arrows", () => { + describe("when both endpoints are bound inside the same element", () => { + beforeEach(async () => { + mouse.reset(); - it("should create valid binding if duplicate start/end points", async () => { - const rect = API.createElement({ - type: "rectangle", - x: 0, - y: 0, - width: 50, - height: 50, - }); - const arrow = API.createElement({ - type: "arrow", - x: 100, - y: 0, - width: 100, - height: 1, - points: [ - pointFrom(0, 0), - pointFrom(0, 0), - pointFrom(100, 0), - pointFrom(100, 0), - ], - }); - API.setElements([rect, arrow]); - expect(arrow.startBinding).toBe(null); - - // select arrow - mouse.clickAt(150, 0); - - // move arrow start to potential binding position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - // Point selection is evaluated like the points are rendered, - // from right to left. So clicking on the first point should move the joint, - // not the start point. - expect(arrow.startBinding).toBe(null); - - // Now that the start point is free, move it into overlapping position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - expect(API.getSelectedElements()).toEqual([arrow]); - - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + await act(() => { + return setLanguage(defaultLang); + }); + await render(); }); - // Move the end point to the overlapping binding position - mouse.downAt(200, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); + it("should create an `inside` binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.reset(); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); - // Both the start and the end points should be bound - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + const rect = API.getSelectedElement(); + + // Draw arrow with endpoint inside the filled rectangle + UI.clickTool("arrow"); + mouse.downAt(110, 110); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(110); + expect(arrow.y).toBe(110); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const startBinding = arrow.startBinding as FixedPointBinding; + expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(startBinding.mode).toBe("inside"); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(endBinding.mode).toBe("inside"); + + // Move the bindable + mouse.downAt(100, 150); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the start point of the arrow to check if + // the behavior remains the same for old arrows + mouse.reset(); + mouse.downAt(110, 110); + mouse.moveTo(120, 120); + mouse.up(); + + // Move the bindable again + mouse.reset(); + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); }); - expect(arrow.endBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + + it("3+ point arrow should be dragged along with the bindable", () => { + // Create two rectangles as binding targets + const rectLeft = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const rectRight = API.createElement({ + type: "rectangle", + x: 300, + y: 0, + width: 100, + height: 100, + }); + + // Create a non-elbowed arrow with inner points bound to different elements + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 50, + width: 200, + height: 0, + points: [ + pointFrom(0, 0), // start point + pointFrom(50, -20), // first inner point + pointFrom(150, 20), // second inner point + pointFrom(200, 0), // end point + ], + startBinding: { + elementId: rectLeft.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + endBinding: { + elementId: rectRight.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + }); + + API.setElements([rectLeft, rectRight, arrow]); + + // Store original inner point positions + const originalInnerPoint1 = [...arrow.points[1]]; + const originalInnerPoint2 = [...arrow.points[2]]; + + // Move the right rectangle down by 50 pixels + mouse.reset(); + mouse.downAt(350, 50); // Click on the right rectangle + mouse.moveTo(350, 100); // Move it down + mouse.up(); + + // Verify that inner points did NOT move when bound to different elements + // The arrow should NOT translate inner points proportionally when only one end moves + expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); + expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); + expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); + expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); }); }); - //@TODO fix the test with rotation - it.skip("rotation of arrow should rebind both ends", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); + describe("when arrow is outside of shape", () => { + beforeEach(async () => { + mouse.reset(); - const rotation = getTransformHandles( - arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).rotation!; - const rotationHandleX = rotation[0] + rotation[2] / 2; - const rotationHandleY = rotation[1] + rotation[3] / 2; - mouse.down(rotationHandleX, rotationHandleY); - mouse.move(300, 400); - mouse.up(); - expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); - expect(arrow.angle).toBeLessThan(1.3 * Math.PI); - expect(arrow.startBinding?.elementId).toBe(rectRight.id); - expect(arrow.endBinding?.elementId).toBe(rectLeft.id); + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should handle new arrow start point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with arrow tool + UI.clickTool("arrow"); + mouse.downAt(150, 150); // Start inside rectangle + mouse.moveTo(250, 150); // End outside + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have start binding to rectangle + expect(arrow.startBinding?.elementId).toBe(rectangle.id); + expect(arrow.startBinding?.mode).toBe("orbit"); // Default is orbit, not inside + expect(arrow.endBinding).toBeNull(); + }); + + it("should handle new arrow end point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with end point in binding zone + UI.clickTool("arrow"); + mouse.downAt(50, 150); // Start outside + mouse.moveTo(190, 190); // End near rectangle edge (should bind as orbit) + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have end binding to rectangle + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + expect(arrow.endBinding?.mode).toBe("orbit"); + expect(arrow.startBinding).toBeNull(); + }); + + it("should create orbit binding when one of the cursor is inside rectangle", () => { + // Create a filled solid rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rect = API.getSelectedElement(); + API.updateElement(rect, { + fillStyle: "solid", + backgroundColor: "#a5d8ff", + }); + + // Draw arrow with endpoint inside the filled rectangle, since only + // filled bindables bind inside the shape + UI.clickTool("arrow"); + mouse.downAt(10, 10); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(86.4669660940663); + expect(arrow.height).toBeCloseTo(86.46696609406821); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + + mouse.reset(); + + // Move the bindable + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(235); + expect(arrow.height).toBeCloseTo(117.5); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the arrow out + mouse.reset(); + mouse.click(10, 10); + mouse.downAt(96.466, 96.466); + mouse.moveTo(50, 50); + mouse.up(); + + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); + + // Re-bind the arrow by moving the cursor inside the rectangle + mouse.reset(); + mouse.downAt(50, 50); + mouse.moveTo(150, 150); + mouse.up(); + + // Check if the arrow is still on the outside + expect(arrow.width).toBeCloseTo(86, 0); + expect(arrow.height).toBeCloseTo(86, 0); + }); + + it("should happen even if the arrow is not pointing at the element", () => { + // Create a rectangle positioned so the extended arrow segment will miss it + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 100, + height: 100, + }); + + API.setElements([rect]); + + // Draw an arrow that doesn't point at the rectangle (extended segment will miss) + UI.clickTool("arrow"); + mouse.reset(); + mouse.downAt(125, 93); // Start point + mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Should create a fixed point binding since the extended line segment + // from the last arrow segment misses the rectangle + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.startBinding).toHaveProperty("fixedPoint"); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeGreaterThanOrEqual(0); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeLessThanOrEqual(1); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(0.5); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(1); + expect(arrow.endBinding).toBe(null); + }); }); - // TODO fix & reenable once we rewrite tests to work with concurrency - it.skip( - "editing arrow and moving its head to bind it to element A, finalizing the" + - "editing by clicking on element A should end up selecting A", - async () => { - UI.createElement("rectangle", { + describe("", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it( + "editing arrow and moving its head to bind it to element A, finalizing the" + + "editing by clicking on element A should end up selecting A", + async () => { + UI.createElement("rectangle", { + y: 0, + size: 100, + }); + // Create arrow bound to rectangle + UI.clickTool("arrow"); + mouse.down(50, -100); + mouse.up(0, 80); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + + // move arrow head + mouse.down(); + mouse.up(0, 10); + expect(API.getSelectedElement().type).toBe("arrow"); + + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(-50, -50); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(API.getSelectedElement().type).toBe("arrow"); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(0, 0); + expect(h.state.selectedLinearElement).toBeNull(); + expect(API.getSelectedElement().type).toBe("rectangle"); + }, + ); + + it("should unbind on bound element deletion", () => { + const rectangle = UI.createElement("rectangle", { + x: 60, y: 0, size: 100, }); - // Create arrow bound to rectangle - UI.clickTool("arrow"); - mouse.down(50, -100); - mouse.up(0, 80); - // Edit arrow with multi-point - mouse.doubleClick(); - // move arrow head - mouse.down(); - mouse.up(0, 10); - expect(API.getSelectedElement().type).toBe("arrow"); + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); - // NOTE this mouse down/up + await needs to be done in order to repro - // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 - mouse.reset(); - expect(h.state.selectedLinearElement?.isEditing).toBe(true); - mouse.down(0, 0); - await new Promise((r) => setTimeout(r, 100)); - expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + + mouse.select(rectangle); expect(API.getSelectedElement().type).toBe("rectangle"); + Keyboard.keyDown(KEYS.DELETE); + expect(arrow.endBinding).toBe(null); + }); + + it("should unbind arrow when arrow is resized", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 180, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + // Drag arrow off of bound rectangle range + const handles = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).se!; + + const elX = handles[0] + handles[2] / 2; + const elY = handles[1] + handles[3] / 2; + mouse.downAt(elX, elY); + mouse.moveTo(300, 400); mouse.up(); - expect(API.getSelectedElement().type).toBe("rectangle"); - }, - ); - it("should unbind arrow when moving it with keyboard", () => { - const rectangle = UI.createElement("rectangle", { - x: 75, - y: 0, - size: 100, + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); }); - // Creates arrow 1px away from bidding with rectangle - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 49, + it("should unbind arrow when arrow is rotated", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + + UI.clickTool("arrow"); + mouse.reset(); + mouse.clickAt(210, 250); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(390, 251); + mouse.clickAt(390, 251); + + const arrow = API.getSelectedElement() as ExcalidrawArrowElement; + + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; + const rotationHandleX = rotation[0] + rotation[2] / 2; + const rotationHandleY = rotation[1] + rotation[3] / 2; + mouse.reset(); + mouse.down(rotationHandleX, rotationHandleY); + mouse.move(300, 400); + mouse.up(); + expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); + expect(arrow.angle).toBeLessThan(1.3 * Math.PI); + expect(arrow.startBinding).toBeNull(); + expect(arrow.endBinding).toBeNull(); }); - expect(arrow.endBinding).toBe(null); + it("should not unbind when duplicating via selection group", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + y: 200, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 177, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); - mouse.downAt(49, 49); - mouse.moveTo(51, 0); - mouse.up(0, 0); - - // Test sticky connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - // Sever connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding).toBe(null); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on bound element deletion", () => { - const rectangle = UI.createElement("rectangle", { - x: 60, - y: 0, - size: 100, - }); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - mouse.select(rectangle); - expect(API.getSelectedElement().type).toBe("rectangle"); - Keyboard.keyDown(KEYS.DELETE); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on text element deletion by submitting empty text", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // edit text element and submit - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - - const editor = await getTextEditor(); - - fireEvent.change(editor, { target: { value: "" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should keep binding on text update", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // delete text element by submitting empty text - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - const editor = await getTextEditor(); - - expect(editor).not.toBe(null); - - fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding?.elementId).toBe(text.id); - }); - - it("should update binding when text containerized", async () => { - const rectangle1 = API.createElement({ - type: "rectangle", - id: "rectangle1", - width: 100, - height: 100, - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - const arrow1 = API.createElement({ - type: "arrow", - id: "arrow1", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const arrow2 = API.createElement({ - type: "arrow", - id: "arrow2", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const text1 = API.createElement({ - type: "text", - id: "text1", - text: "ola", - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - API.setElements([rectangle1, arrow1, arrow2, text1]); - - API.setSelectedElements([text1]); - - expect(h.state.selectedElementIds[text1.id]).toBe(true); - - API.executeAction(actionWrapTextInContainer); - - // new text container will be placed before the text element - const container = h.elements.at(-2)!; - - expect(container.type).toBe("rectangle"); - expect(container.id).not.toBe(rectangle1.id); - - expect(container).toEqual( - expect.objectContaining({ - boundElements: expect.arrayContaining([ - { - type: "text", - id: text1.id, - }, - { - type: "arrow", - id: arrow1.id, - }, - { - type: "arrow", - id: arrow2.id, - }, - ]), - }), - ); - - expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); - expect(arrow1.endBinding?.elementId).toBe(container.id); - expect(arrow2.startBinding?.elementId).toBe(container.id); - expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); - }); - - // #6459 - it("should unbind arrow only from the latest element", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - // Drag arrow off of bound rectangle range - const handles = getTransformHandles( - arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).se!; - - Keyboard.keyDown(KEYS.CTRL_OR_CMD); - const elX = handles[0] + handles[2] / 2; - const elY = handles[1] + handles[3] / 2; - mouse.downAt(elX, elY); - mouse.moveTo(300, 400); - mouse.up(); - - expect(arrow.startBinding).not.toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should not unbind when duplicating via selection group", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - y: 200, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 177, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - mouse.downAt(-100, -100); - mouse.moveTo(650, 750); - mouse.up(0, 0); - - expect(API.getSelectedElements().length).toBe(3); - - mouse.moveTo(5, 5); - Keyboard.withModifierKeys({ alt: true }, () => { - mouse.downAt(5, 5); - mouse.moveTo(1000, 1000); + mouse.downAt(-100, -100); + mouse.moveTo(650, 750); mouse.up(0, 0); - expect(window.h.elements.length).toBe(6); - window.h.elements.forEach((element) => { - if (isLinearElement(element)) { - expect(element.startBinding).not.toBe(null); - expect(element.endBinding).not.toBe(null); - } else { - expect(element.boundElements).not.toBe(null); - } + expect(API.getSelectedElements().length).toBe(3); + + mouse.moveTo(5, 5); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.downAt(5, 5); + mouse.moveTo(1000, 1000); + mouse.up(0, 0); + + expect(window.h.elements.length).toBe(6); + window.h.elements.forEach((element) => { + if (isLinearElement(element)) { + expect(element.startBinding).not.toBe(null); + expect(element.endBinding).not.toBe(null); + } else { + expect(element.boundElements).not.toBe(null); + } + }); }); }); }); + + describe("to text elements", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "rectangle1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "text1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "text1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "rectangle1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + API.setElements([rectangle1, arrow1, arrow2, text1]); + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + API.executeAction(actionWrapTextInContainer); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); + + it("should keep binding on text update", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // delete text element by submitting empty text + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding?.elementId).toBe(text.id); + }); + + it("should unbind on text element deletion by submitting empty text", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // edit text element and submit + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + + const editor = await getTextEditor(); + + fireEvent.change(editor, { target: { value: "" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding).toBe(null); + }); + }); }); diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index 10b9346a6..60c5e6d83 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => { id: "arrow2", endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, boundElements: [{ id: "text2", type: "text" }], }); @@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => { id: "arrow2", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => { id: "arrow3", startBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index b279e596c..25ef2b2ac 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -1,13 +1,10 @@ import { ARROW_TYPE } from "@excalidraw/common"; import { pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; - import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection"; - import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; - import { act, fireEvent, @@ -15,13 +12,11 @@ import { queryByTestId, render, } from "@excalidraw/excalidraw/tests/test-utils"; - import "@excalidraw/utils/test-utils"; +import { bindBindingElement } from "@excalidraw/element"; import type { LocalPoint } from "@excalidraw/math"; -import { bindLinearElement } from "../src/binding"; - import { Scene } from "../src/Scene"; import type { @@ -160,8 +155,8 @@ describe("elbow arrow routing", () => { expect(arrow.width).toEqual(90); expect(arrow.height).toEqual(200); }); + it("can generate proper points for bound elbow arrow", () => { - const scene = new Scene(); const rectangle1 = API.createElement({ type: "rectangle", x: -150, @@ -185,17 +180,15 @@ describe("elbow arrow routing", () => { height: 200, points: [pointFrom(0, 0), pointFrom(90, 200)], }) as ExcalidrawElbowArrowElement; - scene.insertElement(rectangle1); - scene.insertElement(rectangle2); - scene.insertElement(arrow); + API.setElements([rectangle1, rectangle2, arrow]); - bindLinearElement(arrow, rectangle1, "start", scene); - bindLinearElement(arrow, rectangle2, "end", scene); + bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); + bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - h.app.scene.mutateElement(arrow, { + h.scene.mutateElement(arrow, { points: [pointFrom(0, 0), pointFrom(90, 200)], }); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index f1306b872..d53492541 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -379,7 +379,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -549,7 +549,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -600,7 +600,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -641,7 +641,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -689,7 +689,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `17`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -747,7 +747,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -845,7 +845,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -1316,7 +1316,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBe(400); + expect(arrow.width).toBeCloseTo(405); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1335,7 +1335,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBeCloseTo(200, 0); + expect(arrow.width).toBeCloseTo(205); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index 1d0b6ac0b..1ab1fafce 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -174,29 +174,29 @@ describe("generic element", () => { expect(rectangle.angle).toBeCloseTo(0); }); - it("resizes with bound arrow", async () => { - const rectangle = UI.createElement("rectangle", { - width: 200, - height: 100, - }); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const rectangle = UI.createElement("rectangle", { + // width: 200, + // height: 100, + // }); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - UI.resize(rectangle, "e", [40, 0]); + // UI.resize(rectangle, "e", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - UI.resize(rectangle, "w", [50, 0]); + // UI.resize(rectangle, "w", [50, 0]); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); - }); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); + // }); it("resizes with a label", async () => { const rectangle = UI.createElement("rectangle", { @@ -595,31 +595,31 @@ describe("text element", () => { expect(text.fontSize).toBeCloseTo(fontSize * scale); }); - it("resizes with bound arrow", async () => { - const text = UI.createElement("text"); - await UI.editText(text, "hello\nworld"); - const boundArrow = UI.createElement("arrow", { - x: -30, - y: 25, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const text = UI.createElement("text"); + // await UI.editText(text, "hello\nworld"); + // const boundArrow = UI.createElement("arrow", { + // x: -30, + // y: 25, + // width: 28, + // height: 5, + // }); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); - UI.resize(text, "ne", [40, 0]); + // UI.resize(text, "ne", [40, 0]); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); - const textWidth = text.width; - const scale = 20 / text.height; - UI.resize(text, "nw", [50, 20]); + // const textWidth = text.width; + // const scale = 20 / text.height; + // UI.resize(text, "nw", [50, 20]); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( - 30 + textWidth * scale, - ); - }); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( + // 30 + textWidth * scale, + // ); + // }); it("updates font size via keyboard", async () => { const text = UI.createElement("text"); @@ -801,36 +801,36 @@ describe("image element", () => { expect(image.scale).toEqual([1, 1]); }); - it("resizes with bound arrow", async () => { - const image = API.createElement({ - type: "image", - width: 100, - height: 100, - }); - API.setElements([image]); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const image = API.createElement({ + // type: "image", + // width: 100, + // height: 100, + // }); + // API.setElements([image]); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(arrow.endBinding?.elementId).toEqual(image.id); - UI.resize(image, "ne", [40, 0]); + // UI.resize(image, "ne", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - const imageWidth = image.width; - const scale = 20 / image.height; - UI.resize(image, "nw", [50, 20]); + // const imageWidth = image.width; + // const scale = 20 / image.height; + // UI.resize(image, "nw", [50, 20]); - expect(arrow.endBinding?.elementId).toEqual(image.id); - expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( - 30 + imageWidth * scale, - 0, - ); - }); + // expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( + // 30 + imageWidth * scale, + // 0, + // ); + // }); }); describe("multiple selection", () => { @@ -997,68 +997,80 @@ describe("multiple selection", () => { expect(diagLine.angle).toEqual(0); }); - it("resizes with bound arrows", async () => { - const rectangle = UI.createElement("rectangle", { - position: 0, - size: 100, - }); - const leftBoundArrow = UI.createElement("arrow", { - x: -110, - y: 50, - width: 100, - height: 0, - }); + // it("resizes with bound arrows", async () => { + // const rectangle = UI.createElement("rectangle", { + // position: 0, + // size: 100, + // }); + // const leftBoundArrow = UI.createElement("arrow", { + // x: -110, + // y: 50, + // width: 100, + // height: 0, + // }); - const rightBoundArrow = UI.createElement("arrow", { - x: 210, - y: 50, - width: -100, - height: 0, - }); + // const rightBoundArrow = UI.createElement("arrow", { + // x: 210, + // y: 50, + // width: -100, + // height: 0, + // }); - const selectionWidth = 210; - const selectionHeight = 100; - const move = [40, 40] as [number, number]; - const scale = Math.max( - 1 - move[0] / selectionWidth, - 1 - move[1] / selectionHeight, - ); - const leftArrowBinding = { ...leftBoundArrow.endBinding }; - const rightArrowBinding = { ...rightBoundArrow.endBinding }; - delete rightArrowBinding.gap; + // const selectionWidth = 210; + // const selectionHeight = 100; + // const move = [40, 40] as [number, number]; + // const scale = Math.max( + // 1 - move[0] / selectionWidth, + // 1 - move[1] / selectionHeight, + // ); + // const leftArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...leftBoundArrow.endBinding, + // } as PointBinding; + // const rightArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...rightBoundArrow.endBinding, + // } as PointBinding; + // delete rightArrowBinding.gap; - UI.resize([rectangle, rightBoundArrow], "nw", move, { - shift: true, - }); + // UI.resize([rectangle, rightBoundArrow], "nw", move, { + // shift: true, + // }); - expect(leftBoundArrow.x).toBeCloseTo(-110); - expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(140, 0); - expect(leftBoundArrow.height).toBeCloseTo(7, 0); - expect(leftBoundArrow.angle).toEqual(0); - expect(leftBoundArrow.startBinding).toBeNull(); - expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); - expect(leftBoundArrow.endBinding?.elementId).toBe( - leftArrowBinding.elementId, - ); - expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); + // expect(leftBoundArrow.x).toBeCloseTo(-110); + // expect(leftBoundArrow.y).toBeCloseTo(50); + // expect(leftBoundArrow.width).toBeCloseTo(140, 0); + // expect(leftBoundArrow.height).toBeCloseTo(7, 0); + // expect(leftBoundArrow.angle).toEqual(0); + // expect(leftBoundArrow.startBinding).toBeNull(); + // expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); + // expect(leftBoundArrow.endBinding?.elementId).toBe( + // leftArrowBinding.elementId, + // ); + // expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); - expect(rightBoundArrow.x).toBeCloseTo(210); - expect(rightBoundArrow.y).toBeCloseTo( - (selectionHeight - 50) * (1 - scale) + 50, - ); - expect(rightBoundArrow.width).toBeCloseTo(100 * scale); - expect(rightBoundArrow.height).toBeCloseTo(0); - expect(rightBoundArrow.angle).toEqual(0); - expect(rightBoundArrow.startBinding).toBeNull(); - expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); - expect(rightBoundArrow.endBinding?.elementId).toBe( - rightArrowBinding.elementId, - ); - expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( - rightArrowBinding.focus!, - ); - }); + // expect(rightBoundArrow.x).toBeCloseTo(210); + // expect(rightBoundArrow.y).toBeCloseTo( + // (selectionHeight - 50) * (1 - scale) + 50, + // ); + // expect(rightBoundArrow.width).toBeCloseTo(100 * scale); + // expect(rightBoundArrow.height).toBeCloseTo(0); + // expect(rightBoundArrow.angle).toEqual(0); + // expect(rightBoundArrow.startBinding).toBeNull(); + // expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); + // expect(rightBoundArrow.endBinding?.elementId).toBe( + // rightArrowBinding.elementId, + // ); + // expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( + // rightArrowBinding.focus!, + // ); + // }); it("resizes with labeled arrows", async () => { const topArrow = UI.createElement("arrow", { @@ -1338,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX); - expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); + expect(boundArrow.points[1][0]).toBeCloseTo(64.1246); + expect(boundArrow.points[1][1]).toBeCloseTo(-85.4995); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 535d96c7d..c3a5bde8b 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -51,7 +51,7 @@ import { register } from "./register"; import type { AppState, Offsets } from "../types"; -export const actionChangeViewBackgroundColor = register({ +export const actionChangeViewBackgroundColor = register>({ name: "changeViewBackgroundColor", label: "labels.canvasBackground", trackEvent: false, @@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - captureUpdate: !!value.viewBackgroundColor + captureUpdate: !!value?.viewBackgroundColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -463,7 +463,7 @@ export const actionZoomToFit = register({ !event[KEYS.CTRL_OR_CMD], }); -export const actionToggleTheme = register({ +export const actionToggleTheme = register({ name: "toggleTheme", label: (_, appState) => { return appState.theme === THEME.DARK @@ -471,7 +471,8 @@ export const actionToggleTheme = register({ : "buttons.darkMode"; }, keywords: ["toggle", "dark", "light", "mode", "theme"], - icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), + icon: (appState, elements) => + appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_, appState, value) => { diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index d9b011d2b..8d5ed2a30 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -20,12 +20,12 @@ import { t } from "../i18n"; import { actionDeleteSelected } from "./actionDeleteSelected"; import { register } from "./register"; -export const actionCopy = register({ +export const actionCopy = register({ name: "copy", label: "labels.copy", icon: DuplicateIcon, trackEvent: { category: "element" }, - perform: async (elements, appState, event: ClipboardEvent | null, app) => { + perform: async (elements, appState, event, app) => { const elementsToCopy = app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: true, @@ -109,12 +109,12 @@ export const actionPaste = register({ keyTest: undefined, }); -export const actionCut = register({ +export const actionCut = register({ name: "cut", label: "labels.cut", icon: cutIcon, trackEvent: { category: "element" }, - perform: (elements, appState, event: ClipboardEvent | null, app) => { + perform: (elements, appState, event, app) => { actionCopy.perform(elements, appState, event, app); return actionDeleteSelected.perform(elements, appState, null, app); }, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 78a346568..ef9858b85 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -206,12 +206,8 @@ export const actionDeleteSelected = register({ trackEvent: { category: "element", action: "delete" }, perform: (elements, appState, formData, app) => { if (appState.selectedLinearElement?.isEditing) { - const { - elementId, - selectedPointsIndices, - startBindingElement, - endBindingElement, - } = appState.selectedLinearElement; + const { elementId, selectedPointsIndices } = + appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); const linearElement = LinearElementEditor.getElement( elementId, @@ -248,19 +244,6 @@ export const actionDeleteSelected = register({ }; } - // We cannot do this inside `movePoint` because it is also called - // when deleting the uncommitted point (which hasn't caused any binding) - const binding = { - startBindingElement: selectedPointsIndices?.includes(0) - ? null - : startBindingElement, - endBindingElement: selectedPointsIndices?.includes( - linearElement.points.length - 1, - ) - ? null - : endBindingElement, - }; - LinearElementEditor.deletePoints( linearElement, app, @@ -273,7 +256,6 @@ export const actionDeleteSelected = register({ ...appState, selectedLinearElement: { ...appState.selectedLinearElement, - ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 ? [selectedPointsIndices[0] - 1] diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 908e2463e..1604d3849 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -31,7 +31,9 @@ import "../components/ToolIcon.scss"; import { register } from "./register"; -export const actionChangeProjectName = register({ +import type { AppState } from "../types"; + +export const actionChangeProjectName = register({ name: "changeProjectName", label: "labels.fileTitle", trackEvent: false, @@ -51,7 +53,7 @@ export const actionChangeProjectName = register({ ), }); -export const actionChangeExportScale = register({ +export const actionChangeExportScale = register({ name: "changeExportScale", label: "imageExportDialog.scale", trackEvent: { category: "export", action: "scale" }, @@ -101,7 +103,9 @@ export const actionChangeExportScale = register({ }, }); -export const actionChangeExportBackground = register({ +export const actionChangeExportBackground = register< + AppState["exportBackground"] +>({ name: "changeExportBackground", label: "imageExportDialog.label.withBackground", trackEvent: { category: "export", action: "toggleBackground" }, @@ -121,7 +125,9 @@ export const actionChangeExportBackground = register({ ), }); -export const actionChangeExportEmbedScene = register({ +export const actionChangeExportEmbedScene = register< + AppState["exportEmbedScene"] +>({ name: "changeExportEmbedScene", label: "imageExportDialog.tooltip.embedScene", trackEvent: { category: "export", action: "embedScene" }, @@ -288,7 +294,9 @@ export const actionLoadScene = register({ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, }); -export const actionExportWithDarkMode = register({ +export const actionExportWithDarkMode = register< + AppState["exportWithDarkMode"] +>({ name: "exportWithDarkMode", label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 877c817ad..7e29a935b 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,10 +1,6 @@ import { pointFrom } from "@excalidraw/math"; -import { - maybeBindLinearElement, - bindOrUnbindLinearElement, - isBindingEnabled, -} from "@excalidraw/element/binding"; +import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import { isValidPolygon, LinearElementEditor, @@ -21,7 +17,7 @@ import { import { KEYS, arrayToMap, - tupleToCoors, + invariant, updateActiveTool, } from "@excalidraw/common"; import { isPathALoop } from "@excalidraw/element"; @@ -30,11 +26,12 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; -import type { LocalPoint } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, + PointsPositionUpdates, } from "@excalidraw/element/types"; import { t } from "../i18n"; @@ -46,20 +43,37 @@ import { register } from "./register"; import type { AppState } from "../types"; -export const actionFinalize = register({ +type FormData = { + event: PointerEvent; + sceneCoords: { x: number; y: number }; +}; + +export const actionFinalize = register({ name: "finalize", label: "", trackEvent: false, perform: (elements, appState, data, app) => { + let newElements = elements; const { interactiveCanvas, focusContainer, scene } = app; - const { event, sceneCoords } = - (data as { - event?: PointerEvent; - sceneCoords?: { x: number; y: number }; - }) ?? {}; const elementsMap = scene.getNonDeletedElementsMap(); - if (event && appState.selectedLinearElement) { + if (data && appState.selectedLinearElement) { + const { event, sceneCoords } = data; + const element = LinearElementEditor.getElement( + appState.selectedLinearElement.elementId, + elementsMap, + ); + + invariant( + element, + "Arrow element should exist if selectedLinearElement is set", + ); + + invariant( + sceneCoords, + "sceneCoords should be defined if actionFinalize is called with event", + ); + const linearElementEditor = LinearElementEditor.handlePointerUp( event, appState.selectedLinearElement, @@ -67,19 +81,47 @@ 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.newElement; + + const selectedPointsIndices = + newArrow || !appState.selectedLinearElement.selectedPointsIndices + ? [element.points.length - 1] // New arrow creation + : appState.selectedLinearElement.selectedPointsIndices; + + const draggedPoints: PointsPositionUpdates = + selectedPointsIndices.reduce((map, index) => { + map.set(index, { + point: LinearElementEditor.pointFromAbsoluteCoords( + element, + pointFrom(sceneCoords.x, sceneCoords.y), + elementsMap, + ), + }); + + return map; + }, new Map()) ?? new Map(); + + bindOrUnbindBindingElement(element, draggedPoints, scene, appState, { + newArrow, + }); + } else if (isLineElement(element)) { + if ( + appState.selectedLinearElement?.isEditing && + !appState.newElement && + !isValidPolygon(element.points) + ) { + scene.mutateElement(element, { + polygon: false, + }); + } } if (linearElementEditor !== appState.selectedLinearElement) { - let newElements = elements; + // `handlePointerUp()` updated the linear element instance, + // so filter out this element if it is too small, + // but do an update to all new elements anyway for undo/redo purposes. + if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.map((el) => { @@ -91,39 +133,6 @@ export const actionFinalize = register({ return el; }); } - return { - elements: newElements, - appState: { - selectedLinearElement: { - ...linearElementEditor, - selectedPointsIndices: null, - }, - suggestedBindings: [], - }, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }; - } - } - - if (appState.selectedLinearElement?.isEditing) { - const { elementId, startBindingElement, endBindingElement } = - appState.selectedLinearElement; - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (element) { - if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); - } - if (isLineElement(element) && !isValidPolygon(element.points)) { - scene.mutateElement(element, { - polygon: false, - }); - } return { elements: @@ -134,23 +143,25 @@ export const actionFinalize = register({ } return el; }) - : undefined, + : newElements, appState: { ...appState, cursorButton: "up", - selectedLinearElement: new LinearElementEditor( - element, - arrayToMap(elementsMap), - false, // exit editing mode - ), + selectedLinearElement: { + ...linearElementEditor, + selectedPointsIndices: null, + isEditing: false, + }, + selectionElement: null, + suggestedBinding: null, + newElement: null, + multiElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } } - let newElements = elements; - if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -174,7 +185,11 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if (appState.multiElement && element.type !== "freedraw") { + if ( + appState.multiElement && + element.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || @@ -227,25 +242,6 @@ export const actionFinalize = register({ polygon: false, }); } - - if ( - isBindingElement(element) && - !isLoop && - element.points.length > 1 && - isBindingEnabled(appState) - ) { - const coords = - sceneCoords ?? - tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - -1, - arrayToMap(elements), - ), - ); - - maybeBindLinearElement(element, appState, coords, scene); - } } } @@ -271,6 +267,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, + origin: null, + }, + } + : selectedLinearElement; + return { elements: newElements, appState: { @@ -288,7 +302,7 @@ export const actionFinalize = register({ multiElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: element && !appState.activeTool.locked && @@ -298,11 +312,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..7bcc89595 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -96,7 +96,7 @@ export const getDefaultAppState = (): Omit< panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, }, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameToHighlight: null, editingFrame: null, @@ -123,6 +123,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + bindMode: "orbit", }; }; @@ -224,7 +225,7 @@ const APP_STATE_STORAGE_CONF = (< shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, - suggestedBindings: { browser: false, export: false, server: false }, + suggestedBinding: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false }, @@ -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 bf838b1c3..f89278241 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -103,20 +103,21 @@ import { isMobile, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, + BIND_MODE_TIMEOUT, + invariant, } from "@excalidraw/common"; import { getObservedAppState, getCommonBounds, - maybeSuggestBindingsForLinearElementAtCoords, + maybeSuggestBindingsForBindingElementAtCoords, getElementAbsoluteCoords, - bindOrUnbindLinearElements, + bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, shouldEnableBindingForPointerEvent, updateBoundElements, - getSuggestedBindingsForArrows, LinearElementEditor, newElementWith, newFrameElement, @@ -152,7 +153,6 @@ import { isFlowchartNodeElement, isBindableElement, isTextElement, - getLockedLinearCursorAlignSize, getNormalizedDimensions, isElementCompletelyInViewport, isElementInViewport, @@ -238,9 +238,12 @@ import { StoreDelta, type ApplyToOptions, positionElementsOnGrid, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + mutateElement, } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, @@ -265,6 +268,7 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -576,7 +580,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; @@ -610,6 +613,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 = @@ -763,6 +768,30 @@ class App extends React.Component { this.actionManager.registerAction(createRedoAction(this.history)); } + // setState: React.Component["setState"] = ( + // state, + // callback?, + // ) => { + // let newState: Parameters[0] = null; + // if (typeof state === "function") { + // newState = state(this.state, this.props) as Pick< + // AppState, + // keyof AppState + // >; + // } else { + // newState = state as Pick; + // } + + // if (newState && Object.hasOwn(newState, "selectedLinearElement")) { + // //console.trace(!!newState.selectedLinearElement); + // if (!newState.selectedLinearElement?.selectedPointsIndices?.length) { + // console.trace(newState.selectedLinearElement?.selectedPointsIndices); + // } + // } + + // super.setState(newState, callback); + // }; + updateEditorAtom = ( atom: WritableAtom, ...args: Args @@ -835,6 +864,154 @@ class App extends React.Component { } } + private handleSkipBindMode() { + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + this.setState({ + bindMode: "skip", + }); + } + } + + private resetDelayedBindMode() { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + if (this.state.bindMode !== "orbit") { + // We need this iteration to complete binding and change + // back to orbit mode after that + setTimeout(() => + this.setState({ + bindMode: "orbit", + }), + ); + } + } + + private handleDelayedBindModeChange( + arrow: ExcalidrawArrowElement, + hoveredElement: NonDeletedExcalidrawElement | null, + ) { + if (isElbowArrow(arrow)) { + return; + } + + const effector = () => { + this.bindModeHandler = null; + + invariant( + this.lastPointerMoveCoords, + "Expected lastPointerMoveCoords to be set", + ); + + if (!this.state.multiElement) { + invariant( + this.state.selectedLinearElement?.selectedPointsIndices?.length, + "There has to be at least one selected point to trigger bind mode change at arrow drag creation", + ); + + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + + // Check if the whole arrow is dragged by selecting all endpoints + if ((!startDragged && !endDragged) || (startDragged && endDragged)) { + return; + } + } + + const { x, y } = this.lastPointerMoveCoords; + const hoveredElement = getHoveredElementForBinding( + pointFrom(x, y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (hoveredElement && this.state.bindMode !== "skip") { + invariant( + this.state.selectedLinearElement?.elementId === arrow.id, + "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + ); + + // Once the start is set to inside binding, it remains so + const arrowStartIsInside = + this.state.selectedLinearElement.pointerDownState + .arrowStartIsInside || + arrow.startBinding?.elementId === hoveredElement.id; + + // Change the global binding mode + flushSync(() => { + invariant( + this.state.selectedLinearElement, + "this.state.selectedLinearElement must exist", + ); + + this.setState({ + bindMode: "inside", + selectedLinearElement: { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement.pointerDownState, + arrowStartIsInside, + }, + }, + }); + }); + + const event = + this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; + invariant(event, "Last event must exist"); + const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; + const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; + const newState = this.state.multiElement + ? LinearElementEditor.handlePointerMove( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ) + : LinearElementEditor.handlePointDragging( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ); + this.setState(newState); + } + }; + + if (!hoveredElement) { + // Clear the timeout if we're not hovering a bindable + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // Clear the inside binding mode too + if (this.state.bindMode === "inside") { + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + } else if (!this.bindModeHandler) { + // We are hovering a bindable element + this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); + } + } + private cacheEmbeddableRef( element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, @@ -4392,6 +4569,11 @@ class App extends React.Component { return; } + // Handle Alt key for bind mode + if (event.key === KEYS.ALT) { + this.handleSkipBindMode(); + } + if (this.actionManager.handleKeyDown(event)) { return; } @@ -4401,6 +4583,7 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + this.resetDelayedBindMode(); this.setState({ isBindingEnabled: false }); } @@ -4411,14 +4594,12 @@ class App extends React.Component { includeElementsInFrames: true, }); - const elbowArrow = selectedElements.find(isElbowArrow) as - | ExcalidrawArrowElement - | undefined; - const arrowIdsToRemove = new Set(); selectedElements - .filter(isElbowArrow) + .filter((el): el is NonDeleted => + isBindingElement(el), + ) .filter((arrow) => { const startElementNotInSelection = arrow.startBinding && @@ -4475,16 +4656,6 @@ class App extends React.Component { }); }); - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements.filter( - (element) => element.id !== elbowArrow?.id || step !== 0, - ), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - this.scene.triggerUpdate(); event.preventDefault(); @@ -4687,18 +4858,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(), + ); + + if (this.state.selectedLinearElement) { + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + this.scene.getNonDeletedElementsMap(), + ); + + if (isBindingElement(element)) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + } + } 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, ); - this.setState({ suggestedBindings: [] }); + + 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({ suggestedBinding: null }); } if (!event.altKey) { @@ -4797,7 +5045,7 @@ class App extends React.Component { this.focusContainer(); } if (!isLinearElementType(nextActiveTool.type)) { - this.setState({ suggestedBindings: [] }); + this.setState({ suggestedBinding: null }); } if (nextActiveTool.type === "image") { this.onImageToolbarButtonClick(); @@ -5784,6 +6032,12 @@ class App extends React.Component { ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.lastPointerMoveEvent = event.nativeEvent; + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -5833,6 +6087,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5870,9 +6126,6 @@ class App extends React.Component { } } - const scenePointer = viewportCoordsToSceneCoords(event, this.state); - const { x: scenePointerX, y: scenePointerY } = scenePointer; - if ( !this.state.newElement && isActiveToolNonLinearSnappable(this.state.activeTool.type) @@ -5924,15 +6177,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.handlePointerMoveInEditMode( + event, + scenePointerX, + scenePointerY, + this, + ); if ( editingLinearElement && @@ -5947,18 +6199,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)) { @@ -5967,24 +6207,21 @@ class App extends React.Component { const { newElement } = this.state; if (isBindingElement(newElement, false)) { this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( + suggestedBinding: maybeSuggestBindingsForBindingElementAtCoords( newElement, - [scenePointer], + "end", this.scene, - this.state.zoom, - this.state.startBoundElement, + pointFrom(scenePointerX, scenePointerY), ), }); } 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); @@ -6030,58 +6267,38 @@ 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]; - - let dxFromLastCommitted = gridX - rx - lastCommittedX; - let dyFromLastCommitted = gridY - ry - lastCommittedY; - - if (shouldRotateWithDiscreteAngle(event)) { - ({ width: dxFromLastCommitted, height: dyFromLastCommitted } = - getLockedLinearCursorAlignSize( - // actual coordinate of the last committed point - lastCommittedX + rx, - lastCommittedY + ry, - // cursor-grid coordinate - gridX, - gridY, - )); - } - if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - // update last uncommitted point - this.scene.mutateElement( - multiElement, - { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], - }, - { - isDragging: true, - informMutation: false, - }, + // Update arrow points + const elementsMap = this.scene.getNonDeletedElementsMap(); + + if (isSimpleArrow(multiElement)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + elementsMap, + ); + + this.handleDelayedBindModeChange(multiElement, hoveredElement); + } + + invariant( + this.state.selectedLinearElement, + "Expected selectedLinearElement to be set to operate on a linear element", ); - // in this path, we're mutating multiElement to reflect - // how it will be after adding pointer position as the next point - // trigger update here so that new element canvas renders again to reflect this - this.triggerRender(false); + const newState = LinearElementEditor.handlePointerMove( + event.nativeEvent, + this, + scenePointerX, + scenePointerY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } } return; @@ -6250,7 +6467,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) ) { @@ -6372,7 +6589,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) ) { @@ -6386,7 +6603,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) ) { @@ -6431,6 +6648,13 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; + const target = event.target as HTMLElement; // capture subsequent pointer events to the canvas // this makes other elements non-interactive until pointer up @@ -6497,7 +6721,7 @@ class App extends React.Component { newElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: makeNextSelectedElementIds( Object.keys(this.state.selectedElementIds) .filter((key) => key !== element.id) @@ -6855,6 +7079,7 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { + this.resetDelayedBindMode(); this.removePointer(event); this.lastPointerUpEvent = event; @@ -6862,6 +7087,11 @@ class App extends React.Component { { clientX: event.clientX, clientY: event.clientY }, this.state, ); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; const clicklength = event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); @@ -7728,16 +7958,18 @@ 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, ); this.setState({ newElement: element, startBoundElement: boundElement, - suggestedBindings: [], + suggestedBinding: null, }); }; @@ -7909,25 +8141,48 @@ class App extends React.Component { lastCommittedPoint: multiElement.points[multiElement.points.length - 1], }); - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const hoveredElementForBinding = getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords?.x ?? + rx + multiElement.points[multiElement.points.length - 1][0], + this.lastPointerMoveCoords?.y ?? + ry + multiElement.points[multiElement.points.length - 1][1], + ), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); // clicking inside commit zone → finalize arrow if ( - multiElement.points.length > 1 && - lastCommittedPoint && - pointDistance( - pointFrom( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - ), - lastCommittedPoint, - ) < LINE_CONFIRM_THRESHOLD + (isBindingElement(multiElement) && hoveredElementForBinding) || + (multiElement.points.length > 1 && + lastCommittedPoint && + pointDistance( + pointFrom( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + ), + lastCommittedPoint, + ) < LINE_CONFIRM_THRESHOLD) ) { - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } @@ -8016,35 +8271,84 @@ 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(), - this.state.zoom, - isElbowArrow(element), - isElbowArrow(element), + elementsMap, ); + 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 }, + ); + + this.handleDelayedBindModeChange(element, boundElement); + } + + this.setState((prevState) => { + let linearElementEditor = null; + let nextSelectedElementIds = prevState.selectedElementIds; + if (isLinearElement(element)) { + linearElementEditor = new LinearElementEditor( + element, + this.scene.getNonDeletedElementsMap(), + ); + + if (isBindingElement(element)) { + const endIdx = element.points.length - 1; + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [endIdx], + pointerDownState: { + ...linearElementEditor.pointerDownState, + lastClickedPoint: endIdx, + origin: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + }, + }; + nextSelectedElementIds = makeNextSelectedElementIds( + { [element.id]: true }, + prevState, + ); + } + } + + return { + ...prevState, + bindMode: "orbit", + newElement: element, + startBoundElement: boundElement, + suggestedBinding: boundElement || null, + selectedElementIds: nextSelectedElementIds, + selectedLinearElement: linearElementEditor, + }; }); } }; @@ -8338,26 +8642,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; @@ -8415,23 +8699,54 @@ class App extends React.Component { !linearElementEditor.pointerDownState.segmentMidpoint.added ) { return; - } + } else if (linearElementEditor.pointerDownState.lastClickedPoint > -1) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); - const newState = LinearElementEditor.handlePointDragging( - event, - this, - pointerCoords.x, - pointerCoords.y, - linearElementEditor, - ); - if (newState) { - pointerDownState.lastCoords.x = pointerCoords.x; - pointerDownState.lastCoords.y = pointerCoords.y; - pointerDownState.drag.hasOccurred = true; + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + elementsMap, + ); - this.setState(newState); + this.handleDelayedBindModeChange(element, hoveredElement); + } - return; + const newState = LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + linearElementEditor, + ); + if (newState) { + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + pointerDownState.drag.hasOccurred = true; + + // NOTE: Optimize setState calls because it + // affects history and performance + if ( + newState.suggestedBinding !== this.state.suggestedBinding || + !isShallowEqual( + newState.selectedLinearElement?.selectedPointsIndices ?? [], + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ) || + newState.selectedLinearElement?.hoverPointIndex !== + this.state.selectedLinearElement?.hoverPointIndex || + newState.selectedLinearElement?.customLineAngle !== + this.state.selectedLinearElement?.customLineAngle || + this.state.selectedLinearElement.isDragging !== + newState.selectedLinearElement?.isDragging + ) { + this.setState(newState); + } + + return; + } } } @@ -8604,13 +8919,13 @@ class App extends React.Component { const nextCrop = { ...crop, x: clamp( - crop.x - + crop.x + offsetVector[0] * Math.sign(croppingElement.scale[0]), 0, image.naturalWidth - crop.width, ), y: clamp( - crop.y - + crop.y + offsetVector[1] * Math.sign(croppingElement.scale[1]), 0, image.naturalHeight - crop.height, @@ -8662,19 +8977,6 @@ class App extends React.Component { selectionElement: null, }); - if ( - selectedElements.length !== 1 || - !isElbowArrow(selectedElements[0]) - ) { - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - } - // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { // Move the currently selected elements to the top of the z index stack, and @@ -8893,55 +9195,38 @@ 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, - )); - } + invariant( + points.length > 1, + "Do not create linear elements with less than 2 points", + ); - if (points.length === 1) { - this.scene.mutateElement( + let linearElementEditor = this.state.selectedLinearElement; + if (!linearElementEditor) { + linearElementEditor = new LinearElementEditor( newElement, - { - points: [...points, pointFrom(dx, dy)], - }, - { informMutation: false, isDragging: false }, + this.scene.getNonDeletedElementsMap(), ); - } else if ( - points.length === 2 || - (points.length > 1 && isElbowArrow(newElement)) - ) { - this.scene.mutateElement( - newElement, - { - points: [...points.slice(0, -1), pointFrom(dx, dy)], + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [1], + pointerDownState: { + ...linearElementEditor.pointerDownState, + lastClickedPoint: 1, }, - { isDragging: true, informMutation: false }, - ); + }; } this.setState({ newElement, + ...LinearElementEditor.handlePointDragging( + event, + this, + gridX, + gridY, + linearElementEditor, + )!, }); - - if (isBindingElement(newElement, false)) { - // When creating a linear element by dragging - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [pointerCoords], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } } else { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -9092,6 +9377,8 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + this.removePointer(childEvent); pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { @@ -9123,7 +9410,6 @@ class App extends React.Component { // just in case, tool changes mid drag, always clean up this.lassoTrail.endPath(); - this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -9175,10 +9461,12 @@ class App extends React.Component { }); } + this.resetDelayedBindMode(); + this.setState({ selectedElementsAreBeingDragged: false, + bindMode: "orbit", }); - const elementsMap = this.scene.getNonDeletedElementsMap(); if ( pointerDownState.drag.hasOccurred && @@ -9199,7 +9487,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 !== @@ -9213,10 +9504,14 @@ 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, - suggestedBindings: [], + suggestedBinding: null, }); } } @@ -9310,7 +9605,11 @@ class App extends React.Component { } if (isLinearElement(newElement)) { - if (newElement!.points.length > 1) { + if ( + newElement!.points.length > 1 && + newElement.points[1][0] !== 0 && + newElement.points[1][1] !== 0 + ) { this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( @@ -9356,7 +9655,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 }, ); @@ -9367,16 +9666,13 @@ 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, }); } - this.setState({ suggestedBindings: [], startBoundElement: null }); + this.setState({ suggestedBinding: null, startBoundElement: null }); if (!activeTool.locked) { resetCursor(this.interactiveCanvas); this.setState((prevState) => ({ @@ -9971,15 +10267,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") { @@ -9997,7 +10287,7 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, activeTool: updateActiveTool(this.state, { type: this.defaultSelectionTool, }), @@ -10005,7 +10295,7 @@ class App extends React.Component { } else { this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, }); } @@ -10037,6 +10327,67 @@ class App extends React.Component { private eraseElements = () => { let didChange = false; + + // Binding is double accounted on both elements and if one of them is + // deleted, the binding should be removed + this.elementsPendingErasure.forEach((id) => { + const element = this.scene.getElement(id); + if (isBindingElement(element)) { + if (element.startBinding) { + const bindable = this.scene.getElement( + element.startBinding.elementId, + )!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + if (element.endBinding) { + const bindable = this.scene.getElement(element.endBinding.elementId)!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + } else if (isBindableElement(element)) { + element.boundElements?.forEach((boundElement) => { + if (boundElement.type === "arrow") { + const arrow = this.scene.getElement( + boundElement.id, + ) as ExcalidrawArrowElement; + if (arrow?.startBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + startBinding: null, + }, + ); + } + if (arrow?.endBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + endBinding: null, + }, + ); + } + } + }); + } + }); + const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( this.elementsPendingErasure.has(ele.id) || @@ -10345,24 +10696,17 @@ 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: - hoveredBindableElement != null ? [hoveredBindableElement] : [], + suggestedBinding: hoveredBindableElement ?? null, }); }; @@ -10908,12 +11252,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", @@ -11039,12 +11378,6 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - const suggestedBindings = getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ); - const elementsToHighlight = new Set(); selectedFrames.forEach((frame) => { getElementsInResizingFrame( @@ -11057,7 +11390,6 @@ class App extends React.Component { this.setState({ elementsToHighlight: [...elementsToHighlight], - suggestedBindings, }); return true; @@ -11363,6 +11695,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11382,6 +11716,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/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index d216f1d46..cdadbec08 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -582,7 +582,7 @@ const LayerUI = ({ const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const { - suggestedBindings, + suggestedBinding, startBoundElement, cursorButton, scrollX, diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 773f86888..c79e9bb3b 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -34,6 +34,7 @@ const handleDegreeChange: DragInputCallbackType = ({ shouldChangeByStepSize, nextValue, scene, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -48,7 +49,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -74,7 +75,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 539a2ad59..4680858dc 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -94,9 +94,7 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, scene, { - newSize: { width: updates.width, height: updates.height }, - }); + updateBoundElements(latestElement, scene); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { scene.mutateElement(latestBoundTextElement, { diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 19b52e2f4..35f6cfb89 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -38,6 +38,7 @@ const moveElements = ( originalElements: readonly ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { for (let i = 0; i < originalElements.length; i++) { const origElement = originalElements[i]; @@ -63,6 +64,7 @@ const moveElements = ( newTopLeftY, origElement, scene, + appState, originalElementsMap, false, ); @@ -75,6 +77,7 @@ const moveGroupTo = ( originalElements: ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { const elementsMap = scene.getNonDeletedElementsMap(); const [x1, y1, ,] = getCommonBounds(originalElements); @@ -107,6 +110,7 @@ const moveGroupTo = ( topLeftY + offsetY, origElement, scene, + appState, originalElementsMap, false, ); @@ -125,6 +129,7 @@ const handlePositionChange: DragInputCallbackType< property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -152,6 +157,7 @@ const handlePositionChange: DragInputCallbackType< elementsInUnit.map((el) => el.original), originalElementsMap, scene, + app.state, ); } else { const origElement = elementsInUnit[0]?.original; @@ -178,6 +184,7 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, origElement, scene, + app.state, originalElementsMap, false, ); @@ -203,6 +210,7 @@ const handlePositionChange: DragInputCallbackType< originalElements, originalElementsMap, scene, + app.state, ); scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index f89ce2615..8b5718330 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -34,6 +34,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -131,6 +132,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); return; @@ -162,6 +164,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); }; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index bcfab8520..47fcd64be 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -4,9 +4,9 @@ import throttle from "lodash.throttle"; import { useEffect, useMemo, useState, memo } from "react"; import { STATS_PANELS } from "@excalidraw/common"; -import { getCommonBounds } from "@excalidraw/element"; +import { getCommonBounds, isBindingElement } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element"; -import { isElbowArrow, isImageElement } from "@excalidraw/element"; +import { isImageElement } from "@excalidraw/element"; import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; @@ -333,7 +333,7 @@ export const StatsInner = memo( appState={appState} /> - {!isElbowArrow(singleElement) && ( + {!isBindingElement(singleElement) && ( { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("204")); - expect(linear.startBinding).not.toBe(null); - }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("1")); + UI.updateInput(inputX, String("186")); expect(linear.startBinding).not.toBe(null); }); @@ -161,17 +150,6 @@ describe("binding with linear elements", () => { UI.updateInput(inputX, String("254")); expect(linear.startBinding).toBe(null); }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("45")); - expect(linear.startBinding).toBe(null); - }); }); // single element diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 68d202098..762826184 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,6 +1,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; -import { getBoundTextElement } from "@excalidraw/element"; +import { + getBoundTextElement, + isBindingElement, + unbindBindingElement, +} from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element"; import { @@ -12,6 +16,7 @@ import { import { getFrameChildren } from "@excalidraw/element"; import { updateBindings } from "@excalidraw/element"; +import { DRAGGING_THRESHOLD } from "@excalidraw/common"; import type { Radians } from "@excalidraw/math"; @@ -110,9 +115,25 @@ export const moveElement = ( newTopLeftY: number, originalElement: ExcalidrawElement, scene: Scene, + appState: AppState, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + if ( + isBindingElement(originalElement) && + (originalElement.startBinding || originalElement.endBinding) + ) { + if ( + Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD && + Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD + ) { + return; + } + + unbindBindingElement(originalElement, "start", scene); + unbindBindingElement(originalElement, "end", scene); + } + const elementsMap = scene.getNonDeletedElementsMap(); const latestElement = elementsMap.get(originalElement.id); if (!latestElement) { @@ -145,7 +166,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, appState); const boundTextElement = getBoundTextElement( originalElement, @@ -203,7 +224,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestChildElement, scene, { + updateBindings(latestChildElement, scene, appState, { simultaneouslyUpdated: originalChildren, }); }); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c375a2b16..1a7b3b865 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -201,8 +201,9 @@ const getRelevantAppStateProps = ( selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, + newElement: appState.newElement, isBindingEnabled: appState.isBindingEnabled, - suggestedBindings: appState.suggestedBindings, + suggestedBinding: appState.suggestedBinding, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, collaborators: appState.collaborators, // Necessary for collab. sessions diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 9e23fa500..9e6a3324a 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -99,6 +99,7 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => { editingGroupId: appState.editingGroupId, currentHoveredFontFamily: appState.currentHoveredFontFamily, croppingElementId: appState.croppingElementId, + suggestedBinding: appState.suggestedBinding, }; return relevantAppStateProps; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index f00a51817..cd95bedf9 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": -0.007519379844961235, - "gap": 11.562288374879595, + "fixedPoint": [ + 0.04, + 0.4633333333333333, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -118,8 +121,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "id49", - "focus": -0.0813953488372095, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1864ab", "strokeStyle": "solid", @@ -144,8 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": 0.10666666666666667, - "gap": 3.8343264684446097, + "fixedPoint": [ + -0.01, + 0.44666666666666666, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -174,8 +183,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "diamond-1", - "focus": 0, - "gap": 4.535423522449215, + "fixedPoint": [ + 0.9357142857142857, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -334,8 +346,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", - "focus": 0, - "gap": 16, + "fixedPoint": [ + -2.05, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -364,8 +379,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startArrowhead": null, "startBinding": { "elementId": "text-1", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -436,8 +454,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "endArrowhead": "arrow", "endBinding": { "elementId": "id42", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -466,8 +487,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startArrowhead": null, "startBinding": { "elementId": "id41", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -612,8 +636,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "endArrowhead": "arrow", "endBinding": { "elementId": "id46", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -642,8 +669,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startArrowhead": null, "startBinding": { "elementId": "id45", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1476,8 +1506,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "Alice", - "focus": -0, - "gap": 5.299874999999986, + "fixedPoint": [ + -0.07542628418945944, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1508,8 +1541,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1.000004978564514, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1539,8 +1575,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "B", - "focus": 0, - "gap": 32, + "fixedPoint": [ + 0.46387050630528887, + 0.48466257668711654, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1567,8 +1606,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 0.39381496335223337, + 1, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57..7970ba483 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -32,7 +32,6 @@ import { isArrowBoundToElement, isArrowElement, isElbowArrow, - isFixedPointBinding, isLinearElement, isLineElement, isTextElement, @@ -61,7 +60,6 @@ import type { FontFamilyValues, NonDeletedSceneElementsMap, OrderedExcalidrawElement, - PointBinding, StrokeRoundness, } from "@excalidraw/element/types"; @@ -123,36 +121,29 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { const repairBinding = ( element: T, - binding: PointBinding | FixedPointBinding | null, -): T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null => { + binding: FixedPointBinding | null, +): FixedPointBinding | null => { if (!binding) { return null; } - const focus = binding.focus || 0; - if (isElbowArrow(element)) { const fixedPointBinding: | ExcalidrawElbowArrowElement["startBinding"] - | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding) - ? { - ...binding, - focus, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), - } - : null; + | ExcalidrawElbowArrowElement["endBinding"] = { + ...binding, + fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + mode: binding.mode || "orbit", + }; return fixedPointBinding; } return { - ...binding, - focus, - } as T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null; + elementId: binding.elementId, + mode: binding.mode || "orbit", + fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.51, 0.51]), + } as FixedPointBinding | null; }; const restoreElementWithProperties = < diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0d9fcf316..b620abfe5 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -432,12 +432,9 @@ describe("Test Transform", () => { boundElements: [{ id: text.id, type: "text" }], startBinding: { elementId: rectangle.id, - focus: 0, - gap: 1, }, endBinding: { elementId: ellipse.id, - focus: -0, }, }); @@ -517,12 +514,9 @@ describe("Test Transform", () => { boundElements: [{ id: text1.id, type: "text" }], startBinding: { elementId: text2.id, - focus: 0, - gap: 1, }, endBinding: { elementId: text3.id, - focus: -0, }, }); @@ -780,8 +774,8 @@ describe("Test Transform", () => { const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", - focus: -0, - gap: 25, + fixedPoint: [-2.05, 0.5001], + mode: "orbit", }); expect(rect.boundElements).toStrictEqual([ { diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index fd0d3388f..5b9f67e65 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -16,7 +16,7 @@ import { getLineHeight, } from "@excalidraw/common"; -import { bindLinearElement } from "@excalidraw/element"; +import { bindBindingElement } from "@excalidraw/element"; import { newArrowElement, newElement, @@ -330,9 +330,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, startBoundElement as ExcalidrawBindableElement, + "orbit", "start", scene, ); @@ -405,9 +406,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, endBoundElement as ExcalidrawBindableElement, + "orbit", "end", scene, ); diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index e9b6c3f96..4d6bbbb6c 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -101,7 +101,10 @@ declare module "image-blob-reduce" { interface CustomMatchers { toBeNonNaNNumber(): void; - toCloselyEqualPoints(points: readonly [number, number][]): void; + toCloselyEqualPoints( + points: readonly [number, number][], + precision?: number, + ): void; } declare namespace jest { diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 845efc15c..a5da2c3a3 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -81,8 +81,8 @@ "@braintree/sanitize-url": "6.0.2", "@excalidraw/common": "0.18.0", "@excalidraw/element": "0.18.0", - "@excalidraw/math": "0.18.0", "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", @@ -97,8 +97,8 @@ "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", - "lodash.throttle": "4.1.1", "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index d357822ec..a267636af 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -1,26 +1,5 @@ import { THEME, THEME_FILTER } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE } from "@excalidraw/element"; -import { getDiamondPoints } from "@excalidraw/element"; -import { elementCenterPoint, getCornerRadius } from "@excalidraw/element"; - -import { - curve, - curveCatmullRomCubicApproxPoints, - curveCatmullRomQuadraticApproxPoints, - curveOffsetPoints, - type GlobalPoint, - offsetPointsForQuadraticBezier, - pointFrom, - pointRotateRads, -} from "@excalidraw/math"; - -import type { - ElementsMap, - ExcalidrawDiamondElement, - ExcalidrawRectanguloidElement, -} from "@excalidraw/element/types"; - import type { StaticCanvasRenderConfig } from "../scene/types"; import type { AppState, StaticCanvasAppState } from "../types"; @@ -97,163 +76,6 @@ export const bootstrapCanvas = ({ return context; }; -function drawCatmullRomQuadraticApprox( - ctx: CanvasRenderingContext2D, - points: GlobalPoint[], - tension = 0.5, -) { - const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension); - if (pointSets) { - for (let i = 0; i < pointSets.length - 1; i++) { - const [[cpX, cpY], [p2X, p2Y]] = pointSets[i]; - - ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y); - } - } -} - -function drawCatmullRomCubicApprox( - ctx: CanvasRenderingContext2D, - points: GlobalPoint[], - tension = 0.5, -) { - const pointSets = curveCatmullRomCubicApproxPoints(points, tension); - if (pointSets) { - for (let i = 0; i < pointSets.length; i++) { - const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i]; - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); - } - } -} - -export const drawHighlightForRectWithRotation = ( - context: CanvasRenderingContext2D, - element: ExcalidrawRectanguloidElement, - elementsMap: ElementsMap, - padding: number, -) => { - const [x, y] = pointRotateRads( - pointFrom(element.x, element.y), - elementCenterPoint(element, elementsMap), - element.angle, - ); - - context.save(); - context.translate(x, y); - context.rotate(element.angle); - - let radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - if (radius === 0) { - radius = 0.01; - } - - context.beginPath(); - - { - const topLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0, 0 + radius), - pointFrom(0, 0), - pointFrom(0 + radius, 0), - padding, - ); - const topRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width - radius, 0), - pointFrom(element.width, 0), - pointFrom(element.width, radius), - padding, - ); - const bottomRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width, element.height - radius), - pointFrom(element.width, element.height), - pointFrom(element.width - radius, element.height), - padding, - ); - const bottomLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(radius, element.height), - pointFrom(0, element.height), - pointFrom(0, element.height - radius), - padding, - ); - - context.moveTo( - topLeftApprox[topLeftApprox.length - 1][0], - topLeftApprox[topLeftApprox.length - 1][1], - ); - context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topRightApprox); - context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomRightApprox); - context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomLeftApprox); - context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topLeftApprox); - } - - // Counter-clockwise for the cutout in the middle. We need to have an "inverse - // mask" on a filled shape for the diamond highlight, because stroking creates - // sharp inset edges on line joins < 90 degrees. - { - const topLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0 + radius, 0), - pointFrom(0, 0), - pointFrom(0, 0 + radius), - -FIXED_BINDING_DISTANCE, - ); - const topRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width, radius), - pointFrom(element.width, 0), - pointFrom(element.width - radius, 0), - -FIXED_BINDING_DISTANCE, - ); - const bottomRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width - radius, element.height), - pointFrom(element.width, element.height), - pointFrom(element.width, element.height - radius), - -FIXED_BINDING_DISTANCE, - ); - const bottomLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0, element.height - radius), - pointFrom(0, element.height), - pointFrom(radius, element.height), - -FIXED_BINDING_DISTANCE, - ); - - context.moveTo( - topLeftApprox[topLeftApprox.length - 1][0], - topLeftApprox[topLeftApprox.length - 1][1], - ); - context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomLeftApprox); - context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomRightApprox); - context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topRightApprox); - context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topLeftApprox); - } - - context.closePath(); - context.fill(); - - context.restore(); -}; - -export const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - export const strokeRectWithRotation = ( context: CanvasRenderingContext2D, x: number, @@ -283,147 +105,3 @@ export const strokeRectWithRotation = ( } context.restore(); }; - -export const drawHighlightForDiamondWithRotation = ( - context: CanvasRenderingContext2D, - padding: number, - element: ExcalidrawDiamondElement, - elementsMap: ElementsMap, -) => { - const [x, y] = pointRotateRads( - pointFrom(element.x, element.y), - elementCenterPoint(element, elementsMap), - element.angle, - ); - context.save(); - context.translate(x, y); - context.rotate(element.angle); - - { - context.beginPath(); - - const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element); - const verticalRadius = element.roundness - ? getCornerRadius(Math.abs(topX - leftX), element) - : (topX - leftX) * 0.01; - const horizontalRadius = element.roundness - ? getCornerRadius(Math.abs(rightY - topY), element) - : (rightY - topY) * 0.01; - const topApprox = curveOffsetPoints( - curve( - pointFrom(topX - verticalRadius, topY + horizontalRadius), - pointFrom(topX, topY), - pointFrom(topX, topY), - pointFrom(topX + verticalRadius, topY + horizontalRadius), - ), - padding, - ); - const rightApprox = curveOffsetPoints( - curve( - pointFrom(rightX - verticalRadius, rightY - horizontalRadius), - pointFrom(rightX, rightY), - pointFrom(rightX, rightY), - pointFrom(rightX - verticalRadius, rightY + horizontalRadius), - ), - padding, - ); - const bottomApprox = curveOffsetPoints( - curve( - pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), - pointFrom(bottomX, bottomY), - pointFrom(bottomX, bottomY), - pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), - ), - padding, - ); - const leftApprox = curveOffsetPoints( - curve( - pointFrom(leftX + verticalRadius, leftY + horizontalRadius), - pointFrom(leftX, leftY), - pointFrom(leftX, leftY), - pointFrom(leftX + verticalRadius, leftY - horizontalRadius), - ), - padding, - ); - - context.moveTo( - topApprox[topApprox.length - 1][0], - topApprox[topApprox.length - 1][1], - ); - context.lineTo(rightApprox[1][0], rightApprox[1][1]); - drawCatmullRomCubicApprox(context, rightApprox); - context.lineTo(bottomApprox[1][0], bottomApprox[1][1]); - drawCatmullRomCubicApprox(context, bottomApprox); - context.lineTo(leftApprox[1][0], leftApprox[1][1]); - drawCatmullRomCubicApprox(context, leftApprox); - context.lineTo(topApprox[1][0], topApprox[1][1]); - drawCatmullRomCubicApprox(context, topApprox); - } - - // Counter-clockwise for the cutout in the middle. We need to have an "inverse - // mask" on a filled shape for the diamond highlight, because stroking creates - // sharp inset edges on line joins < 90 degrees. - { - const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element); - const verticalRadius = element.roundness - ? getCornerRadius(Math.abs(topX - leftX), element) - : (topX - leftX) * 0.01; - const horizontalRadius = element.roundness - ? getCornerRadius(Math.abs(rightY - topY), element) - : (rightY - topY) * 0.01; - const topApprox = curveOffsetPoints( - curve( - pointFrom(topX + verticalRadius, topY + horizontalRadius), - pointFrom(topX, topY), - pointFrom(topX, topY), - pointFrom(topX - verticalRadius, topY + horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const rightApprox = curveOffsetPoints( - curve( - pointFrom(rightX - verticalRadius, rightY + horizontalRadius), - pointFrom(rightX, rightY), - pointFrom(rightX, rightY), - pointFrom(rightX - verticalRadius, rightY - horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const bottomApprox = curveOffsetPoints( - curve( - pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), - pointFrom(bottomX, bottomY), - pointFrom(bottomX, bottomY), - pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const leftApprox = curveOffsetPoints( - curve( - pointFrom(leftX + verticalRadius, leftY - horizontalRadius), - pointFrom(leftX, leftY), - pointFrom(leftX, leftY), - pointFrom(leftX + verticalRadius, leftY + horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - - context.moveTo( - topApprox[topApprox.length - 1][0], - topApprox[topApprox.length - 1][1], - ); - context.lineTo(leftApprox[1][0], leftApprox[1][1]); - drawCatmullRomCubicApprox(context, leftApprox); - context.lineTo(bottomApprox[1][0], bottomApprox[1][1]); - drawCatmullRomCubicApprox(context, bottomApprox); - context.lineTo(rightApprox[1][0], rightApprox[1][1]); - drawCatmullRomCubicApprox(context, rightApprox); - context.lineTo(topApprox[1][0], topApprox[1][1]); - drawCatmullRomCubicApprox(context, topApprox); - } - context.closePath(); - context.fill(); - context.restore(); -}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index e071d47aa..776d43fd5 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -16,7 +16,6 @@ import { throttleRAF, } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { getOmitSidesForDevice, @@ -44,11 +43,6 @@ import { import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; -import type { - SuggestedBinding, - SuggestedPointBinding, -} from "@excalidraw/element"; - import type { TransformHandles, TransformHandleType, @@ -56,7 +50,6 @@ import type { import type { ElementsMap, - ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, ExcalidrawImageElement, @@ -79,11 +72,8 @@ import { getClientColor, renderRemoteCursors } from "../clients"; import { bootstrapCanvas, - drawHighlightForDiamondWithRotation, - drawHighlightForRectWithRotation, fillCircle, getNormalizedCanvasDimensions, - strokeEllipseWithRotation, strokeRectWithRotation, } from "./helpers"; @@ -188,85 +178,6 @@ const renderSingleLinearPoint = ( ); }; -const renderBindingHighlightForBindableElement = ( - context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], -) => { - const padding = maxBindingGap(element, element.width, element.height, zoom); - - context.fillStyle = "rgba(0,0,0,.05)"; - - switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - drawHighlightForRectWithRotation(context, element, elementsMap, padding); - break; - case "diamond": - drawHighlightForDiamondWithRotation( - context, - padding, - element, - elementsMap, - ); - break; - case "ellipse": { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - - context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = padding - FIXED_BINDING_DISTANCE; - - strokeEllipseWithRotation( - context, - width + padding + FIXED_BINDING_DISTANCE, - height + padding + FIXED_BINDING_DISTANCE, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - } - } -}; - -const renderBindingHighlightForSuggestedPointBinding = ( - context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], -) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; - - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - zoom, - ); - - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - elementsMap, - ); - fillCircle(context, x, y, threshold, true); - }); -}; - type ElementSelectionBorder = { angle: number; x1: number; @@ -336,23 +247,6 @@ const renderSelectionBorder = ( context.restore(); }; -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, - elementsMap: ElementsMap, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom); - - context.restore(); -}; - const renderFrameHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -813,19 +707,6 @@ const _renderInteractiveScene = ({ } } - if (appState.isBindingEnabled) { - appState.suggestedBindings - .filter((binding) => binding != null) - .forEach((suggestedBinding) => { - renderBindingHighlight( - context, - appState, - suggestedBinding!, - elementsMap, - ); - }); - } - if (appState.frameToHighlight) { renderFrameHighlight( context, @@ -891,7 +772,11 @@ const _renderInteractiveScene = ({ } // Paint selected elements - if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) { + if ( + !appState.multiElement && + !appState.newElement && + !appState.selectedLinearElement?.isEditing + ) { const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); const isSingleLinearElementSelected = diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index a7fe59644..62e0cb6cc 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": [ @@ -981,7 +982,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -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, @@ -1172,7 +1174,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Added to library", @@ -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, @@ -1384,7 +1387,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -1713,7 +1717,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2042,7 +2047,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -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, @@ -2252,7 +2258,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2493,7 +2500,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2794,7 +2802,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3159,7 +3168,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -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, @@ -3650,7 +3660,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3971,7 +3982,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -4295,7 +4307,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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": [ @@ -5578,7 +5591,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -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": [ @@ -6795,7 +6809,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -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": [ @@ -7724,7 +7739,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -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": [ @@ -8721,7 +8737,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -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": [ @@ -9713,7 +9730,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index dbe5e3858..833c00422 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, @@ -100,7 +101,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -118,7 +119,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -137,7 +138,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 13, + "version": 2, "width": 100, "x": -100, "y": -50, @@ -148,7 +149,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -167,7 +168,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -182,19 +183,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id15", - "fixedPoint": [ - "0.50000", - 1, - ], - "focus": 0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "99.19972", + "height": 0, "id": "id4", "index": "a2", "isDeleted": false, @@ -208,8 +201,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.40611", - "99.19972", + 100, + 0, ], ], "roughness": 1, @@ -223,239 +216,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 35, - "width": "98.40611", - "x": 1, - "y": 0, + "version": 6, + "width": 100, + "x": 0, + "y": 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] element 3 1`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 50, - "id": "id15", - "index": "a3", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 10, - "width": 50, - "x": 100, - "y": 100, -} -`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `4`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `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": 12, - }, - "inserted": { - "version": 11, - }, - }, - "id1": { - "deleted": { - "boundElements": [], - "version": 9, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 8, - }, - }, - "id15": { - "deleted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 9, - }, - "inserted": { - "boundElements": [], - "version": 8, - }, - }, - "id4": { - "deleted": { - "endBinding": { - "elementId": "id15", - "fixedPoint": [ - "0.50000", - 1, - ], - "focus": 0, - "gap": 1, - }, - "height": "68.58402", - "points": [ - [ - 0, - 0, - ], - [ - 98, - "68.58402", - ], - ], - "startBinding": { - "elementId": "id0", - "focus": "0.02970", - "gap": 1, - }, - "version": 33, - }, - "inserted": { - "endBinding": { - "elementId": "id1", - "focus": "-0.02000", - "gap": 1, - }, - "height": "0.00656", - "points": [ - [ - 0, - 0, - ], - [ - "98.00000", - "-0.00656", - ], - ], - "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": 13, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 12, - }, - }, - "id15": { - "deleted": { - "version": 10, - }, - "inserted": { - "version": 9, - }, - }, - "id4": { - "deleted": { - "height": "99.19972", - "points": [ - [ - 0, - 0, - ], - [ - "98.40611", - "99.19972", - ], - ], - "startBinding": null, - "version": 35, - "y": 0, - }, - "inserted": { - "height": "68.58402", - "points": [ - [ - 0, - 0, - ], - [ - 98, - "68.58402", - ], - ], - "startBinding": { - "elementId": "id0", - "focus": "0.02970", - "gap": 1, - }, - "version": 33, - "y": "35.82151", - }, - }, - }, - }, - "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`] = ` [ @@ -611,6 +383,98 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": 10, + "points": [ + [ + 0, + 0, + ], + [ + 100, + -10, + ], + ], + "version": 5, + "y": 10, + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "version": 4, + "y": 0, + }, + }, + }, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "version": 6, + }, + "inserted": { + "height": 10, + "points": [ + [ + 0, + 0, + ], + [ + 100, + -10, + ], + ], + "version": 5, + }, + }, + }, + }, + "id": "id12", + }, ] `; @@ -625,6 +489,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -714,7 +579,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -732,7 +597,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -751,9 +616,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 14, + "version": 2, "width": 100, - "x": 150, + "x": -100, "y": -50, } `; @@ -762,7 +627,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -781,9 +646,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 2, "width": 100, - "x": 150, + "x": 100, "y": -50, } `; @@ -814,7 +679,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 0, + 100, 0, ], ], @@ -829,117 +694,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 31, - "width": 0, - "x": 149, - "y": 0, + "version": 6, + "width": 100, + "x": 0, + "y": 10, } `; 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`] = `10`; -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": 13, - }, - "inserted": { - "version": 12, - }, - }, - "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": 14, - }, - "inserted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 13, - }, - }, - "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`] = ` [ @@ -1095,6 +861,98 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": 10, + "points": [ + [ + 0, + 0, + ], + [ + 100, + -10, + ], + ], + "version": 5, + "y": 10, + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "version": 4, + "y": 0, + }, + }, + }, + }, + "id": "id9", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id4": { + "deleted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "version": 6, + }, + "inserted": { + "height": 10, + "points": [ + [ + 0, + 0, + ], + [ + 100, + -10, + ], + ], + "version": 5, + }, + }, + }, + }, + "id": "id12", + }, ] `; @@ -1109,6 +967,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1197,7 +1056,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1225,19 +1084,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "30.01725", "id": "id4", "index": "Zz", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1245,8 +1104,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "30.01725", ], ], "roughness": 1, @@ -1258,8 +1117,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1267,9 +1125,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "1.67622", } `; @@ -1433,8 +1291,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "startBinding": { "elementId": "id0", @@ -1442,8 +1299,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "version": 10, }, @@ -1471,6 +1327,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1559,7 +1416,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1587,19 +1444,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "15.64048", "id": "id5", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1607,8 +1464,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "-15.64048", ], ], "roughness": 1, @@ -1620,8 +1477,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1629,9 +1485,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "37.37707", } `; @@ -1737,13 +1593,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "15.64048", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1756,8 +1611,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 90, + "-15.64048", ], ], "roughness": 1, @@ -1769,17 +1624,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": 90, + "x": 5, + "y": "37.37707", }, "inserted": { "isDeleted": true, @@ -1836,6 +1690,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1924,7 +1779,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2097,6 +1952,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2169,9 +2025,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, @@ -2184,7 +2038,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2202,12 +2056,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2226,7 +2075,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 2, "width": 100, "x": -100, "y": -50, @@ -2237,12 +2086,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2261,10 +2105,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 2, "width": 100, - "x": 500, - "y": -500, + "x": 100, + "y": -50, } `; @@ -2276,18 +2120,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id1", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": 0, "id": "id4", "index": "a2", - "isDeleted": false, + "isDeleted": true, "lastCommittedPoint": null, "link": null, "locked": false, @@ -2298,8 +2138,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + 100, + 0, ], ], "roughness": 1, @@ -2307,28 +2147,102 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 5, + "width": 100, + "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": {}, + "selectedLinearElement": null, + }, + "inserted": { + "selectedElementIds": { + "id4": true, + }, + "selectedLinearElement": { + "elementId": "id4", + "isEditing": false, + }, + }, + }, + }, + "elements": { + "added": { + "id4": { + "deleted": { + "isDeleted": true, + "version": 5, + }, + "inserted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": null, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": null, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "version": 4, + "width": 100, + "x": 0, + "y": 0, + }, + }, + }, + "removed": {}, + "updated": {}, + }, + "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`] = ` [ @@ -2409,120 +2323,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id": "id3", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id4": true, - }, - "selectedLinearElement": { - "elementId": "id4", - "isEditing": false, - }, - }, - "inserted": { - "selectedElementIds": {}, - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": { - "id4": { - "deleted": { - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id1", - "focus": -0, - "gap": 1, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": "370.26975", - "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - "498.00000", - "-370.26975", - ], - ], - "roughness": 1, - "roundness": { - "type": 2, - }, - "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", - }, - "inserted": { - "isDeleted": true, - "version": 7, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 5, - }, - "inserted": { - "boundElements": [], - "version": 4, - }, - }, - "id1": { - "deleted": { - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], - "version": 6, - }, - "inserted": { - "boundElements": [], - "version": 5, - }, - }, - }, - }, - "id": "id8", - }, ] `; @@ -2537,6 +2337,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2625,7 +2426,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2838,6 +2639,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2926,7 +2728,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3155,6 +2957,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3243,7 +3046,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3447,6 +3250,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3535,7 +3339,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3731,6 +3535,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3819,7 +3624,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3964,6 +3769,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4052,7 +3858,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4219,6 +4025,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4307,7 +4114,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4488,6 +4295,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4576,7 +4384,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4715,6 +4523,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4803,7 +4612,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4942,6 +4751,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5030,7 +4840,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5187,6 +4997,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5275,7 +5086,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5441,6 +5252,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5529,7 +5341,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5697,6 +5509,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5784,7 +5597,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6024,6 +5837,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6111,7 +5925,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6449,6 +6263,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6539,7 +6354,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6821,6 +6636,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6917,7 +6733,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7131,6 +6947,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7216,7 +7033,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7274,7 +7091,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 9, + "version": 8, "width": 10, "x": 0, "y": 0, @@ -7283,7 +7100,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] redo stack 1`] = `[]`; @@ -7296,9 +7113,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -7348,40 +7170,19 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 8, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 8, + "version": 7, }, }, }, }, - "id": "id13", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id14", + "id": "id10", }, { "appState": AppStateDelta { @@ -7405,7 +7206,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id11", }, { "appState": AppStateDelta { @@ -7429,7 +7230,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id16", + "id": "id12", }, ] `; @@ -7445,6 +7246,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7530,7 +7332,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7673,6 +7475,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7758,7 +7561,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8023,6 +7826,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8108,7 +7912,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8373,6 +8177,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, @@ -8464,7 +8269,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8777,6 +8582,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, @@ -8862,7 +8668,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9062,6 +8868,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, @@ -9149,7 +8956,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9324,6 +9131,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9411,7 +9219,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9587,6 +9395,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9674,7 +9483,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9817,6 +9626,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9905,7 +9715,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10112,6 +9922,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10199,7 +10010,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10269,7 +10080,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 12, "width": 30, "x": 0, "y": 0, @@ -10278,7 +10089,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `14`; +exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `12`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] redo stack 1`] = `[]`; @@ -10291,9 +10102,14 @@ exports[`history > multiplayer undo/redo > should override remotely added points "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -10342,20 +10158,20 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 11, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 11, + "version": 10, }, }, }, "updated": {}, }, - "id": "id10", + "id": "id7", }, { "appState": AppStateDelta { @@ -10397,7 +10213,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points 20, ], ], - "version": 13, + "version": 12, "width": 30, }, "inserted": { @@ -10416,34 +10232,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points 10, ], ], - "version": 12, + "version": 11, "width": 10, }, }, }, }, - "id": "id11", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id12", + "id": "id8", }, ] `; @@ -10459,6 +10254,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10544,7 +10340,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10693,6 +10489,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, @@ -10781,7 +10578,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10869,8 +10666,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -10908,8 +10704,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -10957,8 +10752,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -10995,8 +10789,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -11139,6 +10932,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11226,7 +11020,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11397,6 +11191,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11482,7 +11277,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11630,6 +11425,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, @@ -11717,7 +11513,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11865,6 +11661,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11950,7 +11747,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12266,6 +12063,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, @@ -12356,7 +12154,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12474,6 +12272,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, @@ -12561,7 +12360,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12679,6 +12478,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, @@ -12770,7 +12570,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12978,6 +12778,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, @@ -13066,7 +12867,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13274,6 +13075,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, @@ -13362,7 +13164,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13517,6 +13319,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13604,7 +13407,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13752,6 +13555,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13839,7 +13643,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13987,6 +13791,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14074,7 +13879,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14232,6 +14037,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, @@ -14319,7 +14125,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14561,6 +14367,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14649,7 +14456,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14729,6 +14536,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, @@ -14819,7 +14627,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15011,6 +14819,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, @@ -15099,7 +14908,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15272,6 +15081,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15360,7 +15170,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15423,6 +15233,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15512,7 +15323,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15703,6 +15514,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15791,7 +15603,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15863,6 +15675,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15952,7 +15765,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15975,10 +15788,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id1", "type": "text", }, - { - "id": "id13", - "type": "arrow", - }, ], "customData": undefined, "fillStyle": "solid", @@ -15998,7 +15807,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -16036,7 +15845,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 5, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -16048,12 +15857,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -16072,7 +15876,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -16087,11 +15891,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16109,7 +15909,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -16118,106 +15918,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "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`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id13": true, - }, - "selectedLinearElement": { - "elementId": "id13", - "isEditing": false, - }, - }, - "inserted": { - "selectedElementIds": {}, - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": { - "id13": { - "deleted": { - "isDeleted": false, - "version": 10, - }, - "inserted": { - "isDeleted": true, - "version": 7, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 6, - }, - "inserted": { - "boundElements": [], - "version": 5, - }, - }, - "id1": { - "deleted": { - "version": 5, - }, - "inserted": { - "version": 4, - }, - }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 5, - }, - "inserted": { - "boundElements": [], - "version": 4, - }, - }, - }, - }, - "id": "id18", - }, -] -`; +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`] = `[]`; 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] undo stack 1`] = ` [ @@ -16467,11 +16185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16497,58 +16211,23 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 4, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 4, - }, - "inserted": { - "boundElements": [], "version": 3, }, }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 3, - }, - "inserted": { - "boundElements": [], - "version": 2, - }, - }, }, + "updated": {}, }, "id": "id15", }, @@ -16566,6 +16245,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16655,7 +16335,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -16678,10 +16358,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id1", "type": "text", }, - { - "id": "id13", - "type": "arrow", - }, ], "customData": undefined, "fillStyle": "solid", @@ -16701,7 +16377,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -16739,7 +16415,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, @@ -16751,12 +16427,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -16775,7 +16446,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -16790,11 +16461,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16812,7 +16479,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -16821,26 +16488,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "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`] = `[]`; @@ -17092,11 +16755,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17113,7 +16772,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -17122,68 +16781,25 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 7, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 6, - }, - "inserted": { - "boundElements": [], - "version": 5, - }, - }, - "id1": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 5, - }, - }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 5, - }, - "inserted": { - "boundElements": [], - "version": 4, + "version": 3, }, }, }, + "updated": {}, }, - "id": "id17", + "id": "id15", }, ] `; @@ -17199,6 +16815,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17288,7 +16905,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -17311,10 +16928,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id1", "type": "text", }, - { - "id": "id13", - "type": "arrow", - }, ], "customData": undefined, "fillStyle": "solid", @@ -17334,7 +16947,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 10, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -17372,7 +16985,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "textAlign": "center", "type": "text", "updated": 1, - "version": 10, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17384,12 +16997,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -17408,7 +17016,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -17423,11 +17031,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17445,7 +17049,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -17454,26 +17058,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "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`] = `[]`; @@ -17510,14 +17110,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": { @@ -17549,7 +17149,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "text": "ola", "textAlign": "left", "type": "text", - "version": 8, + "version": 2, "verticalAlign": "top", "width": 100, "x": -200, @@ -17557,7 +17157,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "inserted": { "isDeleted": true, - "version": 7, + "version": 1, }, }, "id2": { @@ -17581,20 +17181,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 { @@ -17614,7 +17214,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id22", + "id": "id7", }, { "appState": AppStateDelta { @@ -17634,7 +17234,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id23", + "id": "id10", }, { "appState": AppStateDelta { @@ -17661,11 +17261,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "text", }, ], - "version": 9, + "version": 3, }, "inserted": { "boundElements": [], - "version": 8, + "version": 2, }, }, "id1": { @@ -17673,7 +17273,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": "id0", "height": 25, "textAlign": "center", - "version": 9, + "version": 4, "verticalAlign": "middle", "width": 30, "x": -65, @@ -17683,7 +17283,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "containerId": null, "height": 100, "textAlign": "left", - "version": 8, + "version": 2, "verticalAlign": "top", "width": 100, "x": -200, @@ -17692,7 +17292,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, }, }, - "id": "id24", + "id": "id12", }, { "appState": AppStateDelta { @@ -17725,11 +17325,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17746,7 +17342,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -17755,68 +17351,25 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 7, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 10, - }, - "inserted": { - "boundElements": [], - "version": 9, - }, - }, - "id1": { - "deleted": { - "version": 10, - }, - "inserted": { - "version": 9, - }, - }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 7, - }, - "inserted": { - "boundElements": [], - "version": 6, + "version": 3, }, }, }, + "updated": {}, }, - "id": "id25", + "id": "id15", }, ] `; @@ -17832,6 +17385,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17899,13 +17453,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": {}, @@ -17919,7 +17475,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -17938,10 +17494,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", @@ -17965,7 +17517,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -18003,7 +17555,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, @@ -18015,12 +17567,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -18039,7 +17586,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 4, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -18054,11 +17601,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -18076,7 +17619,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -18085,95 +17628,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "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": 6, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - "id1": { - "deleted": { - "isDeleted": false, - "version": 6, - }, - "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": 4, - }, - "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`] = ` [ @@ -18423,11 +17895,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -18453,87 +17921,25 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 4, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 4, - }, - "inserted": { - "boundElements": [], "version": 3, }, }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 3, - }, - "inserted": { - "boundElements": [], - "version": 2, - }, - }, }, - }, - "id": "id15", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id0": true, - }, - "selectedLinearElement": null, - }, - "inserted": { - "selectedElementIds": { - "id13": true, - }, - "selectedLinearElement": { - "elementId": "id13", - "isEditing": false, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, "updated": {}, }, - "id": "id18", + "id": "id15", }, ] `; @@ -18549,6 +17955,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18624,8 +18031,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "scrollY": 0, "searchMatches": null, "selectedElementIds": { - "id0": true, - "id2": true, + "id13": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -18639,7 +18045,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -18658,10 +18064,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", @@ -18685,7 +18087,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 3, "width": 100, "x": -100, "y": -50, @@ -18723,7 +18125,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, @@ -18735,12 +18137,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], + "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -18759,7 +18156,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 2, "width": 100, "x": 100, "y": -50, @@ -18774,11 +18171,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -18796,7 +18189,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 100, 0, ], ], @@ -18805,104 +18198,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": 98, - "x": 1, + "version": 4, + "width": 100, + "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": 6, - }, - "inserted": { - "isDeleted": true, - "version": 5, - }, - }, - "id1": { - "deleted": { - "isDeleted": false, - "version": 6, - }, - "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`] = ` [ @@ -19152,11 +18465,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": { - "elementId": "id2", - "focus": -0, - "gap": 1, - }, + "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -19182,108 +18491,26 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": 2, }, "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "focus": 0, - "gap": 1, - }, + "startBinding": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 4, "width": 100, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 5, - }, - }, - }, - "updated": { - "id0": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 4, - }, - "inserted": { - "boundElements": [], "version": 3, }, }, - "id2": { - "deleted": { - "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, - ], - "version": 3, - }, - "inserted": { - "boundElements": [], - "version": 2, - }, - }, }, + "updated": {}, }, "id": "id15", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id0": true, - }, - "selectedLinearElement": null, - }, - "inserted": { - "selectedElementIds": { - "id13": true, - }, - "selectedLinearElement": { - "elementId": "id13", - "isEditing": false, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id18", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id2": true, - }, - }, - "inserted": { - "selectedElementIds": {}, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id21", - }, ] `; @@ -19298,6 +18525,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19388,7 +18616,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -19776,6 +19004,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19868,7 +19097,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20285,6 +19514,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20372,7 +19602,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20742,6 +19972,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20831,7 +20062,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20893,7 +20124,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 14, "width": 20, "x": 0, "y": 0, @@ -20902,7 +20133,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`; -exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `20`; +exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `23`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = `[]`; @@ -20915,9 +20146,14 @@ exports[`history > singleplayer undo/redo > should support linear element creati "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -20966,20 +20202,20 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 12, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 12, + "version": 11, }, }, }, "updated": {}, }, - "id": "id23", + "id": "id20", }, { "appState": AppStateDelta { @@ -21012,7 +20248,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 13, "width": 20, }, "inserted": { @@ -21030,34 +20266,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati 10, ], ], - "version": 13, + "version": 12, "width": 10, }, }, }, }, - "id": "id24", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id25", + "id": "id21", }, { "appState": AppStateDelta { @@ -21081,7 +20296,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id26", + "id": "id22", }, { "appState": AppStateDelta { @@ -21111,7 +20326,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 20, ], ], - "version": 15, + "version": 14, }, "inserted": { "height": 10, @@ -21129,12 +20344,12 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 13, }, }, }, }, - "id": "id27", + "id": "id23", }, { "appState": AppStateDelta { @@ -21158,7 +20373,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id28", + "id": "id24", }, ] `; diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 52614ed5f..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__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index ee3f02490..821f1f6be 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -49,8 +49,8 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 7, + "versionNonce": 400692809, "width": 70, "x": 30, "y": 30, @@ -104,8 +104,8 @@ exports[`multi point mode in linear elements > line 3`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 7, + "versionNonce": 400692809, "width": 70, "x": 30, "y": 30, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index a895eb636..b560c4c91 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, @@ -108,7 +109,7 @@ exports[`given element A and group of elements B and given both are selected whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -534,7 +536,7 @@ exports[`given element A and group of elements B and given both are selected whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -939,7 +942,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -1503,7 +1507,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -1713,7 +1718,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2092,7 +2098,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2333,7 +2340,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2513,7 +2521,7 @@ exports[`regression tests > can drag element that covers another element, while "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -2834,7 +2843,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3089,7 +3099,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3328,7 +3339,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3562,7 +3574,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -3819,7 +3832,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -4130,7 +4144,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -4591,7 +4606,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -4844,7 +4860,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -5145,7 +5162,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -5323,7 +5341,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -5521,7 +5540,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -5916,7 +5936,7 @@ exports[`regression tests > drags selected elements from point inside common bou "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": 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, @@ -6205,7 +6226,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6549,7 +6570,10 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id15": true, }, - "selectedLinearElement": null, + "selectedLinearElement": { + "elementId": "id15", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { @@ -6607,14 +6631,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 5, "width": 50, "x": 310, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 4, }, }, }, @@ -6654,7 +6678,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 7, "width": 80, }, "inserted": { @@ -6673,7 +6697,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 5, "width": 50, }, }, @@ -6681,33 +6705,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "id": "id19", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id15", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id21", - }, { "appState": AppStateDelta { "delta": Delta { "deleted": { "selectedElementIds": { - "id22": true, + "id20": true, }, "selectedLinearElement": null, }, @@ -6725,7 +6728,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id22": { + "id20": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6765,20 +6768,20 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "line", - "version": 6, + "version": 5, "width": 50, "x": 430, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 4, }, }, }, "updated": {}, }, - "id": "id24", + "id": "id22", }, { "appState": AppStateDelta { @@ -6791,7 +6794,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "added": {}, "removed": {}, "updated": { - "id22": { + "id20": { "deleted": { "height": 20, "lastCommittedPoint": [ @@ -6812,7 +6815,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 7, "width": 80, }, "inserted": { @@ -6831,25 +6834,45 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 5, "width": 50, }, }, }, }, + "id": "id24", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": { + "selectedLinearElement": { + "elementId": "id20", + "isEditing": false, + }, + }, + "inserted": { + "selectedLinearElement": null, + }, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": {}, + }, "id": "id26", }, { "appState": AppStateDelta { "delta": Delta { "deleted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, + "selectedElementIds": {}, }, "inserted": { - "selectedLinearElement": null, + "selectedElementIds": { + "id20": true, + }, }, }, }, @@ -6860,26 +6883,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "id": "id28", }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": {}, - }, - "inserted": { - "selectedElementIds": { - "id22": true, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id30", - }, { "appState": AppStateDelta { "delta": Delta { @@ -6888,7 +6891,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "inserted": { "selectedLinearElement": { - "elementId": "id22", + "elementId": "id20", "isEditing": false, }, }, @@ -6897,7 +6900,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id31": { + "id29": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6955,7 +6958,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "updated": {}, }, - "id": "id33", + "id": "id31", }, ] `; @@ -6971,6 +6974,7 @@ exports[`regression tests > given a group of selected elements with an element t "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7064,7 +7068,7 @@ exports[`regression tests > given a group of selected elements with an element t "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7303,6 +7307,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7396,7 +7401,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7580,6 +7585,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7672,7 +7678,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7813,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7905,7 +7912,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8051,6 +8058,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8141,7 +8149,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8229,6 +8237,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8319,7 +8328,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8407,6 +8416,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8497,7 +8507,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8585,6 +8595,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8668,12 +8679,12 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -8690,7 +8701,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -8702,7 +8712,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8813,6 +8823,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8896,12 +8907,12 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -8918,7 +8929,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -8930,7 +8940,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9039,6 +9049,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9127,7 +9138,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9233,6 +9244,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9316,12 +9328,12 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -9338,7 +9350,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9350,7 +9361,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9461,6 +9472,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9551,7 +9563,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9639,6 +9651,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9722,12 +9735,12 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, "isDragging": false, "isEditing": false, "lastUncommittedPoint": null, "pointerDownState": { + "arrowStartIsInside": false, "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, @@ -9744,7 +9757,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9756,7 +9768,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9865,6 +9877,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9955,7 +9968,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10043,6 +10056,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10131,7 +10145,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10237,6 +10251,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10327,7 +10342,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10415,6 +10430,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10513,7 +10529,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10944,6 +10960,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11036,7 +11053,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11222,6 +11239,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11310,7 +11328,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11343,6 +11361,7 @@ exports[`regression tests > shift click on selected element should deselect it o "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11433,7 +11452,7 @@ exports[`regression tests > shift click on selected element should deselect it o "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11541,6 +11560,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11635,7 +11655,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11858,6 +11878,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11954,7 +11975,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12285,6 +12306,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12385,7 +12407,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12923,6 +12945,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13014,7 +13037,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13047,6 +13070,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13139,7 +13163,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13676,6 +13700,7 @@ exports[`regression tests > switches from group of selected elements to another "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13797,7 +13822,7 @@ exports[`regression tests > switches from group of selected elements to another "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14013,6 +14038,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14133,7 +14159,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14275,6 +14301,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14363,7 +14390,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14396,6 +14423,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14486,7 +14514,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14502,31 +14530,10 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`; -exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `19`; +exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `18`; exports[`regression tests > undo/redo drawing an element > [end of test] redo stack 1`] = ` [ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": null, - }, - "inserted": { - "selectedLinearElement": { - "elementId": "id6", - "isEditing": false, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id13", - }, { "appState": AppStateDelta { "delta": Delta { @@ -14555,7 +14562,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 10, ], ], - "version": 9, + "version": 8, "width": 60, }, "inserted": { @@ -14578,13 +14585,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 20, ], ], - "version": 8, + "version": 7, "width": 100, }, }, }, }, - "id": "id14", + "id": "id11", }, { "appState": AppStateDelta { @@ -14593,11 +14600,16 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "selectedElementIds": { "id3": true, }, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id6": true, }, + "selectedLinearElement": { + "elementId": "id6", + "isEditing": false, + }, }, }, }, @@ -14606,7 +14618,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "isDeleted": true, - "version": 10, + "version": 9, }, "inserted": { "angle": 0, @@ -14649,7 +14661,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 8, "width": 60, "x": 130, "y": 10, @@ -14659,7 +14671,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id12", }, ] `; @@ -14770,7 +14782,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st }, "updated": {}, }, - "id": "id17", + "id": "id14", }, ] `; @@ -14786,6 +14798,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "locked": false, "type": "text", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14874,7 +14887,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14907,6 +14920,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14998,7 +15012,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index ed9d5137a..e2e58829a 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1011,7 +1011,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1028,7 +1028,7 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1048,11 +1048,11 @@ describe("history", () => { mouse.clickAt(0, 0); mouse.clickAt(10, 10); mouse.clickAt(20, 20); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1069,10 +1069,10 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(3); + expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` + expect(h.state.selectedLinearElement?.isEditing).toBe(false); // undo `open editor` expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1085,29 +1085,29 @@ describe("history", () => { }), ]); - Keyboard.undo(); - expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); + // Keyboard.undo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(4); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1120,9 +1120,8 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(6); + expect(API.getRedoStack().length).toBe(5); expect(API.getSelectedElements().length).toBe(0); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1136,10 +1135,10 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1150,25 +1149,25 @@ describe("history", () => { }), ]); + // Keyboard.redo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(3); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); + Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); - - Keyboard.redo(); - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` @@ -1185,7 +1184,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1202,7 +1201,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1219,7 +1218,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1579,13 +1578,13 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(rect1.boundElements).toStrictEqual([ { id: text.id, type: "text" }, @@ -1602,13 +1601,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1625,13 +1624,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1656,13 +1655,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1679,13 +1678,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([1, 0.5001]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1734,13 +1733,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: true, }), @@ -1779,13 +1784,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1823,8 +1834,11 @@ describe("history", () => { startBinding: null, endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1858,13 +1872,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1931,13 +1951,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -2288,15 +2314,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, }, ], @@ -2411,10 +2435,9 @@ describe("history", () => { captureUpdate: CaptureUpdateAction.NEVER, }); - Keyboard.undo(); // undo `actionFinalize` Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ points: [ @@ -2428,7 +2451,7 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(3); + expect(API.getRedoStack().length).toBe(2); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: true, @@ -2441,7 +2464,7 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -2454,21 +2477,6 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - points: [ - [0, 0], - [5, 5], - [10, 10], - [15, 15], - [20, 20], - ], - }), - ]); - - Keyboard.redo(); // redo `actionFinalize` - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.elements).toEqual([ expect.objectContaining({ @@ -2968,7 +2976,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement?.isEditing).toBe(false); @@ -2985,11 +2993,11 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(4); + expect(API.getRedoStack().length).toBe(3); expect(h.state.selectedLinearElement).toBeNull(); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -4490,16 +4498,30 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 1); - mouse.moveTo(0, 0); + mouse.moveTo(0, 10); + mouse.moveTo(0, 10); mouse.up(); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 1); - mouse.moveTo(100, 0); + mouse.moveTo(100, 10); + mouse.moveTo(100, 10); mouse.up(); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).startBinding + ?.fixedPoint, + ).not.toEqual([1, 0.5001]); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode, + ).toBe("orbit"); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).endBinding, + ).not.toEqual([1, 0.5001]); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).endBinding?.mode, + ).toBe("orbit"); + expect(h.elements).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -4514,13 +4536,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4533,12 +4561,16 @@ describe("history", () => { expect(h.elements).toEqual([ expect.objectContaining({ id: rect1.id, - boundElements: [], + boundElements: [{ id: arrowId, type: "arrow" }], }), expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: arrowId, - startBinding: null, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: [1, 0.5001], + mode: "inside", + }), endBinding: null, }), ]); @@ -4583,13 +4615,13 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [1, 0.6], + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0, 0.6], + mode: "orbit", }), }), ]), @@ -4602,12 +4634,21 @@ describe("history", () => { expect(h.elements).toEqual([ expect.objectContaining({ id: rect1.id, - boundElements: [], + boundElements: [ + expect.objectContaining({ + id: arrowId, + type: "arrow", + }), + ], }), expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: arrowId, - startBinding: null, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: [1, 0.5001], + mode: "inside", + }), endBinding: null, }), ]); @@ -4626,13 +4667,13 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 1); - mouse.upAt(0, 0); + mouse.moveTo(0, 10); + mouse.upAt(0, 10); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 1); - mouse.upAt(100, 0); + mouse.moveTo(100, 10); + mouse.upAt(100, 10); expect(h.elements).toEqual( expect.arrayContaining([ @@ -4648,13 +4689,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4667,12 +4714,21 @@ describe("history", () => { expect(h.elements).toEqual([ expect.objectContaining({ id: rect1.id, - boundElements: [], + boundElements: [ + expect.objectContaining({ + id: arrowId, + type: "arrow", + }), + ], }), expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: arrowId, - startBinding: null, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: [1, 0.5001], + mode: "inside", + }), endBinding: null, }), ]); @@ -4692,9 +4748,8 @@ describe("history", () => { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }), remoteContainer, @@ -4721,14 +4776,14 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [1, 0.6], + mode: "orbit", }), // rebound with previous rectangle endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0, 0.6], + mode: "orbit", }), }), expect.objectContaining({ @@ -4746,7 +4801,12 @@ describe("history", () => { expect.arrayContaining([ expect.objectContaining({ id: rect1.id, - boundElements: [], + boundElements: [ + expect.objectContaining({ + id: arrowId, + type: "arrow", + }), + ], }), expect.objectContaining({ id: rect2.id, @@ -4754,16 +4814,16 @@ describe("history", () => { }), expect.objectContaining({ id: arrowId, - startBinding: null, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: [1, 0.5001], + mode: "inside", + }), endBinding: expect.objectContaining({ // now we are back in the previous state! elementId: remoteContainer.id, - fixedPoint: [ - expect.toBeNonNaNNumber(), - expect.toBeNonNaNNumber(), - ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.5, 1], + mode: "orbit", }), }), expect.objectContaining({ @@ -4781,15 +4841,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", }, }); @@ -4843,8 +4901,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ // now we are back in the previous state! @@ -4853,8 +4910,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), }), expect.objectContaining({ @@ -4890,15 +4946,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, { @@ -4925,8 +4979,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4934,8 +4987,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: true, }), @@ -4965,8 +5017,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }, endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4974,8 +5025,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: false, }), @@ -5018,13 +5068,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, }), @@ -5066,13 +5114,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 1c9b7a53a..f95938afb 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -105,9 +105,8 @@ describe("library", () => { type: "arrow", endBinding: { elementId: "rectangle1", - focus: -1, - gap: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }); diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 095db38a0..0417090bc 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,16 +1,12 @@ import React from "react"; import { vi } from "vitest"; - -import { bindOrUnbindLinearElement } from "@excalidraw/element"; - import { KEYS, reseed } from "@excalidraw/common"; - +import { bindBindingElement } from "@excalidraw/element"; import "@excalidraw/utils/test-utils"; import type { - ExcalidrawLinearElement, + ExcalidrawArrowElement, NonDeleted, - ExcalidrawRectangleElement, } from "@excalidraw/element/types"; import { Excalidraw } from "../index"; @@ -83,12 +79,21 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 }); + act(() => { // bind line to two rectangles - bindOrUnbindLinearElement( - arrow.get() as NonDeleted, - rectA.get() as ExcalidrawRectangleElement, - rectB.get() as ExcalidrawRectangleElement, + bindBindingElement( + arrow.get() as NonDeleted, + rectA.get(), + "orbit", + "start", + h.app.scene, + ); + bindBindingElement( + arrow.get() as NonDeleted, + rectB.get(), + "orbit", + "end", h.app.scene, ); }); @@ -97,7 +102,7 @@ describe("move element", () => { new Pointer("mouse").clickOn(rectB); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `15`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`); expect(h.state.selectionElement).toBeNull(); @@ -105,8 +110,8 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]); - expect([arrow.x, arrow.y]).toEqual([110, 50]); - expect([arrow.width, arrow.height]).toEqual([80, 80]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[105, 45]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[90, 90]], 0); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); @@ -124,8 +129,8 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([201, 2]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[105, 45]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[91, 91]], 0); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index d4b5185ba..929ee797f 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -363,7 +363,6 @@ describe("regression tests", () => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z); - Keyboard.keyPress(KEYS.Z); }); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2); Keyboard.withModifierKeys({ ctrl: true }, () => { diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 38079db8f..dfd20767f 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -35,8 +35,8 @@ test("unselected bound arrow updates when rotating its target element", async () expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.x).toBeCloseTo(-80); expect(arrow.y).toBeCloseTo(50); - expect(arrow.width).toBeCloseTo(110.7, 1); - expect(arrow.height).toBeCloseTo(0); + expect(arrow.width).toBeCloseTo(81.75, 1); + expect(arrow.height).toBeCloseTo(62.3, 1); }); test("unselected bound arrows update when rotating their target elements", async () => { @@ -72,13 +72,13 @@ test("unselected bound arrows update when rotating their target elements", async expect(ellipseArrow.x).toEqual(0); expect(ellipseArrow.y).toEqual(0); expect(ellipseArrow.points[0]).toEqual([0, 0]); - expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1); - expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1); + expect(ellipseArrow.points[1][0]).toBeCloseTo(16.52, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(216.57, 1); expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.x).toEqual(360); expect(textArrow.y).toEqual(300); expect(textArrow.points[0]).toEqual([0, 0]); - expect(textArrow.points[1][0]).toBeCloseTo(-94, 0); - expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0); + expect(textArrow.points[1][0]).toBeCloseTo(-63, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-146, 0); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 10f4f7ad9..19e3b9a48 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -487,7 +487,12 @@ describe("tool locking & selection", () => { expect(h.state.activeTool.locked).toBe(true); for (const { value } of Object.values(SHAPES)) { - if (value !== "image" && value !== "selection" && value !== "eraser") { + if ( + value !== "image" && + value !== "selection" && + value !== "eraser" && + value !== "arrow" + ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 5f62999e0..8a6704548 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -5,8 +5,6 @@ import type { MIME_TYPES, } from "@excalidraw/common"; -import type { SuggestedBinding } from "@excalidraw/element"; - import type { LinearElementEditor } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element"; @@ -33,6 +31,7 @@ import type { ExcalidrawIframeLikeElement, OrderedExcalidrawElement, ExcalidrawNonSelectionElement, + BindMode, } from "@excalidraw/element/types"; import type { @@ -204,6 +203,7 @@ export type StaticCanvasAppState = Readonly< frameRendering: AppState["frameRendering"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; hoveredElementIds: AppState["hoveredElementIds"]; + suggestedBinding: AppState["suggestedBinding"]; // Cropping croppingElementId: AppState["croppingElementId"]; } @@ -217,8 +217,9 @@ export type InteractiveCanvasAppState = Readonly< selectedGroupIds: AppState["selectedGroupIds"]; selectedLinearElement: AppState["selectedLinearElement"]; multiElement: AppState["multiElement"]; + newElement: AppState["newElement"]; isBindingEnabled: AppState["isBindingEnabled"]; - suggestedBindings: AppState["suggestedBindings"]; + suggestedBinding: AppState["suggestedBinding"]; isRotating: AppState["isRotating"]; elementsToHighlight: AppState["elementsToHighlight"]; // Collaborators @@ -292,7 +293,7 @@ export interface AppState { selectionElement: NonDeletedExcalidrawElement | null; isBindingEnabled: boolean; startBoundElement: NonDeleted | null; - suggestedBindings: SuggestedBinding[]; + suggestedBinding: NonDeleted | null; frameToHighlight: NonDeleted | null; frameRendering: { enabled: boolean; @@ -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: BindMode; } export type SearchMatch = { @@ -458,7 +460,7 @@ export type SearchMatch = { export type UIAppState = Omit< AppState, - | "suggestedBindings" + | "suggestedBinding" | "startBoundElement" | "cursorButton" | "scrollX" diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index fa11abd46..7be0f7224 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -21,20 +21,9 @@ export function curve( return [a, b, c, d] as Curve; } -function gradient( - f: (t: number, s: number) => number, - t0: number, - s0: number, - delta: number = 1e-6, -): number[] { - return [ - (f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta), - (f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta), - ]; -} - -function solve( - f: (t: number, s: number) => [number, number], +function solveWithAnalyticalJacobian( + curve: Curve, + lineSegment: LineSegment, t0: number, s0: number, tolerance: number = 1e-3, @@ -48,33 +37,75 @@ function solve( return null; } - const y0 = f(t0, s0); - const jacobian = [ - gradient((t, s) => f(t, s)[0], t0, s0), - gradient((t, s) => f(t, s)[1], t0, s0), - ]; - const b = [[-y0[0]], [-y0[1]]]; - const det = - jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0]; + // Compute bezier point at parameter t0 + const bt = 1 - t0; + const bt2 = bt * bt; + const bt3 = bt2 * bt; + const t0_2 = t0 * t0; + const t0_3 = t0_2 * t0; - if (det === 0) { + const bezierX = + bt3 * curve[0][0] + + 3 * bt2 * t0 * curve[1][0] + + 3 * bt * t0_2 * curve[2][0] + + t0_3 * curve[3][0]; + const bezierY = + bt3 * curve[0][1] + + 3 * bt2 * t0 * curve[1][1] + + 3 * bt * t0_2 * curve[2][1] + + t0_3 * curve[3][1]; + + // Compute line point at parameter s0 + const lineX = + lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]); + const lineY = + lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]); + + // Function values + const fx = bezierX - lineX; + const fy = bezierY - lineY; + + error = Math.abs(fx) + Math.abs(fy); + + if (error < tolerance) { + break; + } + + // Analytical derivatives + const dfx_dt = + -3 * bt2 * curve[0][0] + + 3 * bt2 * curve[1][0] - + 6 * bt * t0 * curve[1][0] - + 3 * t0_2 * curve[2][0] + + 6 * bt * t0 * curve[2][0] + + 3 * t0_2 * curve[3][0]; + + const dfy_dt = + -3 * bt2 * curve[0][1] + + 3 * bt2 * curve[1][1] - + 6 * bt * t0 * curve[1][1] - + 3 * t0_2 * curve[2][1] + + 6 * bt * t0 * curve[2][1] + + 3 * t0_2 * curve[3][1]; + + // Line derivatives + const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]); + const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]); + + // Jacobian determinant + const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt; + + if (Math.abs(det) < 1e-12) { return null; } - const iJ = [ - [jacobian[1][1] / det, -jacobian[0][1] / det], - [-jacobian[1][0] / det, jacobian[0][0] / det], - ]; - const h = [ - [iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]], - [iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]], - ]; + // Newton step + const invDet = 1 / det; + const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy); + const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy); - t0 = t0 + h[0][0]; - s0 = s0 + h[1][0]; - - const [tErr, sErr] = f(t0, s0); - error = Math.max(Math.abs(tErr), Math.abs(sErr)); + t0 += dt; + s0 += ds; iter += 1; } @@ -96,63 +127,49 @@ export const bezierEquation = ( t ** 3 * c[3][1], ); +const initial_guesses: [number, number][] = [ + [0.5, 0], + [0.2, 0], + [0.8, 0], +]; + +const calculate = ( + [t0, s0]: [number, number], + l: LineSegment, + c: Curve, +) => { + const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 3); + + if (!solution) { + return null; + } + + const [t, s] = solution; + + if (t < 0 || t > 1 || s < 0 || s > 1) { + return null; + } + + return bezierEquation(c, t); +}; + /** * Computes the intersection between a cubic spline and a line segment. */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, >(c: Curve, l: LineSegment): Point[] { - const line = (s: number) => - pointFrom( - l[0][0] + s * (l[1][0] - l[0][0]), - l[0][1] + s * (l[1][1] - l[0][1]), - ); - - const initial_guesses: [number, number][] = [ - [0.5, 0], - [0.2, 0], - [0.8, 0], - ]; - - const calculate = ([t0, s0]: [number, number]) => { - const solution = solve( - (t: number, s: number) => { - const bezier_point = bezierEquation(c, t); - const line_point = line(s); - - return [ - bezier_point[0] - line_point[0], - bezier_point[1] - line_point[1], - ]; - }, - t0, - s0, - ); - - if (!solution) { - return null; - } - - const [t, s] = solution; - - if (t < 0 || t > 1 || s < 0 || s > 1) { - return null; - } - - return bezierEquation(c, t); - }; - - let solution = calculate(initial_guesses[0]); + let solution = calculate(initial_guesses[0], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1]); + solution = calculate(initial_guesses[1], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2]); + solution = calculate(initial_guesses[2], l, c); if (solution) { return [solution]; } diff --git a/packages/math/tests/curve.test.ts b/packages/math/tests/curve.test.ts index 739562096..0d1f3001d 100644 --- a/packages/math/tests/curve.test.ts +++ b/packages/math/tests/curve.test.ts @@ -46,9 +46,11 @@ describe("Math curve", () => { pointFrom(10, 50), pointFrom(50, 50), ); - const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0)); + const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60)); - expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]); + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [9.99, 5.05], + ]); }); it("can be detected where the determinant is overly precise", () => { diff --git a/packages/utils/src/test-utils.ts b/packages/utils/src/test-utils.ts index 1dfd14cac..966a589ab 100644 --- a/packages/utils/src/test-utils.ts +++ b/packages/utils/src/test-utils.ts @@ -6,11 +6,11 @@ expect.extend({ throw new Error("expected and received are not point arrays"); } - const COMPARE = 1 / Math.pow(10, precision || 2); + const COMPARE = 1 / precision === 0 ? 1 : Math.pow(10, precision ?? 2); const pass = expected.every( (point, idx) => - Math.abs(received[idx]?.[0] - point[0]) < COMPARE && - Math.abs(received[idx]?.[1] - point[1]) < COMPARE, + Math.abs(received[idx][0] - point[0]) < COMPARE && + Math.abs(received[idx][1] - point[1]) < COMPARE, ); if (!pass) { diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 1c89411dd..b2840d4e3 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, @@ -100,7 +101,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null,