From 1668a9736b123ee5414fffa76ee466f784fa8488 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 29 Aug 2025 17:40:10 +0200 Subject: [PATCH] Refactor dragging --- packages/element/src/linearElementEditor.ts | 325 ++++++++------------ packages/excalidraw/components/App.tsx | 100 +++--- 2 files changed, 180 insertions(+), 245 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 440ea5d0b6..0acd0056cc 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -19,7 +19,6 @@ import { shouldRotateWithDiscreteAngle, getGridPoint, invariant, - tupleToCoors, } from "@excalidraw/common"; import { @@ -290,207 +289,146 @@ export class LinearElementEditor { scenePointerY: number, linearElementEditor: LinearElementEditor, ): Pick | null { - if (!linearElementEditor) { - return null; - } - const { elementId } = linearElementEditor; const elementsMap = app.scene.getNonDeletedElementsMap(); + const elements = app.scene.getNonDeletedElements(); + const { elbowed, elementId, pointerDownState, selectedPointsIndices } = + linearElementEditor; + const { lastClickedPoint } = pointerDownState; const element = LinearElementEditor.getElement(elementId, elementsMap); - let customLineAngle = linearElementEditor.customLineAngle; - if (!element) { - return null; - } + invariant( + selectedPointsIndices, + "There must be selected points in order to drag them", + ); - const elbowed = isElbowArrow(element); + invariant( + lastClickedPoint > -1 && selectedPointsIndices.includes(lastClickedPoint), + "There must be a valid lastClickedPoint in order to drag it", + ); - if ( - elbowed && - !linearElementEditor.pointerDownState.lastClickedIsEndPoint && - linearElementEditor.pointerDownState.lastClickedPoint !== 0 - ) { - return null; - } + invariant(element, "Element being dragged must exist in the scene"); - const selectedPointsIndices = elbowed - ? [ - !!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 = elbowed - ? linearElementEditor.pointerDownState.lastClickedPoint > 0 - ? element.points.length - 1 - : 0 - : linearElementEditor.pointerDownState.lastClickedPoint; + invariant(element.points.length > 1, "Element must have at least 2 points"); + + invariant( + !elbowed || + selectedPointsIndices?.filter( + (idx) => idx !== 0 && idx !== element.points.length - 1, + ).length === 0, + "Only start and end points can be selected for elbow arrows", + ); // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; + // The adjacent point to the one dragged point + const pivotPoint = + element.points[lastClickedPoint === 0 ? 1 : lastClickedPoint - 1]; + const singlePointDragged = selectedPointsIndices.length === 1; + const customLineAngle = + linearElementEditor.customLineAngle ?? + determineCustomLinearAngle(pivotPoint, element.points[lastClickedPoint]); + const startIsSelected = selectedPointsIndices.includes(0); + const endIsSelected = selectedPointsIndices.includes( + element.points.length - 1, + ); - if (selectedPointsIndices && draggingPoint) { - const elements = app.scene.getNonDeletedElements(); - - 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, - ); - const reference = pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ); - const deltaX = reference[0] - draggingPoint[0]; - const deltaY = reference[1] - draggingPoint[1]; - - const { positions, updates } = pointDraggingUpdates( - selectedPointsIndices, - deltaX, - deltaY, - elementsMap, - element, - elements, - app, - ); - - LinearElementEditor.movePoints(element, app.scene, positions, updates); - } else if ( - shouldAllowDraggingPoint( - element, - scenePointerX, - scenePointerY, - selectedPointsIndices, - elementsMap, - app, - ) - ) { - const newDraggingPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - ); - const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; - const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; - - const { positions, updates } = pointDraggingUpdates( - selectedPointsIndices, - deltaX, - deltaY, - elementsMap, - element, - elements, - app, - ); - - LinearElementEditor.movePoints(element, app.scene, positions, updates); - } - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement) { - handleBindTextResize(element, app.scene, false); - } - - // suggest bindings for first and last point if selected - let suggestedBinding: AppState["suggestedBinding"] = null; - if (isBindingElement(element, false)) { - const firstIndexIsSelected = selectedPointsIndices[0] === 0; - const lastIndexIsSelected = - selectedPointsIndices[selectedPointsIndices.length - 1] === - element.points.length - 1; - const coords: { x: number; y: number }[] = []; - - if (firstIndexIsSelected !== lastIndexIsSelected) { - if (firstIndexIsSelected) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[0], - elementsMap, - ), - ), - ); - } - - if (lastIndexIsSelected) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[ - selectedPointsIndices[selectedPointsIndices.length - 1] - ], - elementsMap, - ), - ), - ); - } - } - - if (coords.length) { - suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords( - element, - firstIndexIsSelected && lastIndexIsSelected - ? "both" - : firstIndexIsSelected - ? "start" - : "end", - app.scene, - pointFrom(scenePointerX, scenePointerY), - ); - } - } - - const newLinearElementEditor = { - ...linearElementEditor, - selectedPointsIndices, - segmentMidPointHoveredCoords: - lastClickedPoint !== 0 && - lastClickedPoint !== element.points.length - 1 - ? this.getPointGlobalCoordinates( - element, - draggingPoint, - elementsMap, - ) - : null, - hoverPointIndex: - lastClickedPoint === 0 || - lastClickedPoint === element.points.length - 1 - ? lastClickedPoint - : -1, - isDragging: true, + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) { + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + pivotPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), customLineAngle, - }; + ); + const target = pointFrom( + width + pivotPoint[0], + height + pivotPoint[1], + ); - return { - selectedLinearElement: newLinearElementEditor, - suggestedBinding, - } as Pick; + deltaX = target[0] - draggingPoint[0]; + deltaY = target[1] - draggingPoint[1]; + } else if ( + shouldAllowDraggingPoint( + element, + scenePointerX, + scenePointerY, + selectedPointsIndices, + elementsMap, + app, + ) + ) { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + deltaY = newDraggingPointPosition[1] - draggingPoint[1]; } - return null; + // Apply the point movement if needed + if (deltaX || deltaY) { + const { positions, updates } = pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, updates); + } + + // Attached text might need to update if arrow dimensions change + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + handleBindTextResize(element, app.scene, false); + } + + // Suggest bindings for first and last point if selected + let suggestedBinding: AppState["suggestedBinding"] = null; + if (isBindingElement(element, false)) { + if (startIsSelected || endIsSelected) { + suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords( + element, + startIsSelected && endIsSelected + ? "both" + : startIsSelected + ? "start" + : "end", + app.scene, + pointFrom(scenePointerX, scenePointerY), + ); + } + } + + 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, + customLineAngle, + }; + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; } static handlePointerUp( @@ -1841,7 +1779,10 @@ export class LinearElementEditor { x: number, y: number, scene: Scene, - ): LinearElementEditor { + ): Pick< + LinearElementEditor, + "segmentMidPointHoveredCoords" | "pointerDownState" + > { const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement( linearElement.elementId, @@ -2133,3 +2074,9 @@ const shouldAllowDraggingPoint = ( return allowDrag; }; + +const determineCustomLinearAngle = ( + pivotPoint: LocalPoint, + draggedPoint: LocalPoint, +) => + Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b70447052d..65bde9b392 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8787,54 +8787,54 @@ class App extends React.Component { !linearElementEditor.pointerDownState.segmentMidpoint.added ) { return; - } - - const element = LinearElementEditor.getElement( - linearElementEditor.elementId, - elementsMap, - ); - - if (isBindingElement(element)) { - const hoveredElement = getHoveredElementForBinding( - pointFrom(pointerCoords.x, pointerCoords.y), - this.scene.getNonDeletedElements(), + } else if (linearElementEditor.pointerDownState.lastClickedPoint > -1) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, elementsMap, ); - this.handleDelayedBindModeChange(element, hoveredElement); - } + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + 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; - - // NOTE: Optimize setState calls because it - // affects history and performance - if ( - newState.suggestedBinding !== this.state.suggestedBinding || - !isShallowEqual( - newState.selectedLinearElement?.selectedPointsIndices ?? [], - this.state.selectedLinearElement?.selectedPointsIndices ?? [], - ) || - newState.selectedLinearElement?.hoverPointIndex !== - this.state.selectedLinearElement?.hoverPointIndex || - newState.selectedLinearElement?.customLineAngle !== - this.state.selectedLinearElement?.customLineAngle || - this.state.selectedLinearElement.isDragging !== - newState.selectedLinearElement?.isDragging - ) { - this.setState(newState); + this.handleDelayedBindModeChange(element, hoveredElement); } - return; + const newState = LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + linearElementEditor, + ); + if (newState) { + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + pointerDownState.drag.hasOccurred = true; + + // NOTE: Optimize setState calls because it + // affects history and performance + if ( + newState.suggestedBinding !== this.state.suggestedBinding || + !isShallowEqual( + newState.selectedLinearElement?.selectedPointsIndices ?? [], + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ) || + newState.selectedLinearElement?.hoverPointIndex !== + this.state.selectedLinearElement?.hoverPointIndex || + newState.selectedLinearElement?.customLineAngle !== + this.state.selectedLinearElement?.customLineAngle || + this.state.selectedLinearElement.isDragging !== + newState.selectedLinearElement?.isDragging + ) { + this.setState(newState); + } + + return; + } } } @@ -9007,13 +9007,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, @@ -9316,18 +9316,6 @@ class App extends React.Component { linearElementEditor, )!, }); - - // if (isBindingElement(newElement, false)) { - // // When creating a linear element by dragging - // this.setState({ - // suggestedBinding: maybeSuggestBindingsForBindingElementAtCoords( - // newElement, - // "end", - // this.scene, - // pointFrom(pointerCoords.x, pointerCoords.y), - // ), - // }); - // } } else { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y;