diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e0c2cbdad..5b51a063a 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -25,6 +25,7 @@ import { import { deconstructLinearOrFreeDrawElement, + hitElementItself, isPathALoop, type Store, } from "@excalidraw/element"; @@ -43,6 +44,7 @@ import type { import type { Mutable } from "@excalidraw/common/utility-types"; import { + bindLinearElement, bindOrUnbindLinearElement, getHoveredElementForBinding, getOutlineAvoidingPoint, @@ -60,6 +62,7 @@ import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { isArrowElement, + isBindableElement, isBindingElement, isElbowArrow, isFixedPointBinding, @@ -87,6 +90,8 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, PointsPositionUpdates, + NonDeletedExcalidrawElement, + Ordered, } from "./types"; /** @@ -136,6 +141,7 @@ export class LinearElementEditor { index: number | null; added: boolean; }; + arrowOtherPoint?: GlobalPoint; }>; /** whether you're dragging a point */ @@ -280,6 +286,7 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, linearElementEditor: LinearElementEditor, + thresholdCallback: (element: ExcalidrawElement) => number, ): Pick | null { if (!linearElementEditor) { return null; @@ -288,6 +295,8 @@ export class LinearElementEditor { const elementsMap = app.scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); let customLineAngle = linearElementEditor.customLineAngle; + let arrowOtherPoint: GlobalPoint | undefined = + linearElementEditor.pointerDownState.arrowOtherPoint; if (!element) { return null; } @@ -370,99 +379,39 @@ export class LinearElementEditor { scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; + const elements = app.scene.getNonDeletedElements(); + + arrowOtherPoint = pointDraggingOtherEndpoint( + element, + elementsMap, + selectedPointsIndices, + scenePointerX, + scenePointerY, + linearElementEditor, + app.scene, + elements, + app.state.zoom, + thresholdCallback, + ); LinearElementEditor.movePoints( element, app.scene, - new Map( - selectedPointsIndices.map((pointIndex) => { - let 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, - ); - - if ( - pointIndex === 0 || - pointIndex === element.points.length - 1 - ) { - const [, , , , cx, cy] = getElementAbsoluteCoords( - element, - elementsMap, - true, - ); - let newGlobalPointPosition = pointRotateRads( - pointFrom( - element.x + newPointPosition[0], - element.y + newPointPosition[1], - ), - pointFrom(cx, cy), - element.angle, - ); - const hoveredElement = getHoveredElementForBinding( - { - x: newGlobalPointPosition[0], - y: newGlobalPointPosition[1], - }, - app.scene.getNonDeletedElements(), - elementsMap, - app.state.zoom, - true, - isElbowArrow(element), - ); - - const otherBinding = - element[pointIndex === 0 ? "endBinding" : "startBinding"]; - - // Allow binding inside the element if both ends are inside - if ( - isArrowElement(element) && - !( - hoveredElement?.id === otherBinding?.elementId && - hoveredElement != null - ) - ) { - newGlobalPointPosition = getOutlineAvoidingPoint( - element, - hoveredElement, - newGlobalPointPosition, - pointIndex, - elementsMap, - ); - } - - newPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - newGlobalPointPosition[0] - - linearElementEditor.pointerOffset.x, - newGlobalPointPosition[1] - - linearElementEditor.pointerOffset.y, - null, - ); - } - - return [ - pointIndex, - { - point: newPointPosition, - isDragging: pointIndex === lastClickedPoint, - }, - ]; - }), + pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + lastClickedPoint, + element, + scenePointerX, + scenePointerY, + linearElementEditor, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + elements, + app.state.zoom, ), ); } @@ -541,6 +490,10 @@ export class LinearElementEditor { : -1, isDragging: true, customLineAngle, + pointerDownState: { + ...linearElementEditor.pointerDownState, + arrowOtherPoint, + }, }; return { @@ -671,6 +624,10 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + pointerDownState: { + ...editingLinearElement.pointerDownState, + arrowOtherPoint: undefined, + }, }; } @@ -2043,3 +2000,221 @@ const normalizeSelectedPoints = ( nextPoints = nextPoints.sort((a, b) => a - b); return nextPoints.length ? nextPoints : null; }; + +const pointDraggingUpdates = ( + selectedPointsIndices: readonly number[], + deltaX: number, + deltaY: number, + elementsMap: NonDeletedSceneElementsMap, + lastClickedPoint: number, + element: NonDeleted, + scenePointerX: number, + scenePointerY: number, + linearElementEditor: LinearElementEditor, + gridSize: NullableGridSize, + elements: readonly Ordered[], + zoom: AppState["zoom"], +): PointsPositionUpdates => { + return new Map( + selectedPointsIndices.map((pointIndex) => { + let newPointPosition: LocalPoint = + pointIndex === lastClickedPoint + ? LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + gridSize, + ) + : pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ); + + if (pointIndex === 0 || pointIndex === element.points.length - 1) { + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + let newGlobalPointPosition = pointRotateRads( + pointFrom( + element.x + newPointPosition[0], + element.y + newPointPosition[1], + ), + pointFrom(cx, cy), + element.angle, + ); + const hoveredElement = getHoveredElementForBinding( + { + x: newGlobalPointPosition[0], + y: newGlobalPointPosition[1], + }, + elements, + elementsMap, + zoom, + true, + isElbowArrow(element), + ); + + const otherBinding = + element[pointIndex === 0 ? "endBinding" : "startBinding"]; + + // Allow binding inside the element if both ends are inside + if ( + isArrowElement(element) && + !( + hoveredElement?.id === otherBinding?.elementId && + hoveredElement != null + ) + ) { + newGlobalPointPosition = getOutlineAvoidingPoint( + element, + hoveredElement, + newGlobalPointPosition, + pointIndex, + elementsMap, + ); + } + + newPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, + newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, + null, + ); + } + + return [ + pointIndex, + { + point: newPointPosition, + isDragging: pointIndex === lastClickedPoint, + }, + ]; + }), + ); +}; + +const pointDraggingOtherEndpoint = ( + element: NonDeleted, + elementsMap: NonDeletedSceneElementsMap, + selectedPointsIndices: readonly number[], + scenePointerX: number, + scenePointerY: number, + linearElementEditor: LinearElementEditor, + scene: Scene, + elements: readonly Ordered[], + zoom: AppState["zoom"], + thresholdCallback: (element: ExcalidrawElement) => number, +) => { + let arrowOtherPoint = linearElementEditor.pointerDownState.arrowOtherPoint; + + if (isArrowElement(element) && !isElbowArrow(element)) { + const startPointIsIncluded = selectedPointsIndices.includes(0); + const endPointIsIncluded = selectedPointsIndices.includes( + element.points.length - 1, + ); + if ( + // Make sure that not both of the endpoints are selected + (startPointIsIncluded || endPointIsIncluded) && + startPointIsIncluded !== endPointIsIncluded + ) { + const otherBinding = + element[startPointIsIncluded ? "endBinding" : "startBinding"]; + if ( + // The other end is bound + otherBinding + ) { + const otherElement = elementsMap.get(otherBinding.elementId); + + invariant( + isBindableElement(otherElement), + "Other element should exist in elementsMap at all times and be a bindable element", + ); + + let newOtherPointPosition; + + // Only avoid shape if the start and end point is not inside + // the same element + if ( + !hitElementItself({ + point: pointFrom(scenePointerX, scenePointerY), + element: otherElement, + elementsMap, + threshold: thresholdCallback(otherElement), + }) + ) { + // If we don't have a restore point, that means we need to jump out + // of the element but first, create the restore point + if (!arrowOtherPoint) { + arrowOtherPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[ + startPointIsIncluded ? element.points.length - 1 : 0 + ], + elementsMap, + ); + } + + // Find a snap point outside the element + const newOtherGlobalPoint = getOutlineAvoidingPoint( + element, + otherElement, + arrowOtherPoint, + startPointIsIncluded ? element.points.length - 1 : 0, + elementsMap, + ); + + newOtherPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + newOtherGlobalPoint[0] - linearElementEditor.pointerOffset.x, + newOtherGlobalPoint[1] - linearElementEditor.pointerOffset.y, + null, + ); + } + // Restore the saved point if we are back inside the element + else if (arrowOtherPoint) { + console.warn("RESTORE"); + + newOtherPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + arrowOtherPoint[0] - linearElementEditor.pointerOffset.x, + arrowOtherPoint[1] - linearElementEditor.pointerOffset.y, + null, + ); + + arrowOtherPoint = undefined; + } + + // Finally, move the other endpoint if needed + if (newOtherPointPosition) { + LinearElementEditor.movePoints( + element, + scene, + new Map([ + [ + startPointIsIncluded ? element.points.length - 1 : 0, + { + point: newOtherPointPosition, + }, + ], + ]), + ); + + bindLinearElement( + element, + otherElement, + startPointIsIncluded ? "end" : "start", + scene, + ); + } + } + } + } + + return arrowOtherPoint; +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index aa5faa9b0..0aadd67c2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8249,6 +8249,7 @@ class App extends React.Component { pointerCoords.x, pointerCoords.y, linearElementEditor, + (element) => this.getElementHitThreshold(element), ); if (newState) { pointerDownState.lastCoords.x = pointerCoords.x;