diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 71b0133076..63e4c86dc7 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -15,7 +15,6 @@ import { getGlobalFixedPointForBindableElement, isArrowElement, isBindableElement, - isFixedPointBinding, } from "@excalidraw/element"; import { @@ -35,7 +34,6 @@ import type { ExcalidrawBindableElement, FixedPointBinding, OrderedExcalidrawElement, - PointBinding, } from "@excalidraw/element/types"; import { STORAGE_KEYS } from "../app_constants"; @@ -44,7 +42,7 @@ const renderLine = ( context: CanvasRenderingContext2D, zoom: number, segment: LineSegment, - color: string, + color: string ) => { context.save(); context.strokeStyle = color; @@ -59,7 +57,7 @@ const renderCubicBezier = ( context: CanvasRenderingContext2D, zoom: number, [start, control1, control2, end]: Curve, - color: string, + color: string ) => { context.save(); context.strokeStyle = color; @@ -71,7 +69,7 @@ const renderCubicBezier = ( control2[0] * zoom, control2[1] * zoom, end[0] * zoom, - end[1] * zoom, + end[1] * zoom ); context.stroke(); context.restore(); @@ -91,92 +89,88 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { const _renderBinding = ( context: CanvasRenderingContext2D, - binding: FixedPointBinding | PointBinding, + binding: FixedPointBinding, elementsMap: ElementsMap, zoom: number, width: number, height: number, - color: string, + color: string ) => { - if (isFixedPointBinding(binding)) { - 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(); + 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 | PointBinding, + binding: FixedPointBinding, context: CanvasRenderingContext2D, elementsMap: ElementsMap, zoom: number, width: number, height: number, - color: string, + color: string ) => { - if (isFixedPointBinding(binding)) { - 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 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, + zoom: number ) => { const elementsMap = arrayToMap(elements); const dim = 16; @@ -197,12 +191,12 @@ const renderBindings = ( _renderBinding( context, - element.startBinding as FixedPointBinding, + element.startBinding, elementsMap, zoom, dim, dim, - "red", + element.startBinding?.mode === "orbit" ? "red" : "black" ); } @@ -221,7 +215,7 @@ const renderBindings = ( zoom, dim, dim, - "red", + element.endBinding?.mode === "orbit" ? "red" : "black" ); } } @@ -233,7 +227,7 @@ const renderBindings = ( } const arrow = elementsMap.get( - boundElement.id, + boundElement.id ) as ExcalidrawArrowElement; if (arrow && arrow.startBinding?.elementId === element.id) { @@ -244,7 +238,7 @@ const renderBindings = ( zoom, dim, dim, - "green", + "green" ); } if (arrow && arrow.endBinding?.elementId === element.id) { @@ -255,7 +249,7 @@ const renderBindings = ( zoom, dim, dim, - "green", + "green" ); } }); @@ -266,7 +260,7 @@ const renderBindings = ( const render = ( frame: DebugElement[], context: CanvasRenderingContext2D, - appState: AppState, + appState: AppState ) => { frame.forEach((el: DebugElement) => { switch (true) { @@ -275,7 +269,7 @@ const render = ( context, appState.zoom.value, el.data as LineSegment, - el.color, + el.color ); break; case isCurve(el.data): @@ -283,7 +277,7 @@ const render = ( context, appState.zoom.value, el.data as Curve, - el.color, + el.color ); break; default: @@ -296,11 +290,11 @@ const _debugRenderer = ( canvas: HTMLCanvasElement, appState: AppState, elements: readonly OrderedExcalidrawElement[], - scale: number, + scale: number ) => { const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( canvas, - scale, + scale ); const context = bootstrapCanvas({ @@ -315,7 +309,7 @@ const _debugRenderer = ( context.save(); context.translate( appState.scrollX * appState.zoom.value, - appState.scrollY * appState.zoom.value, + appState.scrollY * appState.zoom.value ); renderOrigin(context, appState.zoom.value); @@ -340,7 +334,7 @@ const _debugRenderer = ( if (window.visualDebug) { window.visualDebug!.data = window.visualDebug?.data.map((frame) => - frame.filter((el) => el.permanent), + frame.filter((el) => el.permanent) ) ?? []; } }; @@ -360,7 +354,7 @@ export const saveDebugState = (debug: { enabled: boolean }) => { try { localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_DEBUG, - JSON.stringify(debug), + JSON.stringify(debug) ); } catch (error: any) { console.error(error); @@ -372,18 +366,18 @@ export const debugRenderer = throttleRAF( canvas: HTMLCanvasElement, appState: AppState, elements: readonly OrderedExcalidrawElement[], - scale: number, + scale: number ) => { _debugRenderer(canvas, appState, elements, scale); }, - { trailing: true }, + { trailing: true } ); export const loadSavedDebugState = () => { let debug; try { const savedDebugState = localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_DEBUG, + STORAGE_KEYS.LOCAL_STORAGE_DEBUG ); if (savedDebugState) { debug = JSON.parse(savedDebugState) as { enabled: boolean }; @@ -523,7 +517,7 @@ const DebugCanvas = React.forwardRef( Debug Canvas ); - }, + } ); export default DebugCanvas; diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a2a930a1ac..13cdf09ac4 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -16,7 +16,6 @@ import { DEFAULT_SIDEBAR, debounce, } from "@excalidraw/common"; -import { clearElementsForLocalStorage } from "@excalidraw/element"; import { createStore, entries, @@ -28,6 +27,7 @@ import { } from "idb-keyval"; import { appJotaiStore, atom } from "excalidraw-app/app-jotai"; +import { getNonDeletedElements } from "@excalidraw/element"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; @@ -88,7 +88,7 @@ const saveDataStateToLocalStorage = ( localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, - JSON.stringify(clearElementsForLocalStorage(elements)), + JSON.stringify(getNonDeletedElements(elements)), ); localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index bc0df4a678..28c166cd74 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -2,7 +2,6 @@ import { clearAppStateForLocalStorage, getDefaultAppState, } from "@excalidraw/excalidraw/appState"; -import { clearElementsForLocalStorage } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -50,7 +49,7 @@ export const importFromLocalStorage = () => { let elements: ExcalidrawElement[] = []; if (savedElements) { try { - elements = clearElementsForLocalStorage(JSON.parse(savedElements)); + elements = JSON.parse(savedElements); } catch (error: any) { console.error(error); // Do nothing because elements array is already empty diff --git a/excalidraw-app/sentry.ts b/excalidraw-app/sentry.ts index 30b84f3f69..58e34bba53 100644 --- a/excalidraw-app/sentry.ts +++ b/excalidraw-app/sentry.ts @@ -1,3 +1,4 @@ +import { getFeatureFlag } from "@excalidraw/common"; import * as Sentry from "@sentry/browser"; import callsites from "callsites"; @@ -33,6 +34,7 @@ Sentry.init({ Sentry.captureConsoleIntegration({ levels: ["error"], }), + Sentry.featureFlagsIntegration(), ], beforeSend(event) { if (event.request?.url) { @@ -79,3 +81,14 @@ Sentry.init({ return event; }, }); + +const flagsIntegration = + Sentry.getClient()?.getIntegrationByName( + "FeatureFlags", + ); +if (flagsIntegration) { + flagsIntegration.addFeatureFlag( + "COMPLEX_BINDINGS", + getFeatureFlag("COMPLEX_BINDINGS"), + ); +} diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index b6a451d988..191bec5ac6 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -498,6 +498,8 @@ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const DOUBLE_TAP_POSITION_THRESHOLD = 35; +export const BIND_MODE_TIMEOUT = 700; // ms + // glass background for mobile action buttons export const MOBILE_ACTION_BUTTON_BG = { background: "var(--mobile-action-button-bg)", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 69e854b0b0..f2bb2c47c6 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,10 +1,6 @@ import { average } from "@excalidraw/math"; -import type { - ExcalidrawBindableElement, - FontFamilyValues, - FontString, -} from "@excalidraw/element/types"; +import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { ActiveTool, @@ -558,9 +554,6 @@ export const isTransparent = (color: string) => { ); }; -export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) => - el.fillStyle !== "solid" || isTransparent(el.backgroundColor); - export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] ? (value?: MaybePromise>) => void @@ -1270,3 +1263,47 @@ export const reduceToCommonValue = ( return commonValue; }; + +type FEATURE_FLAGS = { + COMPLEX_BINDINGS: boolean; +}; + +const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags"; +const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = { + COMPLEX_BINDINGS: false, +}; +let featureFlags: FEATURE_FLAGS | null = null; + +export const getFeatureFlag = ( + flag: F, +): FEATURE_FLAGS[F] => { + if (!featureFlags) { + try { + const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY); + if (serializedFlags) { + const flags = JSON.parse(serializedFlags); + featureFlags = flags ?? DEFAULT_FEATURE_FLAGS; + } + } catch {} + } + + return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag]; +}; + +export const setFeatureFlag = ( + flag: F, + value: FEATURE_FLAGS[F], +) => { + try { + featureFlags = { + ...(featureFlags || DEFAULT_FEATURE_FLAGS), + [flag]: value, + }; + localStorage.setItem( + FEATURE_FLAGS_STORAGE_KEY, + JSON.stringify(featureFlags), + ); + } catch (e) { + console.error("unable to set feature flag", e); + } +}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index fa1355309b..f14ee633cd 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,32 +1,27 @@ import { KEYS, arrayToMap, - isBindingFallthroughEnabled, - tupleToCoors, + getFeatureFlag, invariant, - isDevEnv, - isTestEnv, + isTransparent, } from "@excalidraw/common"; import { - lineSegment, - pointFrom, - pointRotateRads, - type GlobalPoint, - vectorFromPoint, - pointDistanceSq, - clamp, - pointDistance, - pointFromVector, - vectorScale, - vectorNormalize, - vectorCross, - pointsEqual, - lineSegmentIntersectionPoints, PRECISION, + clamp, + lineSegment, + pointDistance, + pointDistanceSq, + pointFrom, + pointFromVector, + pointRotateRads, + vectorFromPoint, + vectorNormalize, + vectorScale, + type GlobalPoint, } from "@excalidraw/math"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -37,7 +32,13 @@ import { getCenterForBounds, getElementBounds, } from "./bounds"; -import { intersectElementWithLineSegment } from "./collision"; +import { + getAllHoveredElementAtPoint, + getHoveredElementForBinding, + intersectElementWithLineSegment, + isBindableElementInsideOtherBindable, + isPointInElement, +} from "./collision"; import { distanceToElement } from "./distance"; import { headingForPointFromElement, @@ -53,46 +54,83 @@ import { isBindableElement, isBoundToContainer, isElbowArrow, - isFixedPointBinding, - isFrameLikeElement, - isLinearElement, isRectanguloidElement, isTextElement, } from "./typeChecks"; import { aabbForElement, elementCenterPoint } from "./bounds"; import { updateElbowArrowPoints } from "./elbowArrow"; +import { projectFixedPointOntoDiagonal } from "./utils"; import type { Scene } from "./Scene"; import type { Bounds } from "./bounds"; import type { ElementUpdate } from "./mutateElement"; import type { - ExcalidrawBindableElement, - ExcalidrawElement, - NonDeleted, - ExcalidrawLinearElement, - PointBinding, - NonDeletedExcalidrawElement, + BindMode, ElementsMap, - NonDeletedSceneElementsMap, - ExcalidrawTextElement, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawElbowArrowElement, + ExcalidrawElement, + ExcalidrawTextElement, FixedPoint, FixedPointBinding, + NonDeleted, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, PointsPositionUpdates, } 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 BINDING_HIGHLIGHT_THICKNESS = 10; + +export const getFixedBindingDistance = ( + element: ExcalidrawBindableElement, +): number => FIXED_BINDING_DISTANCE + element.strokeWidth / 2; + +export const maxBindingGap_simple = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, + zoom?: AppState["zoom"], +): number => { + const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; + + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + + return Math.max( + 16, + // bigger bindable boundary for bigger elements + Math.min(0.25 * smallerDimension, 32), + // keep in sync with the zoomed highlight + BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE, + ); +}; export const shouldEnableBindingForPointerEvent = ( event: React.PointerEvent, @@ -104,633 +142,874 @@ 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; + altKey?: boolean; + initialBinding?: boolean; + }, +) => { + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + arrow, + draggingPoints, + scene.getNonDeletedElementsMap(), + scene.getNonDeletedElements(), + appState, + { + ...opts, + finalize: true, + }, + ); + + 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"], + shiftKey?: boolean, +): { + start: BindingStrategy; + end: BindingStrategy; +} => { + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + const isMultiPoint = arrow.points.length > 2; + 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?.initialState.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: isMultiPoint + ? { mode: undefined } + : { + mode: "inside", + element: hit, + focusPoint: origin ?? center, + }, + end: isMultiPoint + ? { mode: "orbit", element: hit, focusPoint: point } + : { mode: "inside", element: hit, focusPoint: point }, + }; + } + + // Check and handle nested shapes + if (hit && arrow.startBinding) { + const startBinding = arrow.startBinding; + const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap); + + if (allHits.find((el) => el.id === startBinding.elementId)) { + const otherElement = elementsMap.get( + arrow.startBinding.elementId, + ) as ExcalidrawBindableElement; + + invariant(otherElement, "Other element must be in the elements map"); + + return { + start: isMultiPoint + ? { mode: undefined } + : { + mode: otherElement.id !== hit.id ? "orbit" : "inside", + element: otherElement, + focusPoint: origin ?? pointFrom(arrow.x, arrow.y), + }, + end: { + mode: "orbit", + element: hit, + focusPoint: point, + }, + }; } } - return null; - }); + // 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 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"; + const otherIsInsideBinding = + !!appState.selectedLinearElement?.initialState.arrowStartIsInside; + const other: BindingStrategy = { + mode: otherIsInsideBinding ? "inside" : "orbit", + element: otherElement, + focusPoint: shiftKey + ? elementCenterPoint(otherElement, elementsMap) + : origin ?? pointFrom(arrow.x, arrow.y), + }; - return [start, end]; -}; + // We are hovering another element with the end point + const isNested = + hit && + isBindableElementInsideOtherBindable(otherElement, hit, elementsMap); + let current: BindingStrategy; + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || globalBindMode === "skip"; + current = { + mode: isInsideBinding && !isNested ? "inside" : "orbit", + element: hit, + focusPoint: isInsideBinding || isNested ? point : point, + }; + } else { + current = { mode: null }; + } -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 { + start: isMultiPoint ? { mode: undefined } : other, + end: current, + }; + } + + // No start binding + if (!arrow.startBinding) { + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || globalBindMode === "skip"; + + end = { + mode: isInsideBinding ? "inside" : "orbit", + element: hit, + focusPoint: point, + }; + } else { + end = { mode: null }; + } + + return { start, end }; + } } - 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]; + invariant(false, "New arrow creation should not reach here"); }; -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[], +const bindingStrategyForSimpleArrowEndpointDragging_complex = ( + point: GlobalPoint, + currentBinding: FixedPointBinding | null, + oppositeBinding: FixedPointBinding | null, elementsMap: NonDeletedSceneElementsMap, - zoom: AppState["zoom"], -): SuggestedBinding[] => { - // HOT PATH: Bail out if selected elements list is too large - if (selectedElements.length > 50) { - return []; - } + elements: readonly Ordered[], + globalBindMode: AppState["bindMode"], + arrow: NonDeleted, + finalize?: boolean, +): { current: BindingStrategy; other: BindingStrategy } => { + let current: BindingStrategy = { mode: undefined }; + let other: BindingStrategy = { mode: undefined }; - return ( - selectedElements - .filter(isLinearElement) - .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), + const isMultiPoint = arrow.points.length > 2; + const hit = getHoveredElementForBinding(point, elements, elementsMap); + const isOverlapping = oppositeBinding + ? getAllHoveredElementAtPoint(point, elements, elementsMap).some( + (el) => el.id === oppositeBinding.elementId, ) - .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, - ) - ); -}; + : false; + const oppositeElement = oppositeBinding + ? (elementsMap.get(oppositeBinding.elementId) as ExcalidrawBindableElement) + : null; + const otherIsTransparent = + isOverlapping && oppositeElement + ? isTransparent(oppositeElement.backgroundColor) + : false; + const isNested = + hit && + oppositeElement && + isBindableElementInsideOtherBindable(oppositeElement, hit, elementsMap); -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); + // 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") { + current = hit + ? { + element: + !isOverlapping || !oppositeElement || otherIsTransparent + ? hit + : oppositeElement, + focusPoint: point, + mode: "inside", } + : { mode: null }; + other = + finalize && hit && hit.id === oppositeBinding?.elementId + ? { mode: null } + : other; - return acc; - }, - new Set() as Set>, - ), - ); + return { current, other }; + } -export const maybeBindLinearElement = ( - linearElement: NonDeleted, + // Dragged point is outside of any bindable element + // so we break any existing binding + if (!hit) { + return { current: { mode: null }, other }; + } + + // Already inside binding over the same hit element should remain inside bound + if ( + hit.id === currentBinding?.elementId && + currentBinding.mode === "inside" + ) { + return { + current: { mode: "inside", focusPoint: point, element: hit }, + other, + }; + } + + // The dragged point is inside the hovered bindable element + if (oppositeBinding) { + // The opposite binding is on the same element + 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: finalize ? null : undefined }; + + return { current, other: isMultiPoint ? { mode: undefined } : 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: isMultiPoint ? { mode: undefined } : other }; + } + } + // The opposite binding is on a different element (or nested) + // eslint-disable-next-line no-else-return + else { + // Handle the nested element case + if (isOverlapping && oppositeElement && !otherIsTransparent) { + current = { + element: oppositeElement, + mode: "inside", + focusPoint: point, + }; + } else { + current = { + element: hit, + mode: "orbit", + focusPoint: isNested ? point : point, + }; + } + + return { current, other: isMultiPoint ? { mode: undefined } : other }; + } + } + // The opposite binding is on a different element or no binding + else { + current = { + element: hit, + mode: "orbit", + focusPoint: point, + }; + } + + // Must return as only one endpoint is dragged, therefore + // the end binding strategy might accidentally gets overriden + return { current, other: isMultiPoint ? { mode: undefined } : other }; +}; + +export const getBindingStrategyForDraggingBindingElementEndpoints = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], appState: AppState, - pointerCoords: { x: number; y: number }, - scene: Scene, -): void => { - const elements = scene.getNonDeletedElements(); - const elementsMap = scene.getNonDeletedElementsMap(); - - if (appState.startBoundElement != null) { - bindLinearElement( - linearElement, - appState.startBoundElement, - "start", - scene, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + altKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + return getBindingStrategyForDraggingBindingElementEndpoints_complex( + arrow, + draggingPoints, + elementsMap, + elements, + appState, + opts, ); } - const hoveredElement = getHoveredElementForBinding( - pointerCoords, - elements, + return getBindingStrategyForDraggingBindingElementEndpoints_simple( + arrow, + draggingPoints, elementsMap, - appState.zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), + elements, + appState, + opts, ); - - if (hoveredElement !== null) { - if ( - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - hoveredElement, - "end", - ) - ) { - bindLinearElement(linearElement, hoveredElement, "end", scene); - } - } }; -const normalizePointBinding = ( - binding: { focus: number; gap: number }, - hoveredElement: ExcalidrawBindableElement, -) => ({ - ...binding, - gap: Math.min( - binding.gap, - maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height), - ), -}); +const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + altKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + const startIdx = 0; + const endIdx = arrow.points.length - 1; + const startDragged = draggingPoints.has(startIdx); + const endDragged = draggingPoints.has(endIdx); -export const bindLinearElement = ( - linearElement: NonDeleted, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", - scene: Scene, -): void => { - if (!isArrowElement(linearElement)) { - return; + 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 }; } - let binding: PointBinding | FixedPointBinding = { - elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - scene.getNonDeletedElementsMap(), - ), - hoveredElement, - ), - }; + // 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 (isElbowArrow(linearElement)) { + // 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 otherBinding = startDragged ? arrow.endBinding : arrow.startBinding; + const localPoint = draggingPoints.get( + startDragged ? startIdx : endIdx, + )?.point; + invariant( + localPoint, + `Local point must be defined for ${ + startDragged ? "start" : "end" + } dragging`, + ); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + const hit = getHoveredElementForBinding( + globalPoint, + elements, + elementsMap, + (e) => maxBindingGap_simple(e, e.width, e.height, appState.zoom), + ); + const pointInElement = hit && isPointInElement(globalPoint, hit, elementsMap); + const otherBindableElement = otherBinding + ? (elementsMap.get( + otherBinding.elementId, + ) as NonDeleted) + : undefined; + const otherFocusPoint = + otherBinding && + otherBindableElement && + getGlobalFixedPointForBindableElement( + otherBinding.fixedPoint, + otherBindableElement, + elementsMap, + ); + // const otherPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + // arrow, + // startDragged ? -1 : 0, + // elementsMap, + // ); + const otherFocusPointIsInElement = + otherBindableElement && + otherFocusPoint && + isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); + + // Handle outside-outside binding to the same element + if (otherBinding && otherBinding.elementId === hit?.id) { + // const [startFixedPoint, endFixedPoint] = getGlobalFixedPoints( + // arrow, + // elementsMap, + // ); + + invariant( + !opts?.newArrow || appState.selectedLinearElement?.initialState.origin, + "appState.selectedLinearElement.initialState.origin must be defined for new arrows", + ); + + return { + start: { + mode: "inside", + element: hit, + focusPoint: startDragged + ? globalPoint + : // NOTE: Can only affect the start point because new arrows always drag the end point + opts?.newArrow + ? appState.selectedLinearElement!.initialState.origin! + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + 0, + elementsMap, + ), // startFixedPoint, + }, + end: { + mode: "inside", + element: hit, + focusPoint: endDragged + ? globalPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + -1, + elementsMap, + ), // endFixedPoint + }, + }; + } + + // Handle special alt key case to inside bind no matter what + if (opts?.altKey) { + return { + start: + startDragged && hit + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : start, + end: + endDragged && hit + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : end, + }; + } + + // Handle normal cases + const current: BindingStrategy = hit + ? pointInElement + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : { + mode: "orbit", + element: hit, + focusPoint: + projectFixedPointOntoDiagonal( + arrow, + globalPoint, + hit, + startDragged ? "start" : "end", + elementsMap, + ) || globalPoint, + } + : { mode: null }; + + const other: BindingStrategy = + otherBindableElement && + !otherFocusPointIsInElement && + appState.selectedLinearElement?.initialState.altFocusPoint + ? { + mode: "orbit", + element: otherBindableElement, + focusPoint: appState.selectedLinearElement.initialState.altFocusPoint, + } + : { mode: undefined }; + + return { + start: startDragged ? current : other, + end: endDragged ? current : other, + }; +}; + +const getBindingStrategyForDraggingBindingElementEndpoints_complex = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + const globalBindMode = appState.bindMode || "orbit"; + const startIdx = 0; + const endIdx = arrow.points.length - 1; + const startDragged = draggingPoints.has(startIdx); + const endDragged = draggingPoints.has(endIdx); + + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + invariant( + arrow.points.length > 1, + "Do not attempt to bind linear elements with a single point", + ); + + // If none of the ends are dragged, we don't change anything + if (!startDragged && !endDragged) { + return { start, end }; + } + + // If both ends are dragged, we don't bind to anything + // and break existing bindings + if (startDragged && endDragged) { + return { start: { mode: null }, end: { mode: null } }; + } + + // If binding is disabled and an endpoint is dragged, + // we actively break the end binding + if (!isBindingEnabled(appState)) { + start = startDragged ? { mode: null } : start; + end = endDragged ? { mode: null } : end; + + return { start, end }; + } + + // Handle simpler elbow arrow binding + if (isElbowArrow(arrow)) { + return bindingStrategyForElbowArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + ); + } + + // 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, + opts?.shiftKey, + ); + + 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_complex( + globalPoint, + arrow.startBinding, + arrow.endBinding, + elementsMap, + elements, + globalBindMode, + arrow, + opts?.finalize, + ); + + 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_complex( + globalPoint, + arrow.endBinding, + arrow.startBinding, + elementsMap, + elements, + globalBindMode, + arrow, + opts?.finalize, + ); + + return { start: other, end: current }; + } + + return { start, end }; +}; + +export const bindOrUnbindBindingElements = ( + selectedArrows: NonDeleted[], + scene: Scene, + appState: AppState, +): void => { + selectedArrows.forEach((arrow) => { + bindOrUnbindBindingElement( + arrow, + new Map(), // No dragging points in this case + scene, + appState, + ); + }); +}; + +export const 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 +1019,6 @@ export const updateBoundElements = ( scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { @@ -748,7 +1026,7 @@ export const updateBoundElements = ( return; } - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -762,7 +1040,7 @@ export const updateBoundElements = ( } boundElementsVisitor(elementsMap, changedElement, (element) => { - if (!isLinearElement(element) || element.isDeleted) { + if (!isArrowElement(element) || element.isDeleted) { return; } @@ -776,32 +1054,14 @@ 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; - let endBounds: Bounds | null = null; - if (startBindingElement && endBindingElement) { - startBounds = getElementBounds(startBindingElement, elementsMap); - 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; } @@ -814,16 +1074,15 @@ export const updateBoundElements = ( isBindableElement(bindableElement) && (bindingProp === "startBinding" || bindingProp === "endBinding") && (changedElement.id === element[bindingProp]?.elementId || - (changedElement.id === + changedElement.id === element[ bindingProp === "startBinding" ? "endBinding" : "startBinding" - ]?.elementId && - !doBoundsIntersect(startBounds, endBounds))) + ]?.elementId) ) { const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + element[bindingProp], bindableElement, elementsMap, ); @@ -843,12 +1102,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 +1117,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 +1134,7 @@ export const updateBindings = ( }; const doesNeedUpdate = ( - boundElement: NonDeleted, + boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, ) => { return ( @@ -900,7 +1156,6 @@ export const getHeadingForElbowArrowSnap = ( aabb: Bounds | undefined | null, origPoint: GlobalPoint, elementsMap: ElementsMap, - zoom?: AppState["zoom"], ): Heading => { const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); @@ -908,12 +1163,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,80 +1176,72 @@ 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, + ignoreFrameCutouts?: boolean, ): 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]; if (!intersection) { @@ -1023,25 +1267,31 @@ export const bindPointToSnapToElementOutline = ( ).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 ( @@ -1052,7 +1302,7 @@ export const bindPointToSnapToElementOutline = ( return edgePoint; } - return elbowed ? intersection : edgePoint; + return intersection; }; export const avoidRectangularCorner = ( @@ -1065,15 +1315,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, ); @@ -1082,18 +1332,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, ); @@ -1104,12 +1357,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, @@ -1117,7 +1370,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, @@ -1130,19 +1383,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, ); @@ -1151,7 +1407,7 @@ export const avoidRectangularCorner = ( return p; }; -export const snapToMid = ( +const snapToMid = ( element: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, @@ -1166,6 +1422,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 && @@ -1173,7 +1434,7 @@ export const snapToMid = ( ) { // LEFT return pointRotateRads( - pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x - getFixedBindingDistance(element), center[1]), center, angle, ); @@ -1184,7 +1445,7 @@ export const snapToMid = ( ) { // TOP return pointRotateRads( - pointFrom(center[0], y - FIXED_BINDING_DISTANCE), + pointFrom(center[0], y - getFixedBindingDistance(element)), center, angle, ); @@ -1195,7 +1456,7 @@ export const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x + width + getFixedBindingDistance(element), center[1]), center, angle, ); @@ -1206,12 +1467,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, @@ -1258,130 +1519,137 @@ export const snapToMid = ( return p; }; -const updateBoundPoint = ( - linearElement: NonDeleted, +const compareElementArea = ( + a: ExcalidrawBindableElement, + b: ExcalidrawBindableElement, +) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); + +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; + const otherBinding = + startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding; + const otherBindableElement = + otherBinding && + (elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement); + const bounds = getElementBounds(bindableElement, elementsMap); + const otherBounds = + otherBindableElement && getElementBounds(otherBindableElement, elementsMap); + const isLargerThanOther = + otherBindableElement && + compareElementArea(bindableElement, otherBindableElement) < + // if both shapes the same size, pretend the other is larger + (startOrEnd === "endBinding" ? 1 : 0); + const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds); - // 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; + // GOAL: If the arrow becomes too short, we want to jump the arrow endpoints + // to the exact focus points on the elements. + // INTUITION: We're not interested in the exacts length of the arrow (which + // will change if we change where we route it), we want to know the length of + // the part which lies outside of both shapes and consider that as a trigger + // to change where we point the arrow. Avoids jumping the arrow in and out + // at every frame. + let arrowTooShort = false; + if ( + !isOverlapping && + arrow.startBinding && + arrow.endBinding && + otherBindableElement + ) { + const startFocusPoint = getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + startOrEnd === "startBinding" ? bindableElement : otherBindableElement, + elementsMap, + ); + const endFocusPoint = getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + startOrEnd === "endBinding" ? bindableElement : otherBindableElement, + elementsMap, + ); + const segment = lineSegment(startFocusPoint, endFocusPoint); + const startIntersection = intersectElementWithLineSegment( + startOrEnd === "endBinding" ? bindableElement : otherBindableElement, + elementsMap, + segment, + 0, + true, + ); + const endIntersection = intersectElementWithLineSegment( + startOrEnd === "startBinding" ? bindableElement : otherBindableElement, + elementsMap, + segment, + 0, + true, + ); + if (startIntersection.length > 0 && endIntersection.length > 0) { + const len = pointDistance(startIntersection[0], endIntersection[0]); + arrowTooShort = len < 40; } } + const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther; + + const maybeOutlineGlobal = + binding.mode === "orbit" && bindableElement + ? isNested + ? global + : 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, ); }; @@ -1424,58 +1692,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 = ( @@ -1591,324 +1844,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", @@ -2218,7 +2153,7 @@ export class BindableElement { } export const getGlobalFixedPointForBindableElement = ( - fixedPointRatio: [number, number], + fixedPointRatio: FixedPoint, element: ExcalidrawBindableElement, elementsMap: ElementsMap, ): GlobalPoint => { @@ -2235,7 +2170,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 cc15947edb..17dc9b1987 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, @@ -34,10 +34,14 @@ import { elementCenterPoint, getCenterForBounds, getCubicBezierCurveBound, + getDiamondPoints, getElementBounds, + pointInsideBounds, } from "./bounds"; import { hasBoundTextElement, + isBindableElement, + isFrameLikeElement, isFreeDrawElement, isIframeLikeElement, isImageElement, @@ -58,12 +62,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 +103,7 @@ export type HitTestArgs = { threshold: number; elementsMap: ElementsMap; frameNameBound?: FrameNameBounds | null; + overrideShouldTestInside?: boolean; }; export const hitElementItself = ({ @@ -102,6 +112,7 @@ export const hitElementItself = ({ threshold, elementsMap, frameNameBound = null, + overrideShouldTestInside = false, }: HitTestArgs) => { // Hit test against a frame's name const hitFrameName = frameNameBound @@ -134,7 +145,9 @@ export const hitElementItself = ({ } // Do the precise (and relatively costly) hit test - const hitElement = shouldTestInside(element) + const hitElement = ( + overrideShouldTestInside ? true : shouldTestInside(element) + ) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" isPointInElement(point, element, elementsMap) || @@ -193,6 +206,116 @@ 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; + } + + // If the element is inside a frame, we should clip the element + if (element.frameId) { + const enclosingFrame = elementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + const enclosingFrameBounds = getElementBounds( + enclosingFrame, + elementsMap, + ); + if (!pointInsideBounds(p, enclosingFrameBounds)) { + 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 getAllHoveredElementAtPoint = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + toleranceFn?: (element: ExcalidrawBindableElement) => number, +): NonDeleted[] => { + 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 (!isTransparent(element.backgroundColor)) { + break; + } + } + } + + return candidateElements; +}; + +export const getHoveredElementForBinding = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + toleranceFn?: (element: ExcalidrawBindableElement) => number, +): NonDeleted | null => { + const candidateElements = getAllHoveredElementAtPoint( + point, + elements, + elementsMap, + toleranceFn, + ); + + 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 * @@ -554,3 +677,61 @@ export const isPointInElement = ( return intersections.length % 2 === 1; }; + +export const isBindableElementInsideOtherBindable = ( + innerElement: ExcalidrawBindableElement, + outerElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, +): boolean => { + // Get corner points of the inner element based on its type + const getCornerPoints = ( + element: ExcalidrawElement, + offset: number, + ): GlobalPoint[] => { + const { x, y, width, height, angle } = element; + const center = elementCenterPoint(element, elementsMap); + + if (element.type === "diamond") { + // Diamond has 4 corner points at the middle of each side + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const corners: GlobalPoint[] = [ + pointFrom(x + topX, y + topY - offset), // top + pointFrom(x + rightX + offset, y + rightY), // right + pointFrom(x + bottomX, y + bottomY + offset), // bottom + pointFrom(x + leftX - offset, y + leftY), // left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + } + if (element.type === "ellipse") { + // For ellipse, test points at the extremes (top, right, bottom, left) + const cx = x + width / 2; + const cy = y + height / 2; + const rx = width / 2; + const ry = height / 2; + const corners: GlobalPoint[] = [ + pointFrom(cx, cy - ry - offset), // top + pointFrom(cx + rx + offset, cy), // right + pointFrom(cx, cy + ry + offset), // bottom + pointFrom(cx - rx - offset, cy), // left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + } + // Rectangle and other rectangular shapes (image, text, etc.) + const corners: GlobalPoint[] = [ + pointFrom(x - offset, y - offset), // top-left + pointFrom(x + width + offset, y - offset), // top-right + pointFrom(x + width + offset, y + height + offset), // bottom-right + pointFrom(x - offset, y + height + offset), // bottom-left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + }; + + const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset + const innerCorners = getCornerPoints(innerElement, offset); + + // Check if all corner points of the inner element are inside the outer element + return innerCorners.every((corner) => + isPointInElement(corner, outerElement, elementsMap), + ); +}; diff --git a/packages/element/src/dragElements.ts b/packages/element/src/dragElements.ts index 4b17ba20c3..9e82953cc9 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 b988eb25bb..d62f328a71 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 6cffb56a83..daa98ed397 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/index.ts b/packages/element/src/index.ts index d677859ad5..a365c517de 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -1,7 +1,6 @@ import { toIterable } from "@excalidraw/common"; import { isInvisiblySmallElement } from "./sizeHelpers"; -import { isLinearElementType } from "./typeChecks"; import type { ExcalidrawElement, @@ -55,27 +54,6 @@ export const isNonDeletedElement = ( element: T, ): element is NonDeleted => !element.isDeleted; -const _clearElements = ( - elements: readonly ExcalidrawElement[], -): ExcalidrawElement[] => - getNonDeletedElements(elements).map((element) => - isLinearElementType(element.type) - ? { ...element, lastCommittedPoint: null } - : element, - ); - -export const clearElementsForDatabase = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - -export const clearElementsForExport = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - -export const clearElementsForLocalStorage = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - export * from "./align"; export * from "./binding"; export * from "./bounds"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 995d866b54..3b0484f2cc 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,16 @@ import { shouldRotateWithDiscreteAngle, getGridPoint, invariant, - tupleToCoors, - viewportCoordsToSceneCoords, + isShallowEqual, + getFeatureFlag, } from "@excalidraw/common"; import { deconstructLinearOrFreeDrawElement, + getHoveredElementForBinding, isPathALoop, + moveArrowAboveBindable, + projectFixedPointOntoDiagonal, type Store, } from "@excalidraw/element"; @@ -40,13 +44,11 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; -import type { Mutable } from "@excalidraw/common/utility-types"; - import { - bindOrUnbindLinearElement, - getHoveredElementForBinding, + calculateFixedPointForNonElbowArrowBinding, + getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, - maybeSuggestBindingsForLinearElementAtCoords, + updateBoundPoint, } from "./binding"; import { getElementAbsoluteCoords, @@ -57,11 +59,7 @@ import { import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { - isBindingElement, - isElbowArrow, - isFixedPointBinding, -} from "./typeChecks"; +import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -76,8 +74,6 @@ import type { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, - PointBinding, - ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, @@ -85,6 +81,9 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, PointsPositionUpdates, + NonDeletedExcalidrawElement, + Ordered, + ExcalidrawBindableElement, } from "./types"; /** @@ -116,6 +115,13 @@ const getNormalizedPoints = ({ }; }; +type PointMoveOtherUpdates = { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + moveMidPointsWithElement?: boolean | null; + suggestedBinding?: AppState["suggestedBinding"] | null; +}; + export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -123,34 +129,36 @@ export class LinearElementEditor { /** indices */ public readonly selectedPointsIndices: readonly number[] | null; - public readonly pointerDownState: Readonly<{ + public readonly initialState: Readonly<{ 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; + altFocusPoint: Readonly | null; }>; /** whether you're dragging a point */ public readonly isDragging: boolean; public readonly lastUncommittedPoint: LocalPoint | null; + public readonly lastCommittedPoint: 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; public readonly customLineAngle: number | null; public readonly isEditing: boolean; + // @deprecated renamed to initialState because the data is used during linear + // element click creation as well (with multiple pointer down events) + // @ts-ignore + public readonly pointerDownState: never; + constructor( element: NonDeleted, elementsMap: ElementsMap, @@ -169,14 +177,12 @@ export class LinearElementEditor { } this.selectedPointsIndices = null; this.lastUncommittedPoint = null; + this.lastCommittedPoint = null; this.isDragging = false; this.pointerOffset = { x: 0, y: 0 }; - this.startBindingElement = "keep"; - this.endBindingElement = "keep"; - this.pointerDownState = { + this.initialState = { prevSelectedPointsIndices: null, lastClickedPoint: -1, - lastClickedIsEndPoint: false, origin: null, segmentMidpoint: { @@ -184,6 +190,8 @@ export class LinearElementEditor { index: null, added: false, }, + arrowStartIsInside: false, + altFocusPoint: null, }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; @@ -276,222 +284,380 @@ 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]); + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + elements, + elementsMap, + ); + + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if ( + shouldRotateWithDiscreteAngle(event) && + !hoveredElement && + !element.startBinding && + !element.endBinding + ) { + 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 + let suggestedBinding: AppState["suggestedBinding"] = null; + const { positions, updates } = pointDraggingUpdates( + [idx], + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + event.shiftKey, + event.altKey, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }); + // Set the suggested binding from the updates if available + if (isBindingElement(element, false)) { + if (isBindingEnabled(app.state)) { + suggestedBinding = updates?.suggestedBinding ?? null; + } + } + + // 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, + ); + } + + // PERF: Avoid state updates if not absolutely necessary + if ( + app.state.selectedLinearElement?.customLineAngle === customLineAngle && + (!suggestedBinding || + isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding)) + ) { + return null; + } + + const startBindingElement = + isBindingElement(element) && + element.startBinding && + (elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement | null); + const newLinearElementEditor = { + ...linearElementEditor, + customLineAngle, + initialState: { + ...linearElementEditor.initialState, + altFocusPoint: + !linearElementEditor.initialState.altFocusPoint && + startBindingElement && + updates?.suggestedBinding?.id !== startBindingElement.id + ? projectFixedPointOntoDiagonal( + element, + pointFrom(element.x, element.y), + startBindingElement, + "start", + elementsMap, + ) + : linearElementEditor.initialState.altFocusPoint, + }, + }; + + 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, initialState } = linearElementEditor; + const selectedPointsIndices = Array.from( + linearElementEditor.selectedPointsIndices ?? [], + ); + let { lastClickedPoint } = initialState; const element = LinearElementEditor.getElement(elementId, elementsMap); - let customLineAngle = linearElementEditor.customLineAngle; - if (!element) { - return null; + + invariant(element, "Element being dragged must exist in the scene"); + + invariant(element.points.length > 1, "Element must have at least 2 points"); + + invariant( + selectedPointsIndices, + "There must be selected points in order to drag them", + ); + + if (elbowed) { + selectedPointsIndices.some((pointIdx, idx) => { + if (pointIdx > 0 && pointIdx !== element.points.length - 1) { + selectedPointsIndices[idx] = element.points.length - 1; + lastClickedPoint = element.points.length - 1; + return true; + } + + return false; + }); } - if ( - isElbowArrow(element) && - !linearElementEditor.pointerDownState.lastClickedIsEndPoint && - linearElementEditor.pointerDownState.lastClickedPoint !== 0 - ) { - return null; - } - - 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( + lastClickedPoint > -1 && + selectedPointsIndices.includes(lastClickedPoint) && + element.points[lastClickedPoint], + `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( + selectedPointsIndices, + )}) points(0..${ + element.points.length - 1 + }) lastClickedPoint(${lastClickedPoint})`, + ); // 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, + ); + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + elements, + elementsMap, + ); - 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 && + !hoveredElement && + !element.startBinding && + !element.endBinding + ) { + 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 { + 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 + let suggestedBinding: AppState["suggestedBinding"] = null; + const { positions, updates } = pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + event.shiftKey, + event.altKey, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }); + + // Set the suggested binding from the updates if available + if (isBindingElement(element, false)) { + if (isBindingEnabled(app.state) && (startIsSelected || endIsSelected)) { + suggestedBinding = updates?.suggestedBinding ?? null; + } + } + + // 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); + } + + // 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 startBindingElement = + isBindingElement(element) && + element.startBinding && + (elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement | null); + const endBindingElement = + isBindingElement(element) && + element.endBinding && + (elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement | null); + const altFocusPointBindableElement = + endIsSelected && // The "other" end (i.e. "end") is dragged + startBindingElement && + updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap + ? startBindingElement + : startIsSelected && // The "other" end (i.e. "start") is dragged + endBindingElement && + updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap + ? endBindingElement + : null; + + const newLinearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: newSelectedPointsIndices, + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: newLastClickedPoint, + altFocusPoint: + !linearElementEditor.initialState.altFocusPoint && // We only set it once per arrow drag + isBindingElement(element) && + altFocusPointBindableElement + ? projectFixedPointOntoDiagonal( + element, + pointFrom(element.x, element.y), + altFocusPointBindableElement, + "start", + elementsMap, + ) + : linearElementEditor.initialState.altFocusPoint, + }, + segmentMidPointHoveredCoords: newSelectedMidPointHoveredCoords, + hoverPointIndex: newHoverPointIndex, + isDragging: true, + customLineAngle, + }; + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; } static handlePointerUp( @@ -501,25 +667,18 @@ 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; + const { + elementId, + selectedPointsIndices, + isDragging, + initialState: pointerDownState, + } = editingLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return editingLinearElement; } - const bindings: Mutable< - Partial< - Pick< - InstanceType, - "startBindingElement" | "endBindingElement" - > - > - > = {}; - if (isDragging && selectedPointsIndices) { for (const selectedPoint of selectedPointsIndices) { if ( @@ -555,36 +714,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 +744,11 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + initialState: { + ...editingLinearElement.initialState, + origin: null, + arrowStartIsInside: false, + }, }; } @@ -853,7 +993,6 @@ export class LinearElementEditor { } { const appState = app.state; const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -871,13 +1010,16 @@ export class LinearElementEditor { if (!element) { return ret; } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, appState, elementsMap, ); + const point = pointFrom(scenePointer.x, scenePointer.y); let segmentMidpointIndex = null; + if (segmentMidpoint) { segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, @@ -904,29 +1046,26 @@ export class LinearElementEditor { store.scheduleCapture(); ret.linearElementEditor = { ...linearElementEditor, - pointerDownState: { + initialState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: -1, - lastClickedIsEndPoint: false, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: point, segmentMidpoint: { value: segmentMidpoint, index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), + altFocusPoint: null, }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding( - scenePointer, - elements, - elementsMap, - app.state.zoom, - linearElementEditor.elbowed, - ), }; ret.didAddPoint = true; + return ret; } @@ -941,21 +1080,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); @@ -984,16 +1108,19 @@ export class LinearElementEditor { : null; ret.linearElementEditor = { ...linearElementEditor, - pointerDownState: { + initialState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: clickedPointIndex, - lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: point, segmentMidpoint: { value: segmentMidpoint, index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), + altFocusPoint: null, }, selectedPointsIndices: nextSelectedPointsIndices, pointerOffset: targetPoint @@ -1020,7 +1147,7 @@ export class LinearElementEditor { return pointsEqual(point1, point2); } - static handlePointerMove( + static handlePointerMoveInEditMode( event: React.PointerEvent, scenePointerX: number, scenePointerY: number, @@ -1055,20 +1182,16 @@ export class LinearElementEditor { let newPoint: LocalPoint; if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { - const lastCommittedPoint = points[points.length - 2]; - + const anchor = points[points.length - 2]; const [width, height] = LinearElementEditor._getShiftLockedDelta( element, elementsMap, - lastCommittedPoint, + anchor, pointFrom(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - newPoint = pointFrom( - width + lastCommittedPoint[0], - height + lastCommittedPoint[1], - ); + newPoint = pointFrom(width + anchor[0], height + anchor[1]); } else { newPoint = LinearElementEditor.createPointAt( element, @@ -1141,7 +1264,6 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, - indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, ): GlobalPoint { @@ -1409,8 +1531,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 +1579,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, @@ -1495,20 +1627,20 @@ export class LinearElementEditor { return false; } - const { segmentMidpoint } = linearElementEditor.pointerDownState; + const { segmentMidpoint } = linearElementEditor.initialState; if ( segmentMidpoint.added || segmentMidpoint.value === null || segmentMidpoint.index === null || - linearElementEditor.pointerDownState.origin === null + linearElementEditor.initialState.origin === null ) { return false; } - const origin = linearElementEditor.pointerDownState.origin!; + const origin = linearElementEditor.initialState.origin!; const dist = pointDistance( - pointFrom(origin.x, origin.y), + origin, pointFrom(pointerCoords.x, pointerCoords.y), ); if ( @@ -1535,12 +1667,12 @@ export class LinearElementEditor { if (!element) { return; } - const { segmentMidpoint } = linearElementEditor.pointerDownState; + const { segmentMidpoint } = linearElementEditor.initialState; const ret: { - pointerDownState: LinearElementEditor["pointerDownState"]; + pointerDownState: LinearElementEditor["initialState"]; selectedPointsIndices: LinearElementEditor["selectedPointsIndices"]; } = { - pointerDownState: linearElementEditor.pointerDownState, + pointerDownState: linearElementEditor.initialState, selectedPointsIndices: linearElementEditor.selectedPointsIndices, }; @@ -1560,9 +1692,9 @@ export class LinearElementEditor { scene.mutateElement(element, { points }); ret.pointerDownState = { - ...linearElementEditor.pointerDownState, + ...linearElementEditor.initialState, segmentMidpoint: { - ...linearElementEditor.pointerDownState.segmentMidpoint, + ...linearElementEditor.initialState.segmentMidpoint, added: true, }, lastClickedPoint: segmentMidpoint.index!, @@ -1578,8 +1710,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 +1726,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 +2010,10 @@ export class LinearElementEditor { x: number, y: number, scene: Scene, - ): LinearElementEditor { + ): Pick< + LinearElementEditor, + "segmentMidPointHoveredCoords" | "initialState" + > { const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement( linearElement.elementId, @@ -1948,8 +2075,8 @@ export class LinearElementEditor { return { ...linearElement, segmentMidPointHoveredCoords: point, - pointerDownState: { - ...linearElement.pointerDownState, + initialState: { + ...linearElement.initialState, segmentMidpoint: { added: false, index: element.fixedSegments![offset].index, @@ -1984,3 +2111,296 @@ 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, + shiftKey: boolean, + altKey: boolean, +): { + 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, + shiftKey, + altKey, + }, + ); + + // Generate the next bindings for the arrow + const updates: PointMoveOtherUpdates = { + suggestedBinding: null, + }; + 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 ( + startIsDragged && + (updates.startBinding.mode === "orbit" || + !getFeatureFlag("COMPLEX_BINDINGS")) + ) { + updates.suggestedBinding = start.element; + } + } else if (startIsDragged) { + updates.suggestedBinding = app.state.suggestedBinding; + } + + 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, + ), + }; + + if ( + endIsDragged && + (updates.endBinding.mode === "orbit" || + !getFeatureFlag("COMPLEX_BINDINGS")) + ) { + updates.suggestedBinding = end.element; + } + } else if (endIsDragged) { + updates.suggestedBinding = app.state.suggestedBinding; + } + + // 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 === undefined + ? element.startBinding + : updates.startBinding === null + ? null + : updates.startBinding, + endBinding: + updates.endBinding === undefined + ? element.endBinding + : updates.endBinding === null + ? null + : updates.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; + + // Needed to handle a special case where an existing arrow is dragged over + // the same element it is bound to on the other side + const startIsDraggingOverEndElement = + element.endBinding && + nextArrow.startBinding && + startIsDragged && + nextArrow.startBinding.elementId === element.endBinding.elementId; + const endIsDraggingOverStartElement = + element.startBinding && + nextArrow.endBinding && + endIsDragged && + element.startBinding.elementId === nextArrow.endBinding.elementId; + + // 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 = startIsDraggingOverEndElement + ? nextArrow.points[nextArrow.points.length - 1] + : endIsDraggingOverStartElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[0] + : 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 = + endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[0] + : startIsDraggingOverEndElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[nextArrow.points.length - 1] + : startBindable + ? updateBoundPoint( + nextArrow, + "startBinding", + nextArrow.startBinding, + startBindable, + elementsMap, + customIntersector, + ) || nextArrow.points[0] + : nextArrow.points[0]; + + const endChanged = + pointDistance( + endLocalPoint, + nextArrow.points[nextArrow.points.length - 1], + ) !== 0; + const startChanged = + pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; + + const indicesSet = new Set(selectedPointsIndices); + if (startBindable && startChanged) { + indicesSet.add(0); + } + if (endBindable && endChanged) { + indicesSet.add(element.points.length - 1); + } + const indices = Array.from(indicesSet); + + return { + updates: + updates.startBinding || updates.suggestedBinding + ? { + startBinding: updates.startBinding, + suggestedBinding: updates.suggestedBinding, + } + : 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 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 0fc3e0bb8f..c45c6df08c 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/newElement.ts b/packages/element/src/newElement.ts index 69ccaf595f..ec50a81ff2 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -452,7 +452,6 @@ export const newFreeDrawElement = ( points: opts.points || [], pressures: opts.pressures || [], simulatePressure: opts.simulatePressure, - lastCommittedPoint: null, }; }; @@ -466,7 +465,7 @@ export const newLinearElement = ( const element = { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, + startBinding: null, endBinding: null, startArrowhead: null, @@ -501,7 +500,6 @@ export const newArrowElement = ( return { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: opts.startArrowhead || null, @@ -516,7 +514,6 @@ export const newArrowElement = ( return { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: opts.startArrowhead || null, diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 8c17863ee0..6a49d4202f 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -98,7 +98,7 @@ const isPendingImageElement = ( const shouldResetImageFilter = ( element: ExcalidrawElement, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { return ( appState.theme === THEME.DARK && @@ -225,7 +225,7 @@ const generateElementCanvas = ( elementsMap: NonDeletedSceneElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ): ExcalidrawElementWithCanvas | null => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; @@ -277,7 +277,7 @@ const generateElementCanvas = ( context.filter = IMAGE_INVERT_FILTER; } - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); @@ -412,7 +412,6 @@ const drawElementOnCanvas = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, ) => { switch (element.type) { case "rectangle": @@ -558,7 +557,7 @@ const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { const zoom: Zoom = renderConfig ? appState.zoom @@ -615,7 +614,7 @@ const drawElementFromCanvas = ( elementWithCanvas: ExcalidrawElementWithCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, allElementsMap: NonDeletedSceneElementsMap, ) => { const element = elementWithCanvas.element; @@ -733,7 +732,7 @@ export const renderElement = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { const reduceAlphaForSelection = appState.openDialog?.name === "elementLinkSelector" && @@ -803,7 +802,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( @@ -896,13 +895,7 @@ export const renderElement = ( tempCanvasContext.translate(-shiftX, -shiftY); - drawElementOnCanvas( - element, - tempRc, - tempCanvasContext, - renderConfig, - appState, - ); + drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); tempCanvasContext.translate(shiftX, shiftY); @@ -941,7 +934,7 @@ export const renderElement = ( } context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); } context.restore(); @@ -1122,7 +1115,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) { smoothing: 0.5, streamline: 0.5, easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine - last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup + last: true, }; return getStroke(inputPoints as number[][], options) as [number, number][]; diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index 8cfd807855..bb9094a5d2 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/typeChecks.ts b/packages/element/src/typeChecks.ts index ab7a1935f5..f328ee947c 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 c2becd3e6c..8067342a20 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; @@ -322,9 +321,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ 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 +349,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 @@ -379,7 +377,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & points: readonly LocalPoint[]; pressures: readonly number[]; simulatePressure: boolean; - lastCommittedPoint: LocalPoint | null; }>; export type FileId = string & { _brand: "FileId" }; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 44b0fe79c6..673dfefd16 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -1,6 +1,8 @@ import { + debugDrawLine, DEFAULT_ADAPTIVE_RADIUS, DEFAULT_PROPORTIONAL_RADIUS, + invariant, LINE_CONFIRM_THRESHOLD, ROUNDNESS, } from "@excalidraw/common"; @@ -10,10 +12,15 @@ import { curveCatmullRomCubicApproxPoints, curveOffsetPoints, lineSegment, + lineSegmentIntersectionPoints, pointDistance, pointFrom, pointFromArray, + pointFromVector, + pointRotateRads, rectangle, + vectorFromPoint, + vectorScale, type GlobalPoint, } from "@excalidraw/math"; @@ -21,11 +28,17 @@ import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; -import { getDiamondPoints } from "./bounds"; +import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; +import { isPointInElement } from "./collision"; +import { LinearElementEditor } from "./linearElementEditor"; +import { isRectangularElement } from "./typeChecks"; + import type { + ElementsMap, + ExcalidrawArrowElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -400,20 +413,10 @@ export function deconstructDiamondElement( ), // TOP ]; - const corners = - offset > 0 - ? baseCorners.map( - (corner) => - curveCatmullRomCubicApproxPoints( - curveOffsetPoints(corner, offset), - )!, - ) - : [ - [baseCorners[0]], - [baseCorners[1]], - [baseCorners[2]], - [baseCorners[3]], - ]; + const corners = baseCorners.map( + (corner) => + curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!, + ); const sides = [ lineSegment( @@ -481,3 +484,124 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => { return 0; }; + +const getDiagonalsForBindableElement = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +) => { + const center = elementCenterPoint(element, elementsMap); + const diagonalOne = isRectangularElement(element) + ? lineSegment( + pointRotateRads( + pointFrom(element.x, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height, + ), + center, + element.angle, + ), + ) + : lineSegment( + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + center, + element.angle, + ), + ); + const diagonalTwo = isRectangularElement(element) + ? lineSegment( + pointRotateRads( + pointFrom(element.x + element.width, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom(element.x, element.y + element.height), + center, + element.angle, + ), + ) + : lineSegment( + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + center, + element.angle, + ), + ); + + return [diagonalOne, diagonalTwo]; +}; + +export const projectFixedPointOntoDiagonal = ( + arrow: ExcalidrawArrowElement, + point: GlobalPoint, + element: ExcalidrawElement, + startOrEnd: "start" | "end", + elementsMap: ElementsMap, +): GlobalPoint | null => { + invariant(arrow.points.length >= 2, "Arrow must have at least two points"); + if (arrow.width < 3 && arrow.height < 3) { + return null; + } + + const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( + element, + elementsMap, + ); + + const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "start" ? 1 : arrow.points.length - 2, + elementsMap, + ); + const b = pointFromVector( + vectorScale( + vectorFromPoint(point, a), + 2 * pointDistance(a, point) + + Math.max( + pointDistance(diagonalOne[0], diagonalOne[1]), + pointDistance(diagonalTwo[0], diagonalTwo[1]), + ), + ), + a, + ); + const intersector = lineSegment(point, b); + const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); + const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); + const d1 = p1 && pointDistance(a, p1); + const d2 = p2 && pointDistance(a, p2); + + let p = null; + if (d1 != null && d2 != null) { + p = d1 < d2 ? p1 : p2; + } else { + p = p1 || p2 || null; + } + + debugDrawLine(diagonalOne, { color: "purple", permanent: false }); + debugDrawLine(diagonalTwo, { color: "purple", permanent: false }); + debugDrawLine(intersector, { color: "orange", permanent: false }); + + return p && isPointInElement(p, element, elementsMap) ? p : null; +}; diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index fed9378253..0bb0cda9c2 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/binding.test.tsx b/packages/element/tests/binding.test.tsx index 8690439782..4d9c2f5e22 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -8,9 +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 { LinearElementEditor } from "@excalidraw/element"; +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; import { getTransformHandles } from "../src/transformHandles"; import { @@ -18,459 +22,669 @@ 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(85.75985931287957); + expect(arrow.height).toBeCloseTo(85.75985931288186); + + // 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(234); + expect(arrow.height).toBeCloseTo(117); + + // 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); + }); }); - // 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("additional binding behavior", () => { + 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: 5, + size: 70, + }); - // 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: 190, + y: 250, + width: 220, + height: 1, + points: [pointFrom(0, 0), pointFrom(110, 0), pointFrom(220, 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(190, 250); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(410, 251); + mouse.clickAt(410, 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: 190, + y: 250, + width: 217, + 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 [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - arrow, - -1, - h.scene.getNonDeletedElementsMap(), - ); - Keyboard.keyDown(KEYS.CTRL_OR_CMD); - 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: 65, + }); + + 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: "¡olá!", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 65, + }); + + 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 10b9346a6c..b8c5bede27 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", }, }); @@ -821,7 +814,7 @@ describe("duplication z-order", () => { const arrow = UI.createElement("arrow", { x: -100, y: 50, - width: 95, + width: 115, height: 0, }); diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index b279e596c2..7eece3d2b3 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 { @@ -136,6 +131,11 @@ describe("elbow arrow segment move", () => { }); describe("elbow arrow routing", () => { + beforeEach(async () => { + localStorage.clear(); + await render(); + }); + it("can properly generate orthogonal arrow points", () => { const scene = new Scene(); const arrow = API.createElement({ @@ -160,8 +160,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,25 +185,23 @@ 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)], }); expect(arrow.points).toEqual([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [44, 0], + [44, 200], + [88, 200], ]); }); }); @@ -242,9 +240,9 @@ describe("elbow arrow ui", () => { expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -255,9 +253,9 @@ describe("elbow arrow ui", () => { expect(arrow.elbowed).toBe(true); expect(arrow.points).toEqual([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [44, 0], + [44, 200], + [88, 200], ]); }); @@ -279,9 +277,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -297,9 +295,11 @@ describe("elbow arrow ui", () => { expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ [0, 0], - [35, 0], - [35, 165], - [103, 165], + [36, 0], + [36, 90], + [28, 90], + [28, 164], + [101, 164], ]); }); @@ -321,9 +321,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -353,9 +353,9 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.elbowed).toBe(true); expect(duplicatedArrow.points).toEqual([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [44, 0], + [44, 200], + [88, 200], ]); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); @@ -379,9 +379,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -408,8 +408,8 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.points).toEqual([ [0, 0], [0, 100], - [90, 100], - [90, 200], + [88, 100], + [88, 200], ]); }); }); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index f1306b8728..be5ff26d95 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -217,7 +217,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -329,7 +329,7 @@ describe("Test Linear Elements", () => { expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.selectedLinearElement).toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); await getTextEditor(); }); @@ -357,6 +357,7 @@ describe("Test Linear Elements", () => { const originalY = line.y; enterLineEditingMode(line); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); @@ -379,7 +380,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 +550,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 +601,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 +642,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 +690,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 +748,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 +846,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, @@ -1303,7 +1304,7 @@ describe("Test Linear Elements", () => { const arrow = UI.createElement("arrow", { x: -10, y: 250, - width: 400, + width: 410, height: 1, }); @@ -1316,7 +1317,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(404); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1335,7 +1336,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(204); 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 1d0b6ac0b2..039d519120 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", { @@ -510,12 +510,12 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize(rectangle, "se", [-200, -150]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); }); @@ -538,11 +538,11 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize([rectangle, arrow], "nw", [300, 350]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); }); }); @@ -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(66.3157); + expect(boundArrow.points[1][1]).toBeCloseTo(-88.421); 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 b8f837b402..f9c57a2851 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, }; @@ -466,7 +466,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 @@ -474,7 +474,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 d9b011d2bc..8d5ed2a30a 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 cfc5e69e21..9821abadc8 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -212,12 +212,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, @@ -254,19 +250,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, @@ -279,7 +262,6 @@ export const actionDeleteSelected = register({ ...appState, selectedLinearElement: { ...appState.selectedLinearElement, - ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 ? [selectedPointsIndices[0] - 1] @@ -308,6 +290,7 @@ export const actionDeleteSelected = register({ type: app.state.preferredSelectionTool.type, }), multiElement: null, + newElement: null, activeEmbeddable: null, selectedLinearElement: null, }, diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index cf7a58a98a..e47a5bb84c 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 4e7ae67919..97d4f5655a 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,48 @@ 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, + altKey: event.altKey, + }); + } 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 +134,8 @@ 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, - }); - } + const activeToolLocked = appState.activeTool?.locked; return { elements: @@ -134,23 +146,31 @@ export const actionFinalize = register({ } return el; }) - : undefined, + : newElements, appState: { ...appState, cursorButton: "up", - selectedLinearElement: new LinearElementEditor( - element, - arrayToMap(elementsMap), - false, // exit editing mode - ), + selectedLinearElement: activeToolLocked + ? null + : { + ...linearElementEditor, + selectedPointsIndices: null, + isEditing: false, + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: -1, + }, + }, + selectionElement: null, + suggestedBinding: null, + newElement: null, + multiElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } } - let newElements = elements; - if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -174,8 +194,14 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if (appState.multiElement && element.type !== "freedraw") { - const { points, lastCommittedPoint } = element; + if ( + appState.selectedLinearElement && + appState.multiElement && + element.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { + const { points } = element; + const { lastCommittedPoint } = appState.selectedLinearElement; if ( !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint @@ -227,25 +253,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 +278,25 @@ 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, + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: -1, + origin: null, + }, + } + : selectedLinearElement; + return { elements: newElements, appState: { @@ -288,7 +314,7 @@ export const actionFinalize = register({ multiElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: element && !appState.activeTool.locked && @@ -298,11 +324,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 23e4ffc123..0be29c9800 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", @@ -74,11 +72,11 @@ describe("flipping re-centers selection", () => { const rec1 = h.elements.find((el) => el.id === "rec1")!; expect(rec1.x).toBeCloseTo(100, 0); - expect(rec1.y).toBeCloseTo(100, 0); + expect(rec1.y).toBeCloseTo(101, 0); const rec2 = h.elements.find((el) => el.id === "rec2")!; expect(rec2.x).toBeCloseTo(220, 0); - expect(rec2.y).toBeCloseTo(250, 0); + expect(rec2.y).toBeCloseTo(251, 0); }); }); @@ -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 6456fca8d5..b7e15275d8 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 27f0d6024c..02dcecef50 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 c356456ac1..4206de0074 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 { @@ -20,12 +21,13 @@ import { getLineHeight, isTransparent, reduceToCommonValue, + invariant, } from "@excalidraw/common"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { - bindLinearElement, + bindBindingElement, calculateFixedPointForElbowArrowBinding, updateBoundElements, } from "@excalidraw/element"; @@ -306,13 +308,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, @@ -330,7 +334,7 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - captureUpdate: !!value.currentItemStrokeColor + captureUpdate: !!value?.currentItemStrokeColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -366,12 +370,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, @@ -451,7 +457,7 @@ export const actionChangeBackgroundColor = register({ }, }); -export const actionChangeFillStyle = register({ +export const actionChangeFillStyle = register({ name: "changeFillStyle", label: "labels.fill", trackEvent: false, @@ -533,7 +539,9 @@ export const actionChangeFillStyle = register({ }, }); -export const actionChangeStrokeWidth = register({ +export const actionChangeStrokeWidth = register< + ExcalidrawElement["strokeWidth"] +>({ name: "changeStrokeWidth", label: "labels.strokeWidth", trackEvent: false, @@ -589,7 +597,7 @@ export const actionChangeStrokeWidth = register({ ), }); -export const actionChangeSloppiness = register({ +export const actionChangeSloppiness = register({ name: "changeSloppiness", label: "labels.sloppiness", trackEvent: false, @@ -643,7 +651,9 @@ export const actionChangeSloppiness = register({ ), }); -export const actionChangeStrokeStyle = register({ +export const actionChangeStrokeStyle = register< + ExcalidrawElement["strokeStyle"] +>({ name: "changeStrokeStyle", label: "labels.strokeStyle", trackEvent: false, @@ -696,7 +706,7 @@ export const actionChangeStrokeStyle = register({ ), }); -export const actionChangeOpacity = register({ +export const actionChangeOpacity = register({ name: "changeOpacity", label: "labels.opacity", trackEvent: false, @@ -720,89 +730,100 @@ 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); - }, - PanelComponent: ({ elements, appState, updateData, app, data }) => { - const { isCompact } = getStylesPanelInfo(app); +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, data }) => { + const { isCompact } = getStylesPanelInfo(app); - return ( -
- {t("labels.fontSize")} -
- { - if (isTextElement(element)) { - return element.fontSize; - } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), + return ( +
+ {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) => { + withCaretPositionPreservation( + () => updateData(value), + isCompact, + !!appState.editingTextElement, + data?.onPreventClose, ); - 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) => { - withCaretPositionPreservation( - () => updateData(value), - isCompact, - !!appState.editingTextElement, - data?.onPreventClose, - ); - }} - /> -
-
- ); + }} + /> +
+
+ ); + }, }, -}); +); export const actionDecreaseFontSize = register({ name: "decreaseFontSize", @@ -862,7 +883,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, @@ -899,6 +923,8 @@ export const actionChangeFontFamily = register({ }; } + invariant(value, "actionChangeFontFamily: value must be defined"); + const { currentItemFontFamily, currentHoveredFontFamily } = value; let nextCaptureUpdateAction: CaptureUpdateActionType = @@ -1241,7 +1267,7 @@ export const actionChangeFontFamily = register({ }, }); -export const actionChangeTextAlign = register({ +export const actionChangeTextAlign = register({ name: "changeTextAlign", label: "Change text alignment", trackEvent: false, @@ -1342,7 +1368,7 @@ export const actionChangeTextAlign = register({ }, }); -export const actionChangeVerticalAlign = register({ +export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", label: "Change vertical alignment", trackEvent: { category: "element" }, @@ -1442,7 +1468,7 @@ export const actionChangeVerticalAlign = register({ }, }); -export const actionChangeRoundness = register({ +export const actionChangeRoundness = register<"sharp" | "round">({ name: "changeRoundness", label: "Change edge roundness", trackEvent: false, @@ -1599,15 +1625,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)) { @@ -1702,7 +1729,7 @@ export const actionChangeArrowProperties = register({ }, }); -export const actionChangeArrowType = register({ +export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", trackEvent: false, @@ -1803,7 +1830,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) { @@ -1811,7 +1844,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 7c841e3aee..8f22810393 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 d533294d39..c85b0639ef 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; @@ -157,7 +157,7 @@ export type PanelComponentProps = { ) => React.JSX.Element | null; }; -export interface Action { +export interface Action { name: ActionName; label: | string @@ -174,7 +174,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 7ec58fec12..087b1b795e 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -100,7 +100,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, @@ -127,6 +127,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + bindMode: "orbit", }; }; @@ -229,7 +230,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 }, @@ -252,6 +253,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 db90022d2b..d807ff5279 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -95,6 +95,9 @@ import { Emitter, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, + BIND_MODE_TIMEOUT, + invariant, + getFeatureFlag, createUserAgentDescriptor, getFormFactor, deriveStylesPanelMode, @@ -110,15 +113,13 @@ import { import { getObservedAppState, getCommonBounds, - maybeSuggestBindingsForLinearElementAtCoords, getElementAbsoluteCoords, - bindOrUnbindLinearElements, + bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, shouldEnableBindingForPointerEvent, updateBoundElements, - getSuggestedBindingsForArrows, LinearElementEditor, newElementWith, newFrameElement, @@ -154,7 +155,6 @@ import { isFlowchartNodeElement, isBindableElement, isTextElement, - getLockedLinearCursorAlignSize, getNormalizedDimensions, isElementCompletelyInViewport, isElementInViewport, @@ -240,9 +240,16 @@ import { StoreDelta, type ApplyToOptions, positionElementsOnGrid, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + mutateElement, + getElementBounds, + doBoundsIntersect, + isPointInElement, + maxBindingGap_simple, } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, @@ -267,6 +274,7 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -593,7 +601,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; @@ -627,6 +634,8 @@ class App extends React.Component { public flowChartCreator: FlowChartCreator = new FlowChartCreator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); + bindModeHandler: ReturnType | null = null; + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -779,6 +788,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 @@ -851,6 +884,320 @@ class App extends React.Component { } } + private handleSkipBindMode() { + if ( + this.state.selectedLinearElement?.initialState && + !this.state.selectedLinearElement.initialState.arrowStartIsInside + ) { + invariant( + this.lastPointerMoveCoords, + "Missing last pointer move coords when changing bind skip mode for arrow start", + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const hoveredElement = getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords.x, + this.lastPointerMoveCoords.y, + ), + this.scene.getNonDeletedElements(), + elementsMap, + ); + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + ); + + if ( + element?.startBinding && + hoveredElement?.id === element.startBinding.elementId + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + initialState: { + ...this.state.selectedLinearElement.initialState, + arrowStartIsInside: true, + }, + }, + }); + } + } + + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // PERF: It's okay since it's a single trigger from a key handler + // or single call from pointer move handler because the bindMode check + // will not pass the second time + flushSync(() => { + this.setState({ + bindMode: "skip", + }); + }); + + if ( + this.lastPointerMoveCoords && + this.state.selectedLinearElement?.selectedPointsIndices && + this.state.selectedLinearElement?.selectedPointsIndices.length + ) { + const { x, y } = this.lastPointerMoveCoords; + 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, + ); + if (newState) { + this.setState(newState); + } + } + } + } + + 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 previousHoveredBindableElement: NonDeletedExcalidrawElement | null = + null; + + private handleDelayedBindModeChange( + arrow: ExcalidrawArrowElement, + hoveredElement: NonDeletedExcalidrawElement | null, + ) { + if (arrow.isDeleted || isElbowArrow(arrow)) { + return; + } + + const effector = () => { + this.bindModeHandler = null; + + invariant( + this.lastPointerMoveCoords, + "Expected lastPointerMoveCoords to be set", + ); + + if (!this.state.multiElement) { + if ( + !this.state.selectedLinearElement || + !this.state.selectedLinearElement.selectedPointsIndices || + !this.state.selectedLinearElement.selectedPointsIndices.length + ) { + return; + } + + 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.initialState.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, + initialState: { + ...this.state.selectedLinearElement.initialState, + 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, + ); + if (newState) { + this.setState(newState); + } + } + }; + + let isOverlapping = false; + if (this.state.selectedLinearElement?.selectedPointsIndices) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + const startElement = startDragged + ? hoveredElement + : arrow.startBinding && elementsMap.get(arrow.startBinding.elementId); + const endElement = endDragged + ? hoveredElement + : arrow.endBinding && elementsMap.get(arrow.endBinding.elementId); + const startBounds = + startElement && getElementBounds(startElement, elementsMap); + const endBounds = endElement && getElementBounds(endElement, elementsMap); + isOverlapping = !!( + startBounds && + endBounds && + startElement.id !== endElement.id && + doBoundsIntersect(startBounds, endBounds) + ); + } + + const startDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes(0); + const endDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes( + arrow.points.length - 1, + ); + const currentBinding = startDragged + ? "startBinding" + : endDragged + ? "endBinding" + : null; + const otherBinding = startDragged + ? "endBinding" + : endDragged + ? "startBinding" + : null; + const isAlreadyInsideBindingToSameElement = + (otherBinding && + arrow[otherBinding]?.mode === "inside" && + arrow[otherBinding]?.elementId === hoveredElement?.id) || + (currentBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id === arrow[currentBinding]?.elementId); + + if ( + currentBinding && + otherBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id !== arrow[currentBinding]?.elementId && + arrow[otherBinding]?.elementId !== arrow[currentBinding]?.elementId + ) { + // Update binding out of place to orbit mode + this.scene.mutateElement( + arrow, + { + [currentBinding]: { + ...arrow[currentBinding], + mode: "orbit", + }, + }, + { + informMutation: false, + isDragging: true, + }, + ); + } + + if ( + !hoveredElement || + (this.previousHoveredBindableElement && + hoveredElement.id !== this.previousHoveredBindableElement.id) + ) { + // 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", + }); + }); + } + + this.previousHoveredBindableElement = null; + } else if ( + !this.bindModeHandler && + (!this.state.newElement || !arrow.startBinding || isOverlapping) && + !isAlreadyInsideBindingToSameElement + ) { + // We are hovering a bindable element + this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); + } + + this.previousHoveredBindableElement = hoveredElement; + } + private cacheEmbeddableRef( element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, @@ -1813,6 +2160,7 @@ class App extends React.Component { /> )} { this.setState({ editingTextElement: null }); } - if ( - this.state.selectedLinearElement && - !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] - ) { - // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once - // we have a single API to update `selectedElementIds` - this.setState({ selectedLinearElement: null }); - } + // if ( + // this.state.selectedLinearElement && + // !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] + // ) { + // // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once + // // we have a single API to update `selectedElementIds` + // this.setState({ selectedLinearElement: null }); + // } this.store.commit(elementsMap, this.state); @@ -4408,6 +4756,11 @@ class App extends React.Component { return; } + // Handle Alt key for bind mode + if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleSkipBindMode(); + } + if (this.actionManager.handleKeyDown(event)) { return; } @@ -4417,6 +4770,10 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ isBindingEnabled: false }); } @@ -4427,14 +4784,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 && @@ -4491,16 +4846,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(); @@ -4705,18 +5050,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) && getFeatureFlag("COMPLEX_BINDINGS")) { + 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) { @@ -4815,7 +5237,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(); @@ -5569,8 +5991,8 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement.pointerDownState, + initialState: { + ...this.state.selectedLinearElement.initialState, segmentMidpoint: { index: nextIndex, value: hitCoords, @@ -5802,6 +6224,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, { @@ -5851,6 +6279,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5888,9 +6318,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) @@ -5942,15 +6369,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 && @@ -5965,46 +6391,37 @@ 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)) { // Hovering with a selected tool or creating new linear element via click // and point const { newElement } = this.state; - if (isBindingElement(newElement, false)) { - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [scenePointer], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } else { - this.maybeSuggestBindingAtCursor(scenePointer, false); + if (!newElement && isBindingEnabled(this.state)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + if (hoveredElement) { + this.setState({ + suggestedBinding: hoveredElement, + }); + } else if (this.state.suggestedBinding) { + this.setState({ + suggestedBinding: null, + }); + } } } - if (this.state.multiElement) { - const { multiElement } = this.state; - const { x: rx, y: ry } = multiElement; - - const { points, lastCommittedPoint } = multiElement; + if (this.state.multiElement && this.state.selectedLinearElement) { + const { multiElement, selectedLinearElement } = this.state; + const { x: rx, y: ry, points } = multiElement; const lastPoint = points[points.length - 1]; + const { lastCommittedPoint } = selectedLinearElement; + setCursorForShape(this.interactiveCanvas, this.state); if (lastPoint === lastCommittedPoint) { @@ -6026,6 +6443,23 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + invariant( + this.state.selectedLinearElement?.initialState, + "initialState must be set", + ); + if (this.state.selectedLinearElement.initialState) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: points[points.length - 1], + selectedPointsIndices: [multiElement.points.length - 1], + initialState: { + ...this.state.selectedLinearElement.initialState, + lastClickedPoint: multiElement.points.length - 1, + }, + }, + }); + } } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); // in this branch, we're inside the commit zone, and no uncommitted @@ -6047,64 +6481,91 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + selectedPointsIndices: + selectedLinearElement.selectedPointsIndices?.includes( + multiElement.points.length, + ) + ? [ + ...selectedLinearElement.selectedPointsIndices.filter( + (idx) => + idx !== multiElement.points.length && + idx !== multiElement.points.length - 1, + ), + multiElement.points.length - 1, + ] + : selectedLinearElement.selectedPointsIndices, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: multiElement.points.length - 1, + }, + }, + }); } 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, + ); + + if (getFeatureFlag("COMPLEX_BINDINGS")) { + 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; } + if (this.state.activeTool.type === "arrow") { + const hit = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + (el) => maxBindingGap_simple(el, el.width, el.height, this.state.zoom), + ); + if ( + hit && + !isPointInElement( + pointFrom(scenePointerX, scenePointerY), + hit, + this.scene.getNonDeletedElementsMap(), + ) + ) { + this.setState({ + suggestedBinding: hit, + }); + } + } + const hasDeselectedButton = Boolean(event.buttons); if ( hasDeselectedButton || @@ -6275,7 +6736,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) ) { @@ -6397,7 +6858,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) ) { @@ -6411,7 +6872,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) ) { @@ -6456,6 +6917,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 @@ -6526,7 +6994,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) @@ -6885,6 +7353,10 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.removePointer(event); this.lastPointerUpEvent = event; @@ -6892,6 +7364,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); @@ -7450,20 +7927,34 @@ class App extends React.Component { if ( (hitElement === null || !someHitElementIsSelected) && !event.shiftKey && - !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements + !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + (!this.state.selectedLinearElement?.isEditing || + (hitElement && + hitElement?.id !== this.state.selectedLinearElement?.elementId)) ) { this.clearSelection(hitElement); } if (this.state.selectedLinearElement?.isEditing) { - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - { - [this.state.selectedLinearElement.elementId]: true, - }, - this.state, - ), - }); + this.setState((prevState) => ({ + selectedLinearElement: prevState.selectedLinearElement + ? { + ...prevState.selectedLinearElement, + isEditing: + !!hitElement && + hitElement.id === + this.state.selectedLinearElement?.elementId, + } + : null, + selectedElementIds: prevState.selectedLinearElement + ? makeNextSelectedElementIds( + { + [prevState.selectedLinearElement.elementId]: true, + }, + this.state, + ) + : makeNextSelectedElementIds({}, prevState), + })); // If we click on something } else if (hitElement != null) { // == deep selection == @@ -7763,16 +8254,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, }); }; @@ -7921,17 +8414,37 @@ class App extends React.Component { elementType: ExcalidrawLinearElement["type"], pointerDownState: PointerDownState, ): void => { + if (event.ctrlKey) { + flushSync(() => { + this.setState({ isBindingEnabled: false }); + }); + } + if (this.state.multiElement) { - const { multiElement } = this.state; + const { multiElement, selectedLinearElement } = this.state; + + invariant( + selectedLinearElement, + "selectedLinearElement is expected to be set", + ); // finalize if completing a loop if ( multiElement.type === "line" && isPathALoop(multiElement.points, this.state.zoom.value) ) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + flushSync(() => { + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: -1, // Disable dragging + }, + }, + }); }); this.actionManager.executeAction(actionFinalize); return; @@ -7940,29 +8453,50 @@ class App extends React.Component { // Elbow arrows cannot be created by putting down points // only the start and end points can be defined if (isElbowArrow(multiElement) && multiElement.points.length > 1) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, }); - this.actionManager.executeAction(actionFinalize); return; } - const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const { x: rx, y: ry } = multiElement; + const { lastCommittedPoint } = selectedLinearElement; + + 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; } @@ -7975,11 +8509,7 @@ class App extends React.Component { prevState, ), })); - // clicking outside commit zone → update reference for last committed - // point - this.scene.mutateElement(multiElement, { - lastCommittedPoint: multiElement.points[multiElement.points.length - 1], - }); + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else { const [gridX, gridY] = getGridPoint( @@ -8051,36 +8581,92 @@ 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 boundElement = getHoveredElementForBinding( - pointerDownState.origin, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - isElbowArrow(element), - isElbowArrow(element), + + const point = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const boundElement = isBindingEnabled(this.state) + ? getHoveredElementForBinding( + point, + this.scene.getNonDeletedElements(), + elementsMap, + ) + : null; + + 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, altKey: event.altKey, initialBinding: true }, + ); + } + + // NOTE: We need the flushSync here for the + // delayed bind mode change to see the right state + // (specifically the `newElement`) + flushSync(() => { + this.setState((prevState) => { + let linearElementEditor = null; + let nextSelectedElementIds = prevState.selectedElementIds; + if (isLinearElement(element)) { + linearElementEditor = new LinearElementEditor( + element, + this.scene.getNonDeletedElementsMap(), + ); + + const endIdx = element.points.length - 1; + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [endIdx], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: endIdx, + origin: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + }, + }; + } + + nextSelectedElementIds = !this.state.activeTool.locked + ? makeNextSelectedElementIds({ [element.id]: true }, prevState) + : prevState.selectedElementIds; + + return { + ...prevState, + bindMode: "orbit", + newElement: element, + startBoundElement: boundElement, + suggestedBinding: boundElement || null, + selectedElementIds: nextSelectedElementIds, + selectedLinearElement: linearElementEditor, + }; + }); }); + + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, boundElement); + } } }; @@ -8283,7 +8869,7 @@ class App extends React.Component { if ( this.state.selectedLinearElement && this.state.selectedLinearElement.elbowed && - this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index + this.state.selectedLinearElement.initialState.segmentMidpoint.index ) { const [gridX, gridY] = getGridPoint( pointerCoords.x, @@ -8292,8 +8878,7 @@ class App extends React.Component { ); let index = - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index; + this.state.selectedLinearElement.initialState.segmentMidpoint.index; if (index < 0) { const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( { @@ -8326,7 +8911,7 @@ class App extends React.Component { selectedLinearElement: { ...this.state.selectedLinearElement, segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, - pointerDownState: ret.pointerDownState, + initialState: ret.initialState, }, }); return; @@ -8373,26 +8958,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; @@ -8436,7 +9001,7 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: ret.pointerDownState, + initialState: ret.pointerDownState, selectedPointsIndices: ret.selectedPointsIndices, segmentMidPointHoveredCoords: null, }, @@ -8446,27 +9011,81 @@ class App extends React.Component { return; } else if ( - linearElementEditor.pointerDownState.segmentMidpoint.value !== null && - !linearElementEditor.pointerDownState.segmentMidpoint.added + linearElementEditor.initialState.segmentMidpoint.value !== null && + !linearElementEditor.initialState.segmentMidpoint.added ) { return; - } + } else if (linearElementEditor.initialState.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 (element?.isDeleted) { + return; + } - this.setState(newState); + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + elementsMap, + ); - return; + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + + if ( + event.altKey && + !this.state.selectedLinearElement?.initialState + ?.arrowStartIsInside && + getFeatureFlag("COMPLEX_BINDINGS") + ) { + this.handleSkipBindMode(); + } + + // Ignore drag requests if the arrow modification already happened + if (linearElementEditor.initialState.lastClickedPoint === -1) { + 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.state.selectedLinearElement?.initialState?.altFocusPoint !== + newState.selectedLinearElement?.initialState?.altFocusPoint + ) { + this.setState(newState); + } + + return; + } } } @@ -8642,13 +9261,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, @@ -8700,19 +9319,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 @@ -8928,58 +9534,40 @@ class App extends React.Component { newElement, }); } - } else if (isLinearElement(newElement)) { + } else if (isLinearElement(newElement) && !newElement.isDeleted) { 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], + initialState: { + ...linearElementEditor.initialState, + 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; @@ -9130,6 +9718,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) { @@ -9161,7 +9751,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); @@ -9213,10 +9802,14 @@ class App extends React.Component { }); } + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ selectedElementsAreBeingDragged: false, + bindMode: "orbit", }); - const elementsMap = this.scene.getNonDeletedElementsMap(); if ( pointerDownState.drag.hasOccurred && @@ -9237,7 +9830,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 !== @@ -9251,10 +9847,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, }); } } @@ -9288,6 +9888,23 @@ class App extends React.Component { sceneCoords, }); } + + if ( + this.state.newElement && + this.state.multiElement && + isLinearElement(this.state.newElement) && + this.state.selectedLinearElement + ) { + const { multiElement } = this.state; + + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + }, + }); + } } this.missingPointerEventCleanupEmitter.clear(); @@ -9339,7 +9956,6 @@ class App extends React.Component { this.scene.mutateElement(newElement, { points: [...points, pointFrom(dx, dy)], pressures, - lastCommittedPoint: pointFrom(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -9348,7 +9964,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( @@ -9394,7 +10014,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 }, ); @@ -9405,16 +10025,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) => ({ @@ -10009,15 +10626,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") { @@ -10035,7 +10646,7 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, activeTool: updateActiveTool(this.state, { type: this.state.preferredSelectionTool.type, }), @@ -10043,7 +10654,7 @@ class App extends React.Component { } else { this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, }); } @@ -10075,6 +10686,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) || @@ -10383,27 +11055,6 @@ class App extends React.Component { } }; - private maybeSuggestBindingAtCursor = ( - pointerCoords: { - x: number; - y: number; - }, - considerAll: boolean, - ): void => { - const hoveredBindableElement = getHoveredElementForBinding( - pointerCoords, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - false, - considerAll, - ); - this.setState({ - suggestedBindings: - hoveredBindableElement != null ? [hoveredBindableElement] : [], - }); - }; - private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds({}, prevState), @@ -10422,6 +11073,7 @@ class App extends React.Component { selectedElementIds: makeNextSelectedElementIds({}, this.state), activeEmbeddable: null, previousSelectedElementIds: this.state.selectedElementIds, + selectedLinearElement: null, }); } @@ -10980,12 +11632,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", @@ -11111,12 +11758,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( @@ -11129,7 +11770,6 @@ class App extends React.Component { this.setState({ elementsToHighlight: [...elementsToHighlight], - suggestedBindings, }); return true; @@ -11442,6 +12082,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11461,6 +12103,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 3e922328b8..0840dd803d 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1034,7 +1034,7 @@ const CommandItem = ({ size="var(--icon-size, 1rem)" icon={ typeof command.icon === "function" - ? command.icon(appState) + ? command.icon(appState, []) : command.icon } /> diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 957d699273..3eed838ce8 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 8e527d5498..596456671c 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/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 1fd255c8fe..29282c3c59 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -1,6 +1,7 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common"; import { + isArrowElement, isFlowchartNodeElement, isImageElement, isLinearElement, @@ -43,6 +44,16 @@ const getHints = ({ const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const multiMode = appState.multiElement !== null; + if ( + appState.selectedLinearElement?.isDragging || + isArrowElement(appState.newElement) + ) { + return t("hints.arrowBindModifiers", { + shortcut_1: getTaggedShortcutKey("Alt"), + shortcut_2: getTaggedShortcutKey("CtrlOrCmd"), + }); + } + if ( appState.openSidebar?.name === DEFAULT_SIDEBAR.name && appState.openSidebar.tab === CANVAS_SEARCH_TAB && diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 2af653b6b6..457069e7d5 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -647,7 +647,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 773f868880..c79e9bb3b1 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 539a2ad59e..4680858dcd 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 19b52e2f49..35f6cfb897 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 f89ce26151..8b57183308 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 bcfab85206..47fcd64bea 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) && ( { mouse.up(200, 100); UI.clickTool("arrow"); - mouse.down(5, 0); + mouse.down(-5, 0); mouse.up(300, 50); elementStats = stats?.querySelector("#elementStats"); @@ -135,18 +135,7 @@ describe("binding with linear elements", () => { ) 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 68d2020987..7628261840 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 1bbf3789c6..05e19b17e8 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -6,6 +6,7 @@ import { sceneCoordsToViewportCoords, type EditorInterface, } from "@excalidraw/common"; +import { AnimationController } from "@excalidraw/excalidraw/renderer/animation"; import type { NonDeletedExcalidrawElement, @@ -13,15 +14,20 @@ import type { } from "@excalidraw/element/types"; import { t } from "../../i18n"; -import { isRenderThrottlingEnabled } from "../../reactUtils"; import { renderInteractiveScene } from "../../renderer/interactiveScene"; import type { InteractiveCanvasRenderConfig, + InteractiveSceneRenderAnimationState, + InteractiveSceneRenderConfig, RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; -import type { AppState, InteractiveCanvasAppState } from "../../types"; +import type { + AppClassProperties, + AppState, + InteractiveCanvasAppState, +} from "../../types"; import type { DOMAttributes } from "react"; type InteractiveCanvasProps = { @@ -37,6 +43,7 @@ type InteractiveCanvasProps = { appState: InteractiveCanvasAppState; renderScrollbars: boolean; editorInterface: EditorInterface; + app: AppClassProperties; renderInteractiveSceneCallback: ( data: RenderInteractiveSceneCallback, ) => void; @@ -71,8 +78,11 @@ type InteractiveCanvasProps = { >; }; +export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene"; + const InteractiveCanvas = (props: InteractiveCanvasProps) => { const isComponentMounted = useRef(false); + const rendererParams = useRef(null as InteractiveSceneRenderConfig | null); useEffect(() => { if (!isComponentMounted.current) { @@ -129,29 +139,63 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { )) || "#6965db"; - renderInteractiveScene( - { - canvas: props.canvas, - elementsMap: props.elementsMap, - visibleElements: props.visibleElements, - selectedElements: props.selectedElements, - allElementsMap: props.allElementsMap, - scale: window.devicePixelRatio, - appState: props.appState, - renderConfig: { - remotePointerViewportCoords, - remotePointerButton, - remoteSelectedElementIds, - remotePointerUsernames, - remotePointerUserStates, - selectionColor, - renderScrollbars: props.renderScrollbars, - }, - editorInterface: props.editorInterface, - callback: props.renderInteractiveSceneCallback, + rendererParams.current = { + app: props.app, + canvas: props.canvas, + elementsMap: props.elementsMap, + visibleElements: props.visibleElements, + selectedElements: props.selectedElements, + allElementsMap: props.allElementsMap, + scale: window.devicePixelRatio, + appState: props.appState, + editorInterface: props.editorInterface, + renderConfig: { + remotePointerViewportCoords, + remotePointerButton, + remoteSelectedElementIds, + remotePointerUsernames, + remotePointerUserStates, + selectionColor, + renderScrollbars: props.renderScrollbars, + // NOTE not memoized on so we don't rerender on cursor move + lastViewportPosition: props.app.lastViewportPosition, }, - isRenderThrottlingEnabled(), - ); + callback: props.renderInteractiveSceneCallback, + animationState: { + bindingHighlight: undefined, + }, + deltaTime: 0, + }; + + if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) { + AnimationController.start( + INTERACTIVE_SCENE_ANIMATION_KEY, + ({ deltaTime, state }) => { + const nextAnimationState = renderInteractiveScene( + { + ...rendererParams.current!, + deltaTime, + animationState: state, + }, + false, + ).animationState; + + if (nextAnimationState) { + for (const key in nextAnimationState) { + if ( + nextAnimationState[ + key as keyof InteractiveSceneRenderAnimationState + ] !== undefined + ) { + return nextAnimationState; + } + } + } + + return undefined; + }, + ); + } }); return ( @@ -202,8 +246,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 @@ -215,6 +260,10 @@ const getRelevantAppStateProps = ( croppingElementId: appState.croppingElementId, searchMatches: appState.searchMatches, activeLockedId: appState.activeLockedId, + hoveredElementIds: appState.hoveredElementIds, + frameRendering: appState.frameRendering, + shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, + exportScale: appState.exportScale, }); const areEqual = ( diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 9e23fa500b..9e6a3324a4 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 f00a51817d..afef25eeff 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, @@ -98,7 +101,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -118,8 +120,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 +149,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, @@ -154,7 +162,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -174,8 +181,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 +344,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, @@ -344,7 +357,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -364,8 +376,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 +451,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, @@ -446,7 +464,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -466,8 +483,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 +632,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, @@ -622,7 +645,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -642,8 +664,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", @@ -839,7 +864,6 @@ exports[`Test Transform > should transform linear elements 1`] = ` "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -887,7 +911,6 @@ exports[`Test Transform > should transform linear elements 2`] = ` "id": Any, "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -934,7 +957,6 @@ exports[`Test Transform > should transform linear elements 3`] = ` "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -982,7 +1004,6 @@ exports[`Test Transform > should transform linear elements 4`] = ` "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1476,8 +1497,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, @@ -1486,7 +1510,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "id": Any, "index": "a4", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1508,8 +1531,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 +1565,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, @@ -1549,7 +1578,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "id": Any, "index": "a5", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1567,8 +1595,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", @@ -1858,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1911,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1964,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -2017,7 +2045,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index e8a5401a7a..4de63d645f 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -7,8 +7,6 @@ import { isPromiseLike, } from "@excalidraw/common"; -import { clearElementsForExport } from "@excalidraw/element"; - import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; @@ -159,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async ( type: MIME_TYPES.excalidraw, data: restore( { - elements: clearElementsForExport(data.elements || []), + elements: data.elements || [], appState: { theme: localAppState?.theme, fileHandle: fileHandle || blob.handle || null, diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index b8fb0f62cc..047a2ccdec 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -6,11 +6,6 @@ import { VERSIONS, } from "@excalidraw/common"; -import { - clearElementsForDatabase, - clearElementsForExport, -} from "@excalidraw/element"; - import type { ExcalidrawElement } from "@excalidraw/element/types"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; @@ -57,10 +52,7 @@ export const serializeAsJSON = ( type: EXPORT_DATA_TYPES.excalidraw, version: VERSIONS.excalidraw, source: getExportSource(), - elements: - type === "local" - ? clearElementsForExport(elements) - : clearElementsForDatabase(elements), + elements, appState: type === "local" ? cleanAppStateForExport(appState) diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57f..0aa5092dbd 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 = < @@ -301,7 +292,6 @@ export const restoreElement = ( case "freedraw": { return restoreElementWithProperties(element, { points: element.points, - lastCommittedPoint: null, simulatePressure: element.simulatePressure, pressures: element.pressures, }); @@ -337,7 +327,6 @@ export const restoreElement = ( : element.type, startBinding: repairBinding(element, element.startBinding), endBinding: repairBinding(element, element.endBinding), - lastCommittedPoint: null, startArrowhead, endArrowhead, points, @@ -370,7 +359,6 @@ export const restoreElement = ( type: element.type, startBinding: repairBinding(element, element.startBinding), endBinding: repairBinding(element, element.endBinding), - lastCommittedPoint: null, startArrowhead, endArrowhead, points, diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0d9fcf3161..b620abfe55 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 fd0d3388ff..5b9f67e652 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 e9b6c3f96c..4d6bbbb6c6 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/locales/en.json b/packages/excalidraw/locales/en.json index 21b3f84d18..d6fd2654bf 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -341,6 +341,7 @@ "canvasPanning": "To move canvas, hold {{shortcut_1}} or {{shortcut_2}} while dragging, or use the hand tool", "linearElement": "Click to start multiple points, drag for single line", "arrowTool": "Click to start multiple points, drag for single line. Press {{shortcut}} again to change arrow type.", + "arrowBindModifiers": "Hold {{shortcut_1}} to bind inside, or {{shortcut_2}} to disable binding", "freeDraw": "Click and drag, release when you're finished", "text": "Tip: you can also add text by double-clicking anywhere with the selection tool", "embeddable": "Click-drag to create a website embed", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 845efc15c8..a5da2c3a31 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/animation.ts b/packages/excalidraw/renderer/animation.ts new file mode 100644 index 0000000000..5c98ac7671 --- /dev/null +++ b/packages/excalidraw/renderer/animation.ts @@ -0,0 +1,84 @@ +import { isRenderThrottlingEnabled } from "../reactUtils"; + +export type Animation = (params: { + deltaTime: number; + state?: R; +}) => R | null | undefined; + +export class AnimationController { + private static isRunning = false; + private static animations = new Map< + string, + { + animation: Animation; + lastTime: number; + state: any; + } + >(); + + static start(key: string, animation: Animation) { + const initialState = animation({ + deltaTime: 0, + state: undefined, + }); + + if (initialState) { + AnimationController.animations.set(key, { + animation, + lastTime: 0, + state: initialState, + }); + + if (!AnimationController.isRunning) { + AnimationController.isRunning = true; + + if (isRenderThrottlingEnabled()) { + requestAnimationFrame(AnimationController.tick); + } else { + setTimeout(AnimationController.tick, 0); + } + } + } + } + + private static tick() { + if (AnimationController.animations.size > 0) { + for (const [key, animation] of AnimationController.animations) { + const now = performance.now(); + const deltaTime = + animation.lastTime === 0 ? 0 : now - animation.lastTime; + + const state = animation.animation({ + deltaTime, + state: animation.state, + }); + + if (!state) { + AnimationController.animations.delete(key); + + if (AnimationController.animations.size === 0) { + AnimationController.isRunning = false; + return; + } + } else { + animation.lastTime = now; + animation.state = state; + } + } + + if (isRenderThrottlingEnabled()) { + requestAnimationFrame(AnimationController.tick); + } else { + setTimeout(AnimationController.tick, 0); + } + } + } + + static running(key: string) { + return AnimationController.animations.has(key); + } + + static cancel(key: string) { + AnimationController.animations.delete(key); + } +} diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index d357822ec6..cfa502dfab 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,164 +76,7 @@ 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 = ( +export const strokeRectWithRotation_simple = ( context: CanvasRenderingContext2D, x: number, y: 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 b948aa8c38..0f18b5f2ca 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,4 +1,5 @@ import { + clamp, pointFrom, pointsEqual, type GlobalPoint, @@ -9,28 +10,30 @@ import oc from "open-color"; import { arrayToMap, + BIND_MODE_TIMEOUT, DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE, + getFeatureFlag, invariant, THEME, throttleRAF, } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element"; -import { LinearElementEditor } from "@excalidraw/element"; import { + deconstructDiamondElement, + deconstructRectanguloidElement, + elementCenterPoint, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, -} from "@excalidraw/element"; -import { isElbowArrow, isFrameLikeElement, isImageElement, isLinearElement, isLineElement, isTextElement, + LinearElementEditor, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -44,11 +47,6 @@ import { import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; -import type { - SuggestedBinding, - SuggestedPointBinding, -} from "@excalidraw/element"; - import type { TransformHandles, TransformHandleType, @@ -64,6 +62,7 @@ import type { ExcalidrawTextElement, GroupId, NonDeleted, + NonDeletedSceneElementsMap, } from "@excalidraw/element/types"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -73,18 +72,19 @@ import { SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { type InteractiveCanvasAppState } from "../types"; + +import { + type AppClassProperties, + type InteractiveCanvasAppState, +} from "../types"; import { getClientColor, renderRemoteCursors } from "../clients"; import { bootstrapCanvas, - drawHighlightForDiamondWithRotation, - drawHighlightForRectWithRotation, fillCircle, getNormalizedCanvasDimensions, - strokeEllipseWithRotation, - strokeRectWithRotation, + strokeRectWithRotation_simple, } from "./helpers"; import type { @@ -188,83 +188,485 @@ const renderSingleLinearPoint = ( ); }; -const renderBindingHighlightForBindableElement = ( +const renderBindingHighlightForBindableElement_simple = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], + appState: InteractiveCanvasAppState, ) => { - const padding = maxBindingGap(element, element.width, element.height, zoom); + const enclosingFrame = element.frameId && elementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + context.translate( + enclosingFrame.x + appState.scrollX, + enclosingFrame.y + appState.scrollY, + ); - context.fillStyle = "rgba(0,0,0,.05)"; + context.beginPath(); + + if (FRAME_STYLE.radius && context.roundRect) { + context.roundRect( + -1, + -1, + enclosingFrame.width + 1, + enclosingFrame.height + 1, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(-1, -1, enclosingFrame.width + 1, enclosingFrame.height + 1); + } + + context.clip(); + + context.translate( + -(enclosingFrame.x + appState.scrollX), + -(enclosingFrame.y + appState.scrollY), + ); + } 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; + case "frame": + context.save(); - 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, + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, ); + + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + if (FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(0, 0, element.width, element.height); + } + + context.restore(); + break; + default: + context.save(); + + const center = elementCenterPoint(element, elementsMap); + + context.translate(center[0], center[1]); + context.rotate(element.angle as Radians); + context.translate(-center[0], -center[1]); + + context.translate(element.x, element.y); + + context.lineWidth = + clamp(2.5, element.strokeWidth * 1.75, 4) / + Math.max(0.25, appState.zoom.value); + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + switch (element.type) { + case "ellipse": + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + element.width / 2, + element.height / 2, + 0, + 0, + 2 * Math.PI, + ); + context.closePath(); + context.stroke(); + break; + case "diamond": + { + const [segments, curves] = deconstructDiamondElement(element); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x, + segment[0][1] - element.y, + ); + context.lineTo( + segment[1][0] - element.x, + segment[1][1] - element.y, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo(start[0] - element.x, start[1] - element.y); + context.bezierCurveTo( + control1[0] - element.x, + control1[1] - element.y, + control2[0] - element.x, + control2[1] - element.y, + end[0] - element.x, + end[1] - element.y, + ); + context.stroke(); + }); + } + + break; + default: + { + const [segments, curves] = deconstructRectanguloidElement(element); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x, + segment[0][1] - element.y, + ); + context.lineTo( + segment[1][0] - element.x, + segment[1][1] - element.y, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo(start[0] - element.x, start[1] - element.y); + context.bezierCurveTo( + control1[0] - element.x, + control1[1] - element.y, + control2[0] - element.x, + control2[1] - element.y, + end[0] - element.x, + end[1] - element.y, + ); + context.stroke(); + }); + } + + break; + } + + context.restore(); + break; - } } }; -const renderBindingHighlightForSuggestedPointBinding = ( +const renderBindingHighlightForBindableElement_complex = ( + app: AppClassProperties, context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], + element: ExcalidrawBindableElement, + allElementsMap: NonDeletedSceneElementsMap, + appState: InteractiveCanvasAppState, + deltaTime: number, + state?: { runtime: number }, ) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; + const countdownInProgress = + app.state.bindMode === "orbit" && app.bindModeHandler !== null; - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - zoom, - ); + const remainingTime = + BIND_MODE_TIMEOUT - + (state?.runtime ?? (countdownInProgress ? 0 : BIND_MODE_TIMEOUT)); + const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1); + const offset = element.strokeWidth / 2; - 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, + const enclosingFrame = element.frameId && allElementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + context.translate( + enclosingFrame.x + appState.scrollX, + enclosingFrame.y + appState.scrollY, ); - fillCircle(context, x, y, threshold, true); - }); + + context.beginPath(); + + if (FRAME_STYLE.radius && context.roundRect) { + context.roundRect( + -1, + -1, + enclosingFrame.width + 1, + enclosingFrame.height + 1, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(-1, -1, enclosingFrame.width + 1, enclosingFrame.height + 1); + } + + context.clip(); + + context.translate( + -(enclosingFrame.x + appState.scrollX), + -(enclosingFrame.y + appState.scrollY), + ); + } + + switch (element.type) { + case "magicframe": + case "frame": + context.save(); + + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity})` + : `rgba(106, 189, 252, ${opacity})`; + + if (FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(0, 0, element.width, element.height); + } + + context.restore(); + break; + default: + context.save(); + + const center = elementCenterPoint(element, allElementsMap); + const cx = center[0] + appState.scrollX; + const cy = center[1] + appState.scrollY; + + context.translate(cx, cy); + context.rotate(element.angle as Radians); + context.translate(-cx, -cy); + + context.translate( + element.x + appState.scrollX - offset, + element.y + appState.scrollY - offset, + ); + + context.lineWidth = + clamp(2.5, element.strokeWidth * 1.75, 4) / + Math.max(0.25, appState.zoom.value); + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity / 2})` + : `rgba(106, 189, 252, ${opacity / 2})`; + + switch (element.type) { + case "ellipse": + context.beginPath(); + context.ellipse( + (element.width + offset * 2) / 2, + (element.height + offset * 2) / 2, + (element.width + offset * 2) / 2, + (element.height + offset * 2) / 2, + 0, + 0, + 2 * Math.PI, + ); + context.closePath(); + context.stroke(); + break; + case "diamond": + { + const [segments, curves] = deconstructDiamondElement( + element, + offset, + ); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x + offset, + segment[0][1] - element.y + offset, + ); + context.lineTo( + segment[1][0] - element.x + offset, + segment[1][1] - element.y + offset, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo( + start[0] - element.x + offset, + start[1] - element.y + offset, + ); + context.bezierCurveTo( + control1[0] - element.x + offset, + control1[1] - element.y + offset, + control2[0] - element.x + offset, + control2[1] - element.y + offset, + end[0] - element.x + offset, + end[1] - element.y + offset, + ); + context.stroke(); + }); + } + + break; + default: + { + const [segments, curves] = deconstructRectanguloidElement( + element, + offset, + ); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x + offset, + segment[0][1] - element.y + offset, + ); + context.lineTo( + segment[1][0] - element.x + offset, + segment[1][1] - element.y + offset, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo( + start[0] - element.x + offset, + start[1] - element.y + offset, + ); + context.bezierCurveTo( + control1[0] - element.x + offset, + control1[1] - element.y + offset, + control2[0] - element.x + offset, + control2[1] - element.y + offset, + end[0] - element.x + offset, + end[1] - element.y + offset, + ); + context.stroke(); + }); + } + + break; + } + + context.restore(); + + break; + } + + // Middle indicator is not rendered after it expired + if (!countdownInProgress || (state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { + return; + } + + const radius = 0.5 * (Math.min(element.width, element.height) / 2); + + // Draw center snap area + if (!isFrameLikeElement(element)) { + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + const PROGRESS_RATIO = (1 / BIND_MODE_TIMEOUT) * remainingTime; + + context.strokeStyle = "rgba(0, 0, 0, 0.2)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]); + context.lineDashOffset = (-PROGRESS_RATIO * 10) / appState.zoom.value; + + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + radius, + radius, + 0, + 0, + 2 * Math.PI, + ); + context.stroke(); + + // context.strokeStyle = "transparent"; + context.fillStyle = "rgba(0, 0, 0, 0.04)"; + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + radius * (1 - opacity), + radius * (1 - opacity), + 0, + 0, + 2 * Math.PI, + ); + + context.fill(); + + context.restore(); + } + + return { + runtime: (state?.runtime ?? 0) + deltaTime, + }; +}; + +const renderBindingHighlightForBindableElement = ( + app: AppClassProperties, + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + allElementsMap: NonDeletedSceneElementsMap, + appState: InteractiveCanvasAppState, + deltaTime: number, + state?: { runtime: number }, +) => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + return renderBindingHighlightForBindableElement_complex( + app, + context, + element, + allElementsMap, + appState, + deltaTime, + state, + ); + } + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderBindingHighlightForBindableElement_simple( + context, + element, + allElementsMap, + appState, + ); + context.restore(); }; type ElementSelectionBorder = { @@ -322,7 +724,7 @@ const renderSelectionBorder = ( ]); } context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1 - linePadding, y1 - linePadding, @@ -336,23 +738,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, @@ -368,7 +753,7 @@ const renderFrameHighlight = ( context.save(); context.translate(appState.scrollX, appState.scrollY); - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1, y1, @@ -581,7 +966,7 @@ const renderTransformHandles = ( context.fill(); context.stroke(); } else { - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x, y, @@ -726,6 +1111,7 @@ const renderTextBox = ( }; const _renderInteractiveScene = ({ + app, canvas, elementsMap, visibleElements, @@ -734,8 +1120,15 @@ const _renderInteractiveScene = ({ scale, appState, renderConfig, + animationState, + deltaTime, editorInterface, -}: InteractiveSceneRenderConfig) => { +}: InteractiveSceneRenderConfig): { + scrollBars?: ReturnType; + atLeastOneVisibleElement: boolean; + elementsMap: RenderableElementsMap; + animationState?: typeof animationState; +} => { if (canvas === null) { return { atLeastOneVisibleElement: false, elementsMap }; } @@ -744,6 +1137,7 @@ const _renderInteractiveScene = ({ canvas, scale, ); + let nextAnimationState = animationState; const context = bootstrapCanvas({ canvas, @@ -813,17 +1207,24 @@ const _renderInteractiveScene = ({ } } - if (appState.isBindingEnabled) { - appState.suggestedBindings - .filter((binding) => binding != null) - .forEach((suggestedBinding) => { - renderBindingHighlight( - context, - appState, - suggestedBinding!, - elementsMap, - ); - }); + if (appState.isBindingEnabled && appState.suggestedBinding) { + nextAnimationState = { + ...animationState, + bindingHighlight: renderBindingHighlightForBindableElement( + app, + context, + appState.suggestedBinding, + allElementsMap, + appState, + deltaTime, + animationState?.bindingHighlight, + ), + }; + } else { + nextAnimationState = { + ...animationState, + bindingHighlight: undefined, + }; } if (appState.frameToHighlight) { @@ -891,7 +1292,11 @@ const _renderInteractiveScene = ({ } // Paint selected elements - if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) { + if ( + !appState.multiElement && + !appState.newElement && + !appState.selectedLinearElement?.isEditing + ) { const showBoundingBox = hasBoundingBox( selectedElements, appState, @@ -1074,7 +1479,7 @@ const _renderInteractiveScene = ({ const lineWidth = context.lineWidth; context.lineWidth = 1 / appState.zoom.value; context.strokeStyle = selectionColor; - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1 - dashedLinePadding, y1 - dashedLinePadding, @@ -1198,6 +1603,7 @@ const _renderInteractiveScene = ({ scrollBars, atLeastOneVisibleElement: visibleElements.length > 0, elementsMap, + animationState: nextAnimationState, }; }; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 01fae229c8..c127a9de35 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -65,6 +65,7 @@ export type InteractiveCanvasRenderConfig = { remotePointerUsernames: Map; remotePointerButton: Map; selectionColor: string; + lastViewportPosition: { x: number; y: number }; // extra options passed to the renderer // --------------------------------------------------------------------------- renderScrollbars?: boolean; @@ -87,7 +88,12 @@ export type StaticSceneRenderConfig = { renderConfig: StaticCanvasRenderConfig; }; +export type InteractiveSceneRenderAnimationState = { + bindingHighlight: { runtime: number } | undefined; +}; + export type InteractiveSceneRenderConfig = { + app: AppClassProperties; canvas: HTMLCanvasElement | null; elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; @@ -98,6 +104,8 @@ export type InteractiveSceneRenderConfig = { renderConfig: InteractiveCanvasRenderConfig; editorInterface: EditorInterface; callback: (data: RenderInteractiveSceneCallback) => void; + animationState?: InteractiveSceneRenderAnimationState; + deltaTime: number; }; export type NewElementSceneRenderConfig = { diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index e4ef367a8a..7ae3b5775f 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": [ @@ -985,7 +986,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, @@ -1086,6 +1087,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, @@ -1180,7 +1182,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", @@ -1302,6 +1304,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1396,7 +1399,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1635,6 +1638,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1729,7 +1733,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, @@ -1968,6 +1972,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2062,7 +2067,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.", @@ -2184,6 +2189,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2276,7 +2282,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2427,6 +2433,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2521,7 +2528,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2727,6 +2734,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2826,7 +2834,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3101,6 +3109,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3195,7 +3204,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.", @@ -3596,6 +3605,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3690,7 +3700,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, @@ -3921,6 +3931,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, @@ -4015,7 +4026,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, @@ -4246,6 +4257,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4343,7 +4355,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4659,6 +4671,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5630,7 +5643,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, @@ -5878,6 +5891,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6851,7 +6865,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, @@ -7148,6 +7162,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7784,7 +7799,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, @@ -7817,6 +7832,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8785,7 +8801,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, @@ -8810,6 +8826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -9781,7 +9798,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__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index c25b269f4b..a538500c25 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -18,7 +18,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -135,7 +134,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 37037e7822..b6d37e2b21 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, @@ -104,7 +105,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, @@ -122,7 +123,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -141,7 +147,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 13, + "version": 7, "width": 100, "x": -100, "y": -50, @@ -152,7 +158,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -171,7 +182,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 6, "width": 100, "x": 100, "y": -50, @@ -187,24 +198,23 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "elbowed": false, "endArrowhead": "arrow", "endBinding": { - "elementId": "id15", + "elementId": "id1", "fixedPoint": [ - "0.50000", - 1, + "-0.06000", + "0.59962", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "99.19972", + "height": "0.56170", "id": "id4", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -212,8 +222,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.40611", - "99.19972", + "88.00000", + "0.56170", ], ], "roughness": 1, @@ -221,16 +231,23 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 35, - "width": "98.40611", - "x": 1, - "y": 0, + "version": 17, + "width": "88.00000", + "x": 6, + "y": "9.40000", } `; @@ -238,12 +255,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -262,7 +274,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 10, + "version": 4, "width": 50, "x": 100, "y": 100, @@ -271,195 +283,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `4`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `21`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `16`; -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, - "-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`] = ` [ @@ -576,7 +402,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "height": 0, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -615,6 +440,206 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 6, + }, + "inserted": { + "boundElements": [], + "version": 5, + }, + }, + "id15": { + "deleted": { + "version": 3, + }, + "inserted": { + "version": 2, + }, + }, + "id4": { + "deleted": { + "height": "103.96874", + "points": [ + [ + 0, + 0, + ], + [ + "88.00000", + "103.96874", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 16, + "width": "88.00000", + "x": 6, + "y": "9.40000", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "startBinding": null, + "version": 14, + "width": 100, + "x": 0, + "y": 0, + }, + }, + }, + }, + "id": "id16", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "version": 7, + }, + "inserted": { + "version": 6, + }, + }, + "id1": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 6, + }, + "inserted": { + "boundElements": [], + "version": 5, + }, + }, + "id15": { + "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + }, + "id4": { + "deleted": { + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.06000", + "0.59962", + ], + "mode": "orbit", + }, + "height": "0.56170", + "points": [ + [ + 0, + 0, + ], + [ + "88.00000", + "0.56170", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 17, + "width": "88.00000", + }, + "inserted": { + "endBinding": { + "elementId": "id15", + "fixedPoint": [ + "0.50000", + 1, + ], + "mode": "orbit", + }, + "height": "103.96874", + "points": [ + [ + 0, + 0, + ], + [ + "88.00000", + "103.96874", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 16, + "width": "88.00000", + }, + }, + }, + }, + "id": "id17", + }, ] `; @@ -629,6 +654,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -722,7 +748,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, @@ -740,7 +766,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -759,7 +790,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 14, + "version": 8, "width": 100, "x": 150, "y": -50, @@ -770,7 +801,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [], + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -789,7 +825,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 9, + "version": 6, "width": 100, "x": 150, "y": -50, @@ -804,7 +840,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "customData": undefined, "elbowed": false, "endArrowhead": "arrow", - "endBinding": null, + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.06000", + "0.59962", + ], + "mode": "orbit", + }, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -812,9 +855,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id": "id4", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -831,123 +874,31 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": 2, }, "startArrowhead": null, - "startBinding": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 31, + "version": 20, "width": 0, - "x": 149, - "y": 0, + "x": 144, + "y": "9.96170", } `; 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`] = `17`; -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`] = ` [ @@ -1064,7 +1015,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "height": 0, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1103,6 +1053,176 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id": "id6", }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 7, + }, + "inserted": { + "boundElements": [], + "version": 6, + }, + }, + "id4": { + "deleted": { + "height": "2.65128", + "points": [ + [ + 0, + 0, + ], + [ + -44, + "-2.65128", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 17, + "width": 44, + "x": 144, + "y": "2.65128", + }, + "inserted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], + "startBinding": null, + "version": 15, + "width": 100, + "x": 150, + "y": 0, + }, + }, + }, + }, + "id": "id15", + }, + { + "appState": AppStateDelta { + "delta": Delta { + "deleted": {}, + "inserted": {}, + }, + }, + "elements": { + "added": {}, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "version": 8, + }, + "inserted": { + "version": 7, + }, + }, + "id1": { + "deleted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 6, + }, + "inserted": { + "boundElements": [], + "version": 5, + }, + }, + "id4": { + "deleted": { + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.06000", + "0.59962", + ], + "mode": "orbit", + }, + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 0, + 0, + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 20, + "width": 0, + }, + "inserted": { + "endBinding": null, + "height": "2.65128", + "points": [ + [ + 0, + 0, + ], + [ + -44, + "-2.65128", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "1.06000", + "0.59400", + ], + "mode": "orbit", + }, + "version": 17, + "width": 44, + }, + }, + }, + }, + "id": "id16", + }, ] `; @@ -1117,6 +1237,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1209,7 +1330,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, @@ -1237,19 +1358,18 @@ 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": "29.36414", "id": "id4", "index": "Zz", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1257,8 +1377,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + 88, + "29.36414", ], ], "roughness": 1, @@ -1270,8 +1390,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", @@ -1279,9 +1398,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": 88, + "x": 6, + "y": "2.00946", } `; @@ -1445,8 +1564,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "startBinding": { "elementId": "id0", @@ -1454,8 +1572,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "version": 10, }, @@ -1483,6 +1600,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1575,7 +1693,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, @@ -1603,19 +1721,18 @@ 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": "14.91372", "id": "id5", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1623,8 +1740,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + "88.00000", + "-14.91372", ], ], "roughness": 1, @@ -1636,8 +1753,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", @@ -1645,9 +1761,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": "88.00000", + "x": 6, + "y": "37.05219", } `; @@ -1753,16 +1869,14 @@ 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": "14.91372", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1772,8 +1886,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + "88.00000", + "-14.91372", ], ], "roughness": 1, @@ -1785,17 +1899,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": "88.00000", + "x": 6, + "y": "37.05219", }, "inserted": { "isDeleted": true, @@ -1852,6 +1965,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1944,7 +2058,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, @@ -2117,6 +2231,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2193,9 +2308,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, @@ -2208,7 +2321,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, @@ -2226,12 +2339,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2250,7 +2358,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -2261,12 +2369,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl { "angle": 0, "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id4", - "type": "arrow", - }, - ], + "boundElements": [], "customData": undefined, "fillStyle": "solid", "frameId": null, @@ -2285,10 +2388,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 4, "width": 100, - "x": 500, - "y": -500, + "x": 100, + "y": -50, } `; @@ -2302,17 +2405,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": 0, "id": "id4", "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, + "isDeleted": true, "link": null, "locked": false, "opacity": 100, @@ -2322,8 +2427,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + 88, + 0, ], ], "roughness": 1, @@ -2333,26 +2438,151 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 8, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `8`; -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": 8, + }, + "inserted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": false, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "id1", + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + 88, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", + }, + }, + }, + "removed": {}, + "updated": { + "id0": { + "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + }, + "id1": { + "deleted": { + "boundElements": [], + "version": 4, + }, + "inserted": { + "boundElements": [ + { + "id": "id4", + "type": "arrow", + }, + ], + "version": 3, + }, + }, + }, + }, + "id": "id7", + }, +] +`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] undo stack 1`] = ` [ @@ -2433,120 +2663,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", - }, ] `; @@ -2561,6 +2677,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2653,7 +2770,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, @@ -2866,6 +2983,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2958,7 +3076,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, @@ -3187,6 +3305,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3279,7 +3398,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, @@ -3483,6 +3602,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3575,7 +3695,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, @@ -3771,6 +3891,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3863,7 +3984,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, @@ -4008,6 +4129,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4100,7 +4222,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, @@ -4267,6 +4389,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4359,7 +4482,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, @@ -4540,6 +4663,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4632,7 +4756,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, @@ -4771,6 +4895,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4863,7 +4988,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, @@ -5002,6 +5127,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5094,7 +5220,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, @@ -5251,6 +5377,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5343,7 +5470,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, @@ -5509,6 +5636,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5601,7 +5729,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, @@ -5769,6 +5897,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5860,7 +5989,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, @@ -6100,6 +6229,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6191,7 +6321,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, @@ -6529,6 +6659,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6623,7 +6754,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, @@ -6905,6 +7036,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7005,7 +7137,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, @@ -7219,6 +7351,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7308,7 +7441,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, @@ -7338,10 +7471,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id": "id0", "index": "a0", "isDeleted": true, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7366,7 +7495,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 9, + "version": 7, "width": 10, "x": 0, "y": 0, @@ -7375,7 +7504,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`] = `11`; 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`] = `[]`; @@ -7388,9 +7517,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -7413,10 +7547,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "height": 10, "index": "a0", "isDeleted": true, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7440,40 +7570,19 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 7, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 8, + "version": 6, }, }, }, }, - "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 { @@ -7497,7 +7606,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id11", }, { "appState": AppStateDelta { @@ -7521,7 +7630,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id16", + "id": "id12", }, ] `; @@ -7537,6 +7646,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7626,7 +7736,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, @@ -7769,6 +7879,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7858,7 +7969,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, @@ -8123,6 +8234,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8212,7 +8324,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, @@ -8477,6 +8589,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, @@ -8572,7 +8685,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, @@ -8885,6 +8998,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, @@ -8974,7 +9088,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, @@ -9001,10 +9115,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 50, - ], "link": null, "locked": false, "opacity": 100, @@ -9107,10 +9217,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "height": 50, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 50, - ], "link": null, "locked": false, "opacity": 100, @@ -9174,6 +9280,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, @@ -9265,7 +9372,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, @@ -9440,6 +9547,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9531,7 +9639,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, @@ -9707,6 +9815,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9798,7 +9907,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, @@ -9941,6 +10050,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10033,7 +10143,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10240,6 +10350,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10331,7 +10442,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10361,10 +10472,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -10401,7 +10508,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 10, "width": 30, "x": 0, "y": 0, @@ -10410,7 +10517,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`] = `13`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] redo stack 1`] = `[]`; @@ -10423,9 +10530,14 @@ exports[`history > multiplayer undo/redo > should override remotely added points "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -10447,10 +10559,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "height": 10, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -10474,20 +10582,20 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 9, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 11, + "version": 8, }, }, }, "updated": {}, }, - "id": "id10", + "id": "id7", }, { "appState": AppStateDelta { @@ -10503,10 +10611,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "id0": { "deleted": { "height": 30, - "lastCommittedPoint": [ - 30, - 30, - ], "points": [ [ 0, @@ -10529,15 +10633,11 @@ exports[`history > multiplayer undo/redo > should override remotely added points 20, ], ], - "version": 13, + "version": 10, "width": 30, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 10, - 10, - ], "points": [ [ 0, @@ -10548,34 +10648,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points 10, ], ], - "version": 12, + "version": 9, "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", }, ] `; @@ -10591,6 +10670,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10680,7 +10760,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10829,6 +10909,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, @@ -10921,7 +11002,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, @@ -11009,8 +11090,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", @@ -11021,7 +11101,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "id": "6Rm4g567UQM4WjLwej2Vc", "index": "a2", "isDeleted": true, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -11048,8 +11127,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", @@ -11097,8 +11175,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", @@ -11108,7 +11185,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "height": "236.10000", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -11135,8 +11211,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", @@ -11279,6 +11354,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11370,7 +11446,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, @@ -11541,6 +11617,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11630,7 +11707,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11778,6 +11855,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, @@ -11869,7 +11947,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, @@ -12017,6 +12095,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12106,7 +12185,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, @@ -12163,10 +12242,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "id": "id5", "index": "a1", "isDeleted": true, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12217,10 +12292,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "id": "id9", "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12360,10 +12431,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "height": 10, "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12422,6 +12489,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, @@ -12516,7 +12584,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, @@ -12634,6 +12702,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, @@ -12725,7 +12794,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, @@ -12843,6 +12912,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, @@ -12938,7 +13008,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, @@ -13146,6 +13216,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, @@ -13238,7 +13309,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, @@ -13446,6 +13517,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, @@ -13538,7 +13610,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, @@ -13693,6 +13765,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13784,7 +13857,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, @@ -13932,6 +14005,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, @@ -14023,7 +14097,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, @@ -14171,6 +14245,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14262,7 +14337,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, @@ -14420,6 +14495,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, @@ -14511,7 +14587,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, @@ -14753,6 +14829,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14845,7 +14922,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, @@ -14925,6 +15002,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, @@ -15019,7 +15097,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, @@ -15211,6 +15289,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, @@ -15303,7 +15382,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, @@ -15476,6 +15555,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15568,7 +15648,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, @@ -15631,6 +15711,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15724,7 +15805,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15915,6 +15996,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16007,7 +16089,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, @@ -16079,6 +16161,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16172,7 +16255,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -16218,7 +16301,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -16256,7 +16339,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, @@ -16292,7 +16375,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -16309,8 +16392,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -16319,7 +16405,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -16329,7 +16414,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -16340,104 +16425,29 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `11`; -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`] = ` [ @@ -16689,8 +16699,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -16698,7 +16711,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "height": 0, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -16708,7 +16720,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 88, 0, ], ], @@ -16719,21 +16731,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -16786,6 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16879,7 +16895,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -16925,7 +16941,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -16963,7 +16979,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, @@ -16999,7 +17015,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -17016,8 +17032,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17026,7 +17045,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17036,7 +17054,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -17047,24 +17065,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `11`; 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`] = `[]`; @@ -17318,8 +17339,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17327,7 +17351,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "height": 0, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17337,7 +17360,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -17348,21 +17371,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -17375,19 +17401,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 6, + "version": 4, }, "inserted": { "boundElements": [], - "version": 5, - }, - }, - "id1": { - "deleted": { - "version": 6, - }, - "inserted": { - "version": 5, + "version": 3, }, }, "id2": { @@ -17398,16 +17416,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 5, + "version": 3, }, "inserted": { "boundElements": [], - "version": 4, + "version": 2, }, }, }, }, - "id": "id17", + "id": "id15", }, ] `; @@ -17423,6 +17441,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17516,7 +17535,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -17562,7 +17581,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 10, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -17600,7 +17619,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, @@ -17636,7 +17655,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -17653,8 +17672,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17663,7 +17685,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17673,7 +17694,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -17684,24 +17705,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `11`; 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`] = `[]`; @@ -17738,14 +17762,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": { @@ -17777,7 +17801,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, @@ -17785,7 +17809,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "inserted": { "isDeleted": true, - "version": 7, + "version": 1, }, }, "id2": { @@ -17809,20 +17833,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 { @@ -17842,7 +17866,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id22", + "id": "id7", }, { "appState": AppStateDelta { @@ -17862,7 +17886,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": {}, "updated": {}, }, - "id": "id23", + "id": "id10", }, { "appState": AppStateDelta { @@ -17889,11 +17913,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "text", }, ], - "version": 9, + "version": 3, }, "inserted": { "boundElements": [], - "version": 8, + "version": 2, }, }, "id1": { @@ -17901,7 +17925,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, @@ -17911,7 +17935,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, @@ -17920,7 +17944,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, }, }, - "id": "id24", + "id": "id12", }, { "appState": AppStateDelta { @@ -17955,8 +17979,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -17964,7 +17991,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "height": 0, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17974,7 +18000,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -17985,21 +18011,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -18012,19 +18041,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 10, + "version": 4, }, "inserted": { "boundElements": [], - "version": 9, - }, - }, - "id1": { - "deleted": { - "version": 10, - }, - "inserted": { - "version": 9, + "version": 3, }, }, "id2": { @@ -18035,16 +18056,16 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", }, ], - "version": 7, + "version": 3, }, "inserted": { "boundElements": [], - "version": 6, + "version": 2, }, }, }, }, - "id": "id25", + "id": "id15", }, ] `; @@ -18060,6 +18081,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18131,13 +18153,15 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "initialized": true, "type": "selection", }, - "previousSelectedElementIds": {}, + "previousSelectedElementIds": { + "id0": true, + }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, "selectedElementIds": { - "id0": true, + "id13": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -18151,7 +18175,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -18170,14 +18194,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", }, + { + "id": "id13", + "type": "arrow", + }, ], "customData": undefined, "fillStyle": "solid", @@ -18197,7 +18221,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -18235,7 +18259,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, @@ -18271,7 +18295,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 4, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -18288,8 +18312,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -18298,7 +18325,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -18308,7 +18334,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -18319,93 +18345,29 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `11`; -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`] = ` [ @@ -18657,8 +18619,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -18666,7 +18631,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "height": 0, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -18676,7 +18640,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 88, 0, ], ], @@ -18687,21 +18651,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -18740,33 +18707,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "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", - }, ] `; @@ -18781,6 +18721,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18860,8 +18801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "scrollY": 0, "searchMatches": null, "selectedElementIds": { - "id0": true, - "id2": true, + "id13": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -18875,7 +18815,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -18894,14 +18834,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "angle": 0, "backgroundColor": "transparent", "boundElements": [ - { - "id": "id13", - "type": "arrow", - }, { "id": "id1", "type": "text", }, + { + "id": "id13", + "type": "arrow", + }, ], "customData": undefined, "fillStyle": "solid", @@ -18921,7 +18861,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 6, + "version": 4, "width": 100, "x": -100, "y": -50, @@ -18959,7 +18899,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, @@ -18995,7 +18935,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 5, + "version": 3, "width": 100, "x": 100, "y": -50, @@ -19012,8 +18952,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -19022,7 +18965,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -19032,7 +18974,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, + 88, 0, ], ], @@ -19043,102 +18985,29 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": 98, - "x": 1, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", } `; 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`] = `11`; -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`] = ` [ @@ -19390,8 +19259,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "-0.06000", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -19399,7 +19271,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "height": 0, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -19409,7 +19280,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, + 88, 0, ], ], @@ -19420,21 +19291,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + "0.50010", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 7, + "width": 88, + "x": 6, + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 6, }, }, }, @@ -19473,53 +19347,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "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", - }, ] `; @@ -19534,6 +19361,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19628,7 +19456,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20016,6 +19844,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20112,7 +19941,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20529,6 +20358,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20620,7 +20450,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20990,6 +20820,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21083,7 +20914,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -21113,10 +20944,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 20, - 0, - ], "link": null, "locked": false, "opacity": 100, @@ -21145,7 +20972,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 12, "width": 20, "x": 0, "y": 0, @@ -21154,7 +20981,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`] = `24`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = `[]`; @@ -21167,9 +20994,14 @@ exports[`history > singleplayer undo/redo > should support linear element creati "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -21191,10 +21023,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "height": 10, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -21218,20 +21046,20 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 10, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 12, + "version": 9, }, }, }, "updated": {}, }, - "id": "id23", + "id": "id20", }, { "appState": AppStateDelta { @@ -21246,10 +21074,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "updated": { "id0": { "deleted": { - "lastCommittedPoint": [ - 20, - 0, - ], "points": [ [ 0, @@ -21264,14 +21088,10 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 11, "width": 20, }, "inserted": { - "lastCommittedPoint": [ - 10, - 10, - ], "points": [ [ 0, @@ -21282,34 +21102,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati 10, ], ], - "version": 13, + "version": 10, "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 { @@ -21333,7 +21132,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id26", + "id": "id22", }, { "appState": AppStateDelta { @@ -21363,7 +21162,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 20, ], ], - "version": 15, + "version": 12, }, "inserted": { "height": 10, @@ -21381,12 +21180,12 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 11, }, }, }, }, - "id": "id27", + "id": "id23", }, { "appState": AppStateDelta { @@ -21410,7 +21209,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 52614ed5f4..16f49c8210 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -126,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = ` "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1006504105, + "versionNonce": 760410951, "width": 100, "x": 0, "y": 0, @@ -163,7 +163,7 @@ exports[`move element > rectangles with binding arrow 6`] = ` "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 1984422985, + "versionNonce": 651223591, "width": 300, "x": 201, "y": 2, @@ -180,19 +180,22 @@ exports[`move element > rectangles with binding arrow 7`] = ` "endArrowhead": "arrow", "endBinding": { "elementId": "id3", - "focus": "-0.46667", - "gap": 10, + "fixedPoint": [ + "-0.02000", + "0.44666", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "81.40630", + "height": "89.98900", "id": "id6", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -200,8 +203,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` 0, ], [ - "81.00000", - "81.40630", + "89.00000", + "89.98900", ], ], "roughness": 1, @@ -212,18 +215,21 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": "-0.60000", - "gap": 10, + "fixedPoint": [ + "1.06000", + "0.46011", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "versionNonce": 1573789895, - "width": "81.00000", - "x": "110.00000", - "y": 50, + "version": 14, + "versionNonce": 348321737, + "width": "89.00000", + "x": 106, + "y": "46.01050", } `; diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index ee3f024903..03bec27275 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -16,10 +16,6 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 70, - 110, - ], "link": null, "locked": false, "opacity": 100, @@ -49,8 +45,8 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 5, + "versionNonce": 1014066025, "width": 70, "x": 30, "y": 30, @@ -72,10 +68,6 @@ exports[`multi point mode in linear elements > line 3`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 70, - 110, - ], "link": null, "locked": false, "opacity": 100, @@ -104,8 +96,8 @@ exports[`multi point mode in linear elements > line 3`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 5, + "versionNonce": 1014066025, "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 43ca509d84..c4c71c9704 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, @@ -112,7 +113,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, @@ -439,6 +440,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, @@ -542,7 +544,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, @@ -857,6 +859,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -951,7 +954,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, @@ -1425,6 +1428,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1519,7 +1523,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, @@ -1634,6 +1638,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, @@ -1733,7 +1738,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, @@ -2020,6 +2025,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, @@ -2116,7 +2122,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, @@ -2267,6 +2273,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2361,7 +2368,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2449,6 +2456,7 @@ exports[`regression tests > can drag element that covers another element, while "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2545,7 +2553,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, @@ -2776,6 +2784,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, @@ -2870,7 +2879,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, @@ -3033,6 +3042,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, @@ -3129,7 +3139,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, @@ -3276,6 +3286,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, @@ -3372,7 +3383,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, @@ -3514,6 +3525,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, @@ -3610,7 +3622,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, @@ -3774,6 +3786,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, @@ -3871,7 +3884,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, @@ -4090,6 +4103,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, @@ -4186,7 +4200,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, @@ -4528,6 +4542,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4651,7 +4666,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, @@ -4813,6 +4828,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, @@ -4908,7 +4924,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, @@ -5091,6 +5107,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5213,7 +5230,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, @@ -5301,6 +5318,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5395,7 +5413,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, @@ -5503,6 +5521,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, @@ -5597,7 +5616,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, @@ -5898,6 +5917,7 @@ exports[`regression tests > drags selected elements from point inside common bou "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5996,7 +6016,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, @@ -6197,6 +6217,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, @@ -6278,7 +6299,34 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, - "selectedLinearElement": null, + "selectedLinearElement": { + "customLineAngle": null, + "elbowed": false, + "elementId": "id20", + "hoverPointIndex": -1, + "initialState": { + "arrowStartIsInside": false, + "lastClickedPoint": -1, + "origin": null, + "prevSelectedPointsIndices": null, + "segmentMidpoint": { + "added": false, + "index": null, + "value": null, + }, + }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, + "pointerOffset": { + "x": 0, + "y": 0, + }, + "segmentMidPointHoveredCoords": null, + "selectedPointsIndices": null, + }, "selectionElement": null, "shouldCacheIgnoreZoom": false, "showHyperlinkPopup": false, @@ -6289,7 +6337,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, @@ -6305,7 +6353,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`; -exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `31`; +exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `35`; exports[`regression tests > draw every type of shape > [end of test] redo stack 1`] = `[]`; @@ -6509,7 +6557,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -6588,7 +6635,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a4", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -6633,7 +6679,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": { @@ -6664,10 +6713,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a5", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -6691,14 +6736,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 4, "width": 50, "x": 310, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 3, }, }, }, @@ -6720,10 +6765,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "id15": { "deleted": { "height": 20, - "lastCommittedPoint": [ - 80, - 20, - ], "points": [ [ 0, @@ -6738,15 +6779,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 5, "width": 80, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 50, - 10, - ], "points": [ [ 0, @@ -6757,7 +6794,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 4, "width": 50, }, }, @@ -6769,32 +6806,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "appState": AppStateDelta { "delta": Delta { "deleted": { + "selectedElementIds": { + "id20": true, + }, "selectedLinearElement": { - "elementId": "id15", + "elementId": "id20", "isEditing": false, }, }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id21", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id22": true, - }, - "selectedLinearElement": null, - }, "inserted": { "selectedElementIds": { "id15": true, @@ -6809,7 +6828,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id22": { + "id20": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6823,10 +6842,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a6", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -6849,20 +6864,20 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "line", - "version": 6, + "version": 4, "width": 50, "x": 430, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 3, }, }, }, "updated": {}, }, - "id": "id24", + "id": "id22", }, { "appState": AppStateDelta { @@ -6875,13 +6890,9 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "added": {}, "removed": {}, "updated": { - "id22": { + "id20": { "deleted": { "height": 20, - "lastCommittedPoint": [ - 80, - 20, - ], "points": [ [ 0, @@ -6896,15 +6907,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 5, "width": 80, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 50, - 10, - ], "points": [ [ 0, @@ -6915,34 +6922,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 4, "width": 50, }, }, }, }, - "id": "id26", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id28", + "id": "id24", }, { "appState": AppStateDelta { @@ -6952,7 +6938,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "inserted": { "selectedElementIds": { - "id22": true, + "id20": true, }, }, }, @@ -6962,26 +6948,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "removed": {}, "updated": {}, }, - "id": "id30", + "id": "id26", }, { "appState": AppStateDelta { "delta": Delta { - "deleted": { - "selectedLinearElement": null, - }, - "inserted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, - }, + "deleted": {}, + "inserted": {}, }, }, "elements": { "added": {}, "removed": { - "id31": { + "id27": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6993,10 +6972,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a7", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7039,7 +7014,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "updated": {}, }, - "id": "id33", + "id": "id29", }, ] `; @@ -7055,6 +7030,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, @@ -7152,7 +7128,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, @@ -7391,6 +7367,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, @@ -7488,7 +7465,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, @@ -7672,6 +7649,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, @@ -7768,7 +7746,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, @@ -7909,6 +7887,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, @@ -8005,7 +7984,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, @@ -8151,6 +8130,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, @@ -8245,7 +8225,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, @@ -8333,6 +8313,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, @@ -8427,7 +8408,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, @@ -8515,6 +8496,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, @@ -8609,7 +8591,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, @@ -8697,6 +8679,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, @@ -8784,13 +8767,9 @@ 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": { - "lastClickedIsEndPoint": false, + "initialState": { + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -8800,13 +8779,17 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -8818,7 +8801,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, @@ -8834,7 +8817,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` exports[`regression tests > key 5 selects arrow tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key 5 selects arrow tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key 5 selects arrow tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key 5 selects arrow tool > [end of test] redo stack 1`] = `[]`; @@ -8876,7 +8859,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -8929,6 +8911,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, @@ -9016,13 +8999,9 @@ 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": { - "lastClickedIsEndPoint": false, + "initialState": { + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9032,13 +9011,17 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9050,7 +9033,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, @@ -9066,7 +9049,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] exports[`regression tests > key 6 selects line tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key 6 selects line tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key 6 selects line tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key 6 selects line tool > [end of test] redo stack 1`] = `[]`; @@ -9107,7 +9090,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1 "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -9159,6 +9141,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, @@ -9251,7 +9234,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, @@ -9295,10 +9278,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -9357,6 +9336,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, @@ -9444,13 +9424,9 @@ 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": { - "lastClickedIsEndPoint": false, + "initialState": { + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9460,13 +9436,17 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9478,7 +9458,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, @@ -9494,7 +9474,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` exports[`regression tests > key a selects arrow tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key a selects arrow tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key a selects arrow tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key a selects arrow tool > [end of test] redo stack 1`] = `[]`; @@ -9536,7 +9516,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -9589,6 +9568,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, @@ -9683,7 +9663,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, @@ -9771,6 +9751,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, @@ -9858,13 +9839,9 @@ 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": { - "lastClickedIsEndPoint": false, + "initialState": { + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9874,13 +9851,17 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9892,7 +9873,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, @@ -9908,7 +9889,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] exports[`regression tests > key l selects line tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key l selects line tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key l selects line tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key l selects line tool > [end of test] redo stack 1`] = `[]`; @@ -9949,7 +9930,6 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1 "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -10001,6 +9981,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, @@ -10095,7 +10076,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, @@ -10183,6 +10164,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, @@ -10275,7 +10257,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, @@ -10319,10 +10301,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -10381,6 +10359,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, @@ -10475,7 +10454,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, @@ -10563,6 +10542,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, @@ -10665,7 +10645,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, @@ -11096,6 +11076,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11192,7 +11173,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, @@ -11378,6 +11359,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, @@ -11470,7 +11452,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, @@ -11503,6 +11485,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, @@ -11597,7 +11580,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, @@ -11705,6 +11688,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, @@ -11803,7 +11787,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, @@ -12026,6 +12010,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, @@ -12126,7 +12111,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, @@ -12457,6 +12442,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, @@ -12561,7 +12547,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, @@ -13099,6 +13085,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, @@ -13194,7 +13181,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, @@ -13227,6 +13214,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13323,7 +13311,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, @@ -13860,6 +13848,7 @@ exports[`regression tests > switches from group of selected elements to another "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13985,7 +13974,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, @@ -14201,6 +14190,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14325,7 +14315,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, @@ -14467,6 +14457,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, @@ -14559,7 +14550,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, @@ -14592,6 +14583,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, @@ -14686,7 +14678,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, @@ -14706,27 +14698,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] number 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 { @@ -14741,10 +14712,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "height": 10, - "lastCommittedPoint": [ - 60, - 10, - ], "points": [ [ 0, @@ -14755,15 +14722,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 10, ], ], - "version": 9, + "version": 6, "width": 60, }, "inserted": { "height": 20, - "lastCommittedPoint": [ - 100, - 20, - ], "points": [ [ 0, @@ -14778,13 +14741,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 20, ], ], - "version": 8, + "version": 5, "width": 100, }, }, }, }, - "id": "id14", + "id": "id11", }, { "appState": AppStateDelta { @@ -14793,11 +14756,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, + }, }, }, }, @@ -14806,7 +14774,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "isDeleted": true, - "version": 10, + "version": 7, }, "inserted": { "angle": 0, @@ -14822,10 +14790,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "height": 10, "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 60, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -14849,7 +14813,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 6, "width": 60, "x": 130, "y": 10, @@ -14859,7 +14823,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id12", }, ] `; @@ -14970,7 +14934,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st }, "updated": {}, }, - "id": "id17", + "id": "id14", }, ] `; @@ -14986,6 +14950,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "locked": false, "type": "text", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15078,7 +15043,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15111,6 +15076,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15206,7 +15172,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/__snapshots__/selection.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap index f47b89813f..5d5c701f0a 100644 --- a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap @@ -16,7 +16,6 @@ exports[`select single element on the scene > arrow 1`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -65,7 +64,6 @@ exports[`select single element on the scene > arrow escape 1`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index d59a829a0f..95826081f4 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -16,7 +16,6 @@ exports[`restoreElements > should restore arrow element correctly 1`] = ` "id": "id-arrow01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -175,7 +174,6 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = ` "id": "id-freedraw01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -222,7 +220,6 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] = "id": "id-line01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -270,7 +267,6 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] = "id": "id-draw01", "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index 566c839050..e943bce431 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -157,9 +157,9 @@ describe("Test dragCreate", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `5`, + `6`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); @@ -195,9 +195,9 @@ describe("Test dragCreate", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `5`, + `6`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index aca5530d4c..b54dc32f15 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1021,7 +1021,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); @@ -1038,7 +1038,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); @@ -1058,11 +1058,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); @@ -1079,10 +1079,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({ @@ -1095,29 +1095,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, @@ -1130,9 +1130,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({ @@ -1146,10 +1145,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, @@ -1160,25 +1159,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` @@ -1195,7 +1194,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); @@ -1212,7 +1211,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); @@ -1229,7 +1228,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); @@ -1589,13 +1588,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([0.5001, 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" }, @@ -1612,13 +1611,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({ @@ -1635,13 +1634,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({ @@ -1666,13 +1665,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({ @@ -1689,13 +1688,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({ @@ -1744,13 +1743,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, }), @@ -1789,13 +1794,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, }), @@ -1833,8 +1844,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, }), @@ -1868,13 +1882,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, }), @@ -1941,13 +1961,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, }), @@ -2298,15 +2324,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, }, ], @@ -2421,10 +2445,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: [ @@ -2438,7 +2461,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, @@ -2451,7 +2474,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, @@ -2464,21 +2487,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({ @@ -2978,7 +2986,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); @@ -2995,11 +3003,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); @@ -4500,16 +4508,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({ @@ -4524,13 +4546,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", }), }), ]), @@ -4593,13 +4621,13 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.6363636363636364, 0.6363636363636364], + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.4109529004289598, 0.5890470995710405], + mode: "orbit", }), }), ]), @@ -4636,13 +4664,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([ @@ -4658,13 +4686,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", }), }), ]), @@ -4702,9 +4736,8 @@ describe("history", () => { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }), remoteContainer, @@ -4731,14 +4764,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({ @@ -4768,12 +4801,8 @@ describe("history", () => { 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({ @@ -4791,15 +4820,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", }, }); @@ -4853,8 +4880,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! @@ -4863,8 +4889,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), }), expect.objectContaining({ @@ -4900,15 +4925,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, { @@ -4935,8 +4958,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4944,8 +4966,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: true, }), @@ -4975,8 +4996,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }, endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4984,8 +5004,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: false, }), @@ -5028,13 +5047,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, }), @@ -5076,13 +5093,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/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index d84ce1ffb9..f9a9d12d27 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -210,7 +210,6 @@ describe("Basic lasso selection tests", () => { [0, 0], [168.4765625, -153.38671875], ], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: null, @@ -250,7 +249,6 @@ describe("Basic lasso selection tests", () => { [0, 0], [206.12890625, 35.4140625], ], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: null, @@ -354,7 +352,6 @@ describe("Basic lasso selection tests", () => { ], pressures: [], simulatePressure: true, - lastCommittedPoint: null, }, ].map( (e) => @@ -1229,7 +1226,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1271,7 +1267,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1312,7 +1307,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1353,7 +1347,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1692,7 +1685,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1744,7 +1736,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 55c25188de..1905741efa 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -67,9 +67,8 @@ describe("library items inserting", () => { 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 095db38a0c..ac6b05008d 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,16 +102,16 @@ describe("move element", () => { new Pointer("mouse").clickOn(rectB); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`15`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(3); 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([[106, 46.011]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); @@ -124,8 +129,11 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([201, 2]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( + [[89, 90.033]], + 0, + ); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index 926c8d47f3..26cd88e66b 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -118,8 +118,10 @@ describe("multi point mode in linear elements", () => { key: KEYS.ENTER, }); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `11`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; @@ -161,8 +163,10 @@ describe("multi point mode in linear elements", () => { fireEvent.keyDown(document, { key: KEYS.ENTER, }); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `11`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 2ee2167914..f16606b6f2 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -369,7 +369,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 38079db8f3..285672aa8f 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -24,7 +24,7 @@ test("unselected bound arrow updates when rotating its target element", async () const arrow = UI.createElement("arrow", { x: -80, y: 50, - width: 70, + width: 85, height: 0, }); @@ -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(132.491, 1); + expect(arrow.height).toBeCloseTo(82.267, 1); }); test("unselected bound arrows update when rotating their target elements", async () => { @@ -48,9 +48,10 @@ test("unselected bound arrows update when rotating their target elements", async height: 120, }); const ellipseArrow = UI.createElement("arrow", { - position: 0, - width: 40, - height: 80, + x: -10, + y: 80, + width: 50, + height: 60, }); const text = UI.createElement("text", { position: 220, @@ -59,8 +60,8 @@ test("unselected bound arrows update when rotating their target elements", async const textArrow = UI.createElement("arrow", { x: 360, y: 300, - width: -100, - height: -40, + width: -140, + height: -60, }); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); @@ -69,16 +70,16 @@ test("unselected bound arrows update when rotating their target elements", async UI.rotate([ellipse, text], [-82, 23], { shift: true }); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); - expect(ellipseArrow.x).toEqual(0); - expect(ellipseArrow.y).toEqual(0); + expect(ellipseArrow.x).toEqual(-10); + expect(ellipseArrow.y).toEqual(80); 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(66.317, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(144.38, 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(-98.86, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-123.65, 0); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 10f4f7ad98..dde3c96e48 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -425,8 +425,8 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -469,8 +469,8 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -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 c7857382cb..66110700cb 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -6,8 +6,6 @@ import type { EditorInterface, } from "@excalidraw/common"; -import type { SuggestedBinding } from "@excalidraw/element"; - import type { LinearElementEditor } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element"; @@ -34,6 +32,7 @@ import type { ExcalidrawIframeLikeElement, OrderedExcalidrawElement, ExcalidrawNonSelectionElement, + BindMode, } from "@excalidraw/element/types"; import type { @@ -205,6 +204,7 @@ export type StaticCanvasAppState = Readonly< frameRendering: AppState["frameRendering"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; hoveredElementIds: AppState["hoveredElementIds"]; + suggestedBinding: AppState["suggestedBinding"]; // Cropping croppingElementId: AppState["croppingElementId"]; } @@ -218,8 +218,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 @@ -234,6 +235,11 @@ export type InteractiveCanvasAppState = Readonly< // Search matches searchMatches: AppState["searchMatches"]; activeLockedId: AppState["activeLockedId"]; + // Non-used but needed in binding highlight arrow overdraw + hoveredElementIds: AppState["hoveredElementIds"]; + frameRendering: AppState["frameRendering"]; + shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; + exportScale: AppState["exportScale"]; } >; @@ -293,7 +299,7 @@ export interface AppState { selectionElement: NonDeletedExcalidrawElement | null; isBindingEnabled: boolean; startBoundElement: NonDeleted | null; - suggestedBindings: SuggestedBinding[]; + suggestedBinding: NonDeleted | null; frameToHighlight: NonDeleted | null; frameRendering: { enabled: boolean; @@ -368,6 +374,7 @@ export interface AppState { | { name: "imageExport" | "help" | "jsonExport" } | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "commandPalette" } + | { name: "settings" } | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; /** * Reflects user preference for whether the default sidebar should be docked. @@ -450,6 +457,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 = { @@ -466,7 +474,7 @@ export type SearchMatch = { export type UIAppState = Omit< AppState, - | "suggestedBindings" + | "suggestedBinding" | "startBoundElement" | "cursorButton" | "scrollX" @@ -750,6 +758,8 @@ export type AppClassProperties = { onPointerUpEmitter: App["onPointerUpEmitter"]; updateEditorAtom: App["updateEditorAtom"]; onPointerDownEmitter: App["onPointerDownEmitter"]; + + bindModeHandler: App["bindModeHandler"]; }; export type PointerDownState = Readonly<{ diff --git a/packages/utils/src/test-utils.ts b/packages/utils/src/test-utils.ts index 1dfd14cacb..966a589ab9 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 d3bbff7af7..f914a2bf2b 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, @@ -104,7 +105,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null,