diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 6e0f4b2851..74a58117ca 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,7 +9,6 @@ import { vectorFromPoint, curveLength, curvePointAtLength, - lineSegment, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -24,10 +23,7 @@ import { } from "@excalidraw/common"; import { - bindingBorderTest, - CaptureUpdateAction, deconstructLinearOrFreeDrawElement, - getHoveredElementForBinding, isPathALoop, moveArrowAboveBindable, type Store, @@ -45,11 +41,9 @@ import type { } from "@excalidraw/excalidraw/types"; import { - getGlobalFixedPointForBindableElement, - getOutlineAvoidingPoint, - isBindingEnabled, + getBindingStrategyForDraggingBindingElementEndpoints, + getStartGlobalEndLocalPointsForSimpleArrowBinding, maybeSuggestBindingsForBindingElementAtCoords, - snapToCenter, } from "./binding"; import { getElementAbsoluteCoords, @@ -59,17 +53,8 @@ import { import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; -import { - getBoundTextElement, - getContainerElement, - handleBindTextResize, -} from "./textElement"; -import { - isBindingElement, - isElbowArrow, - isSimpleArrow, - isTextElement, -} from "./typeChecks"; +import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -84,7 +69,6 @@ import type { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, - ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, @@ -357,31 +341,24 @@ export class LinearElementEditor { event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), customLineAngle, ); - const [x, y] = LinearElementEditor.getPointGlobalCoordinates( - element, - pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - elementsMap, + const reference = pointFrom( + width + referencePoint[0], + height + referencePoint[1], ); + const deltaX = reference[0] - draggingPoint[0]; + const deltaY = reference[1] - draggingPoint[1]; + LinearElementEditor.movePoints( element, app.scene, pointDraggingUpdates( selectedPointsIndices, - 0, - 0, + deltaX, + deltaY, elementsMap, - lastClickedPoint, element, - x, - y, - linearElementEditor, - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), elements, app, - true, ), ); } else { @@ -403,12 +380,7 @@ export class LinearElementEditor { deltaX, deltaY, elementsMap, - lastClickedPoint, element, - scenePointerX, - scenePointerY, - linearElementEditor, - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), elements, app, ), @@ -1948,211 +1920,127 @@ const normalizeSelectedPoints = ( return nextPoints.length ? nextPoints : null; }; -const pointDraggingUpdates = ( +export 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[], app: AppClassProperties, - angleLocked?: boolean, ): PointsPositionUpdates => { - const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true); - const hasMidPoints = - selectedPointsIndices.filter( - (_, idx) => idx > 0 && idx < element.points.length - 1, - ).length > 0; - - const updates = new Map( + const naiveDraggingPoints = 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 ( - isSimpleArrow(element) && - !hasMidPoints && - (pointIndex === 0 || pointIndex === element.points.length - 1) - ) { - let newGlobalPointPosition = pointRotateRads( - pointFrom( - element.x + newPointPosition[0], - element.y + newPointPosition[1], - ), - pointFrom(cx, cy), - element.angle, - ); - const hoveredElement = getHoveredElementForBinding( - newGlobalPointPosition, - elements, - elementsMap, - ); - const otherGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - pointIndex === 0 ? -1 : 0, - elementsMap, - ); - const otherPointInsideElement = - !!hoveredElement && - !!bindingBorderTest(hoveredElement, otherGlobalPoint, elementsMap); - - if ( - isBindingEnabled(app.state) && - isBindingElement(element, false) && - hoveredElement && - app.state.bindMode === "orbit" && - !otherPointInsideElement - ) { - let customIntersector; - if (angleLocked) { - const adjacentPointIndex = - pointIndex === 0 ? 1 : element.points.length - 2; - const globalAdjacentPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - adjacentPointIndex, - elementsMap, - ); - customIntersector = lineSegment( - globalAdjacentPoint, - newGlobalPointPosition, - ); - } - - newGlobalPointPosition = getOutlineAvoidingPoint( - element, - hoveredElement, - element.startBinding - ? snapToCenter( - hoveredElement, - elementsMap, - newGlobalPointPosition, - ) - : newGlobalPointPosition, - pointIndex, - elementsMap, - customIntersector, - ); - } - - newPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, - newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, - null, - ); - - // Update z-index of the arrow - if ( - isBindingEnabled(app.state) && - isBindingElement(element) && - hoveredElement - ) { - const boundTextElement = getBoundTextElement( - hoveredElement, - elementsMap, - ); - const containerElement = isTextElement(hoveredElement) - ? getContainerElement(hoveredElement, elementsMap) - : null; - const newElements = moveArrowAboveBindable( - element, - [ - hoveredElement.id, - boundTextElement?.id, - containerElement?.id, - ].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id), - app.scene, - ); - - app.syncActionResult({ - elements: newElements, - captureUpdate: CaptureUpdateAction.EVENTUALLY, - }); - } - } - return [ pointIndex, { - point: newPointPosition, - isDragging: pointIndex === lastClickedPoint, + point: pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ), + isDragging: true, }, ]; }), ); - if (isSimpleArrow(element)) { - const adjacentPointIndices = - element.points.length === 2 - ? [0, 1] - : element.points.length === 3 - ? [1] - : [1, element.points.length - 2]; - - adjacentPointIndices - .filter((adjacentPointIndex) => - selectedPointsIndices.includes(adjacentPointIndex), - ) - .flatMap((adjacentPointIndex) => - element.points.length === 3 - ? [0, 2] - : adjacentPointIndex === 1 - ? 0 - : element.points.length - 1, - ) - .forEach((pointIndex) => { - const binding = - element[pointIndex === 0 ? "startBinding" : "endBinding"]; - const bindingIsOrbiting = binding?.mode === "orbit"; - if (bindingIsOrbiting) { - const hoveredElement = elementsMap.get( - binding.elementId, - ) as ExcalidrawBindableElement; - const focusGlobalPoint = getGlobalFixedPointForBindableElement( - binding.fixedPoint, - hoveredElement, - elementsMap, - ); - const newGlobalPointPosition = getOutlineAvoidingPoint( - element, - hoveredElement, - snapToCenter(hoveredElement, elementsMap, focusGlobalPoint), - pointIndex, - elementsMap, - ); - const newPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, - newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, - null, - ); - updates.set(pointIndex, { - point: newPointPosition, - isDragging: false, - }); - } - }); + // Linear elements have no special logic + if (!isArrowElement(element) || isElbowArrow(element)) { + return naiveDraggingPoints; } - return updates; + const startIsDragged = selectedPointsIndices.includes(0); + const endIsDragged = selectedPointsIndices.includes( + element.points.length - 1, + ); + + if (startIsDragged === endIsDragged) { + return naiveDraggingPoints; + } + + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + element, + naiveDraggingPoints, + elementsMap, + elements, + app.state, + ); + + const originalStartGlobalPoint = + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[0], + elementsMap, + ); + const originalEndGlobalPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[element.points.length - 1], + elementsMap, + ); + const offsetStartGlobalPoint = startIsDragged + ? pointFrom( + originalStartGlobalPoint[0] + deltaX, + originalStartGlobalPoint[1] + deltaY, + ) + : originalStartGlobalPoint; + const offsetEndGlobalPoint = pointFrom( + originalEndGlobalPoint[0] + deltaX, + originalEndGlobalPoint[1] + deltaY, + ); + const offsetEndLocalPoint = pointFrom( + offsetEndGlobalPoint[0] - offsetStartGlobalPoint[0], + offsetEndGlobalPoint[1] - offsetStartGlobalPoint[1], + ); + + const [startGlobalPoint, endLocalPoint] = + getStartGlobalEndLocalPointsForSimpleArrowBinding( + element, + start, + end, + offsetStartGlobalPoint, + offsetEndLocalPoint, + elementsMap, + ); + const startLocalPoint = LinearElementEditor.pointFromAbsoluteCoords( + element, + startGlobalPoint, + elementsMap, + ); + const finalEndLocalPoint = pointFrom( + endLocalPoint[0] + (startGlobalPoint[0] - element.x), + endLocalPoint[1] + (startGlobalPoint[1] - element.y), + ); + + if (startIsDragged !== endIsDragged) { + moveArrowAboveBindable( + startIsDragged + ? startGlobalPoint + : LinearElementEditor.getPointGlobalCoordinates( + element, + finalEndLocalPoint, + elementsMap, + ), + element, + elements, + elementsMap, + app.scene, + ); + } + + const indices = Array.from( + new Set([0, element.points.length - 1, ...selectedPointsIndices]), + ); + + return new Map( + indices.map((idx) => { + return [ + idx, + idx === 0 + ? { point: startLocalPoint, isDragging: true } + : idx === element.points.length - 1 + ? { point: finalEndLocalPoint, isDragging: true } + : naiveDraggingPoints.get(idx)!, + ]; + }), + ); }; diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index 78bdcb9cce..0bb0cda9c2 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -1,21 +1,23 @@ 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 { ExcalidrawArrowElement, ExcalidrawElement, ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, OrderedExcalidrawElement, } from "./types"; @@ -144,12 +146,37 @@ 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, - bindableIds: string[], + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, scene: Scene, ): readonly OrderedExcalidrawElement[] => { - const elements = scene.getElementsIncludingDeleted(); + 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); @@ -157,9 +184,8 @@ export const moveArrowAboveBindable = ( const updatedElements = Array.from(elements); const arrow = updatedElements.splice(arrowIdx, 1)[0]; updatedElements.splice(bindableIdx, 0, arrow); - syncMovedIndices(elements, arrayToMap([arrow])); - return updatedElements; + scene.replaceAllElements(updatedElements); } return elements; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0a07808584..4a3252dc3d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -243,6 +243,7 @@ import { getBindingStrategyForDraggingBindingElementEndpoints, getStartGlobalEndLocalPointsForSimpleArrowBinding, mutateElement, + pointDraggingUpdates, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -934,19 +935,24 @@ class App extends React.Component { ); if (hoveredElement) { + invariant( + this.state.selectedLinearElement?.elementId === arrow.id, + "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + ); + + // Once the start is set to inside binding, it remains so + const arrowStartIsInside = + this.state.selectedLinearElement.pointerDownState + .arrowStartIsInside || + arrow.startBinding?.elementId === hoveredElement.id; + + // Change the global binding mode flushSync(() => { invariant( - this.state.selectedLinearElement?.elementId === arrow.id, - "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + this.state.selectedLinearElement, + "this.state.selectedLinearElement must exist", ); - // Once the start is set to inside binding, it remains so - const arrowStartIsInside = - this.state.selectedLinearElement.pointerDownState - .arrowStartIsInside || - arrow.startBinding?.elementId === hoveredElement.id; - - // Change the global binding mode this.setState({ bindMode: "inside", selectedLinearElement: { @@ -957,21 +963,36 @@ class App extends React.Component { }, }, }); - - // Make the arrow endpoint "jump" to the cursor - const point = LinearElementEditor.createPointAt( - arrow, - this.scene.getNonDeletedElementsMap(), - x, - y, - isBindingEnabled(this.state) ? this.getEffectiveGridSize() : null, - ); - this.scene.mutateElement(arrow, { - points: startDragged - ? [point, ...arrow.points.slice(1)] - : [...arrow.points.slice(0, -1), point], - }); }); + + const elementsMap = this.scene.getNonDeletedElementsMap(); + const elements = this.scene.getNonDeletedElements(); + const newDraggingPointPosition = LinearElementEditor.createPointAt( + arrow, + elementsMap, + x - this.state.selectedLinearElement.pointerOffset.x, + y - this.state.selectedLinearElement.pointerOffset.y, + this.getEffectiveGridSize(), + ); + const draggingPoint = + arrow.points[ + this.state.selectedLinearElement.pointerDownState.lastClickedPoint + ]; + const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; + LinearElementEditor.movePoints( + arrow, + this.scene, + pointDraggingUpdates( + startDragged ? [0] : endDragged ? [arrow.points.length - 1] : [], + deltaX, + deltaY, + elementsMap, + arrow, + elements, + this, + ), + ); } };