From 2997594492967141781a5667daaad9a6416c131a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 31 Oct 2025 16:37:14 +0100 Subject: [PATCH] Simplified binding --- packages/element/src/binding.ts | 143 +++++++++++++++++++- packages/element/src/linearElementEditor.ts | 10 +- packages/excalidraw/components/App.tsx | 33 +++-- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 8606190e29..a5e050869e 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,4 +1,10 @@ -import { KEYS, arrayToMap, invariant, isTransparent } from "@excalidraw/common"; +import { + KEYS, + arrayToMap, + getFeatureFlag, + invariant, + isTransparent, +} from "@excalidraw/common"; import { lineSegment, @@ -31,6 +37,7 @@ import { getHoveredElementForBinding, intersectElementWithLineSegment, isBindableElementInsideOtherBindable, + isPointInElement, } from "./collision"; import { distanceToElement } from "./distance"; import { @@ -524,6 +531,140 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = ( shiftKey?: boolean; finalize?: boolean; }, +): { start: BindingStrategy; end: BindingStrategy } => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + return getBindingStrategyForDraggingBindingElementEndpoints_complex( + arrow, + draggingPoints, + elementsMap, + elements, + appState, + opts, + ); + } + + return getBindingStrategyForDraggingBindingElementEndpoints_simple( + arrow, + draggingPoints, + elementsMap, + elements, + appState, + opts, + ); +}; + +const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + finalize?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + 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, + ); + } + + 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) => 100, // TODO: Zoom-level + ); + const current: BindingStrategy = hit + ? isPointInElement(globalPoint, hit, elementsMap) + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : { + mode: "orbit", + element: hit, + focusPoint: opts?.finalize + ? LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startDragged ? 0 : -1, + elementsMap, + ) + : globalPoint, + } + : { mode: null }; + + return { + start: startDragged ? current : start, + end: endDragged ? current : end, + }; +}; + +const getBindingStrategyForDraggingBindingElementEndpoints_complex = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + finalize?: boolean; + }, ): { start: BindingStrategy; end: BindingStrategy } => { const globalBindMode = appState.bindMode || "orbit"; const startIdx = 0; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 5e6111bc11..9c1de59206 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -21,6 +21,7 @@ import { getGridPoint, invariant, isShallowEqual, + getFeatureFlag, } from "@excalidraw/common"; import { @@ -2236,9 +2237,12 @@ const pointDraggingUpdates = ( nextArrow.endBinding.elementId, )! as ExcalidrawBindableElement) : null; + const endLocalPoint = startIsDraggingOverEndElement ? nextArrow.points[nextArrow.points.length - 1] - : endIsDraggingOverStartElement && app.state.bindMode !== "inside" + : endIsDraggingOverStartElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") ? nextArrow.points[0] : endBindable ? updateBoundPoint( @@ -2266,7 +2270,9 @@ const pointDraggingUpdates = ( const startLocalPoint = endIsDraggingOverStartElement ? nextArrow.points[0] - : startIsDraggingOverEndElement && app.state.bindMode !== "inside" + : startIsDraggingOverEndElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") ? nextArrow.points[nextArrow.points.length - 1] : startBindable ? updateBoundPoint( diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ab68c51005..7122d8b6f0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -106,6 +106,7 @@ import { MQ_MAX_TABLET, MQ_MAX_HEIGHT_LANDSCAPE, MQ_MAX_WIDTH_LANDSCAPE, + getFeatureFlag, } from "@excalidraw/common"; import { @@ -4784,7 +4785,7 @@ class App extends React.Component { } // Handle Alt key for bind mode - if (event.key === KEYS.ALT) { + if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { this.handleSkipBindMode(); } @@ -4797,7 +4798,10 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { - this.resetDelayedBindMode(); + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ isBindingEnabled: false }); } @@ -5103,7 +5107,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if (isBindingElement(element)) { + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { this.handleDelayedBindModeChange(element, hoveredElement); } } @@ -6542,7 +6546,9 @@ class App extends React.Component { elementsMap, ); - this.handleDelayedBindModeChange(multiElement, hoveredElement); + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(multiElement, hoveredElement); + } } invariant( @@ -7348,7 +7354,10 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { - this.resetDelayedBindMode(); + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.removePointer(event); this.lastPointerUpEvent = event; @@ -8655,7 +8664,7 @@ class App extends React.Component { }); }); - if (isBindingElement(element)) { + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { this.handleDelayedBindModeChange(element, boundElement); } } @@ -9023,12 +9032,16 @@ class App extends React.Component { elementsMap, ); - this.handleDelayedBindModeChange(element, hoveredElement); + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, hoveredElement); + } } if ( event.altKey && - !this.state.selectedLinearElement?.initialState?.arrowStartIsInside + !this.state.selectedLinearElement?.initialState + ?.arrowStartIsInside && + getFeatureFlag("COMPLEX_BINDINGS") ) { this.handleSkipBindMode(); } @@ -9784,7 +9797,9 @@ class App extends React.Component { }); } - this.resetDelayedBindMode(); + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } this.setState({ selectedElementsAreBeingDragged: false,