diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 16970b143..882c8b3f3 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -18,14 +18,12 @@ import { import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; import { StoreAction } from "../store"; -import { mutateElbowArrow } from "../element/routing"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], appState: AppState, app: AppClassProperties, ) => { - const elementsMap = app.scene.getNonDeletedElementsMap(); const framesToBeDeleted = new Set( getSelectedElements( elements.filter((el) => isFrameLikeElement(el)), @@ -51,7 +49,7 @@ const deleteSelectedElements = ( endBinding: el.id === bound.endBinding?.elementId ? null : bound.endBinding, }); - mutateElbowArrow(bound, elementsMap, bound.points); + mutateElement(bound, { points: bound.points }); } }); } @@ -208,12 +206,7 @@ export const actionDeleteSelected = register({ : endBindingElement, }; - LinearElementEditor.deletePoints( - element, - selectedPointsIndices, - elementsMap, - appState.zoom, - ); + LinearElementEditor.deletePoints(element, selectedPointsIndices); return { elements, diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 5ee587b20..475aee71e 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => { }, startArrowhead: null, endArrowhead: "arrow", + fixedSegments: null, points: [ pointFrom(0, 0), pointFrom(0, -35), - pointFrom(-90.9, -35), - pointFrom(-90.9, 204.9), - pointFrom(65.1, 204.9), + pointFrom(-90, -35), + pointFrom(-90, 204), + pointFrom(66, 204), ], elbowed: true, }), @@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => { API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal); - const rec1 = h.elements.find((el) => el.id === "rec1"); - expect(rec1?.x).toBeCloseTo(100); - expect(rec1?.y).toBeCloseTo(100); + const rec1 = h.elements.find((el) => el.id === "rec1")!; + expect(rec1.x).toBeCloseTo(100, 0); + expect(rec1.y).toBeCloseTo(100, 0); - const rec2 = h.elements.find((el) => el.id === "rec2"); - expect(rec2?.x).toBeCloseTo(220); - expect(rec2?.y).toBeCloseTo(250); + const rec2 = h.elements.find((el) => el.id === "rec2")!; + expect(rec2.x).toBeCloseTo(220, 0); + expect(rec2.y).toBeCloseTo(250, 0); }); }); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 933960090..34acc01bf 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -24,8 +24,8 @@ import { isElbowArrow, isLinearElement, } from "../element/typeChecks"; -import { mutateElbowArrow } from "../element/routing"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { deepCopyElement } from "../element/newElement"; import { getCommonBoundingBox } from "../element/bounds"; export const actionFlipHorizontal = register({ @@ -134,12 +134,24 @@ const flipElements = ( const { midX, midY } = getCommonBoundingBox(selectedElements); - resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { - flipByX: flipDirection === "horizontal", - flipByY: flipDirection === "vertical", - shouldResizeFromCenter: true, - shouldMaintainAspectRatio: true, - }); + resizeMultipleElements( + selectedElements, + elementsMap, + "nw", + app.scene, + new Map( + Array.from(elementsMap.values()).map((element) => [ + element.id, + deepCopyElement(element), + ]), + ), + { + flipByX: flipDirection === "horizontal", + flipByY: flipDirection === "vertical", + shouldResizeFromCenter: true, + shouldMaintainAspectRatio: true, + }, + ); bindOrUnbindLinearElements( selectedElements.filter(isLinearElement), @@ -181,16 +193,10 @@ const flipElements = ( }), ); elbowArrows.forEach((element) => - mutateElbowArrow( - element, - elementsMap, - element.points, - undefined, - undefined, - { - informMutation: false, - }, - ), + mutateElement(element, { + x: element.x + diffX, + y: element.y + diffY, + }), ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 7e870eec1..17b53e3f5 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -116,10 +116,9 @@ import { calculateFixedPointForElbowArrowBinding, getHoveredElementForBinding, } from "../element/binding"; -import { mutateElbowArrow } from "../element/routing"; import { LinearElementEditor } from "../element/linearElementEditor"; import type { LocalPoint } from "../../math"; -import { pointFrom, vector } from "../../math"; +import { pointFrom } from "../../math"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -1560,152 +1559,162 @@ export const actionChangeArrowType = register({ label: "Change arrow types", trackEvent: false, perform: (elements, appState, value, app) => { - return { - elements: changeProperty(elements, appState, (el) => { - if (!isArrowElement(el)) { - return el; - } - const newElement = newElementWith(el, { - roundness: - value === ARROW_TYPE.round - ? { - type: ROUNDNESS.PROPORTIONAL_RADIUS, - } - : null, - elbowed: value === ARROW_TYPE.elbow, - points: - value === ARROW_TYPE.elbow || el.elbowed - ? [el.points[0], el.points[el.points.length - 1]] - : el.points, - }); - - if (isElbowArrow(newElement)) { - const elementsMap = app.scene.getNonDeletedElementsMap(); + const newElements = changeProperty(elements, appState, (el) => { + if (!isArrowElement(el)) { + return el; + } + const newElement = newElementWith(el, { + roundness: + value === ARROW_TYPE.round + ? { + type: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + elbowed: value === ARROW_TYPE.elbow, + points: + value === ARROW_TYPE.elbow || el.elbowed + ? [el.points[0], el.points[el.points.length - 1]] + : el.points, + }); - app.dismissLinearEditor(); + if (isElbowArrow(newElement)) { + const elementsMap = app.scene.getNonDeletedElementsMap(); - const startGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - newElement, - 0, - elementsMap, - ); - const endGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - newElement, - -1, - elementsMap, - ); - const startHoveredElement = - !newElement.startBinding && - getHoveredElementForBinding( - tupleToCoors(startGlobalPoint), - elements, - elementsMap, - appState.zoom, - true, - ); - const endHoveredElement = - !newElement.endBinding && - getHoveredElementForBinding( - tupleToCoors(endGlobalPoint), - elements, - elementsMap, - appState.zoom, - true, - ); - const startElement = startHoveredElement - ? startHoveredElement - : newElement.startBinding && - (elementsMap.get( - newElement.startBinding.elementId, - ) as ExcalidrawBindableElement); - const endElement = endHoveredElement - ? endHoveredElement - : newElement.endBinding && - (elementsMap.get( - newElement.endBinding.elementId, - ) as ExcalidrawBindableElement); - - const finalStartPoint = startHoveredElement - ? bindPointToSnapToElementOutline( - startGlobalPoint, - endGlobalPoint, - startHoveredElement, - elementsMap, - ) - : startGlobalPoint; - const finalEndPoint = endHoveredElement - ? bindPointToSnapToElementOutline( - endGlobalPoint, - startGlobalPoint, - endHoveredElement, - elementsMap, - ) - : endGlobalPoint; + app.dismissLinearEditor(); - startHoveredElement && - bindLinearElement( - newElement, + const startGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + const endGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + -1, + elementsMap, + ); + const startHoveredElement = + !newElement.startBinding && + getHoveredElementForBinding( + tupleToCoors(startGlobalPoint), + elements, + elementsMap, + appState.zoom, + ); + const endHoveredElement = + !newElement.endBinding && + getHoveredElementForBinding( + tupleToCoors(endGlobalPoint), + elements, + elementsMap, + appState.zoom, + ); + const startElement = startHoveredElement + ? startHoveredElement + : newElement.startBinding && + (elementsMap.get( + newElement.startBinding.elementId, + ) as ExcalidrawBindableElement); + const endElement = endHoveredElement + ? endHoveredElement + : newElement.endBinding && + (elementsMap.get( + newElement.endBinding.elementId, + ) as ExcalidrawBindableElement); + + const finalStartPoint = startHoveredElement + ? bindPointToSnapToElementOutline( + startGlobalPoint, + endGlobalPoint, startHoveredElement, - "start", elementsMap, - ); - endHoveredElement && - bindLinearElement( - newElement, + ) + : startGlobalPoint; + const finalEndPoint = endHoveredElement + ? bindPointToSnapToElementOutline( + endGlobalPoint, + startGlobalPoint, endHoveredElement, - "end", elementsMap, - ); + ) + : endGlobalPoint; - mutateElbowArrow( + startHoveredElement && + bindLinearElement( newElement, + startHoveredElement, + "start", elementsMap, - [finalStartPoint, finalEndPoint].map( - (p): LocalPoint => - pointFrom(p[0] - newElement.x, p[1] - newElement.y), - ), - vector(0, 0), - { - ...(startElement && newElement.startBinding - ? { - startBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.startBinding!, - ...calculateFixedPointForElbowArrowBinding( - newElement, - startElement, - "start", - elementsMap, - ), - }, - } - : {}), - ...(endElement && newElement.endBinding - ? { - endBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.endBinding, - ...calculateFixedPointForElbowArrowBinding( - newElement, - endElement, - "end", - elementsMap, - ), - }, - } - : {}), - }, ); - } + endHoveredElement && + bindLinearElement(newElement, endHoveredElement, "end", elementsMap); + + mutateElement(newElement, { + points: [finalStartPoint, finalEndPoint].map( + (p): LocalPoint => + pointFrom(p[0] - newElement.x, p[1] - newElement.y), + ), + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }); - return newElement; - }), - appState: { - ...appState, - currentItemArrowType: value, - }, + LinearElementEditor.updateEditorMidPointsCache( + newElement, + elementsMap, + app.state, + ); + } + + return newElement; + }); + + const newState = { + ...appState, + currentItemArrowType: value, + }; + + // Change the arrow type and update any other state settings for + // the arrow. + const selectedId = appState.selectedLinearElement?.elementId; + if (selectedId) { + const selected = newElements.find((el) => el.id === selectedId); + if (selected) { + newState.selectedLinearElement = new LinearElementEditor( + selected as ExcalidrawLinearElement, + ); + } + } + + return { + elements: newElements, + appState: newState, storeAction: StoreAction.CAPTURE, }; }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6912bbba0..ff8c273b7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -165,6 +165,7 @@ import { isTextBindableContainer, isElbowArrow, isFlowchartNodeElement, + isBindableElement, } from "../element/typeChecks"; import type { ExcalidrawBindableElement, @@ -189,7 +190,6 @@ import type { MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, - NonDeletedSceneElementsMap, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -292,7 +292,6 @@ import { getDateTime, isShallowEqual, arrayToMap, - toBrandedType, } from "../utils"; import { createSrcDoc, @@ -443,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { mutateElbowArrow, updateElbowArrow } from "../element/routing"; import { FlowChartCreator, FlowChartNavigator, @@ -3184,49 +3182,7 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - let elements = opts.elements.map((el, _, elements) => { - if (isElbowArrow(el)) { - const startEndElements = [ - el.startBinding && - elements.find((l) => l.id === el.startBinding?.elementId), - el.endBinding && - elements.find((l) => l.id === el.endBinding?.elementId), - ]; - const startBinding = startEndElements[0] ? el.startBinding : null; - const endBinding = startEndElements[1] ? el.endBinding : null; - return { - ...el, - ...updateElbowArrow( - { - ...el, - startBinding, - endBinding, - }, - toBrandedType( - new Map( - startEndElements - .filter((x) => x != null) - .map( - (el) => - [el!.id, el] as [ - string, - Ordered, - ], - ), - ), - ), - [el.points[0], el.points[el.points.length - 1]], - undefined, - { - zoom: this.state.zoom, - }, - ), - }; - } - - return el; - }); - elements = restoreElements(elements, null, undefined); + const elements = restoreElements(opts.elements, null, undefined); const [minX, minY, maxX, maxY] = getCommonBounds(elements); const elementsCenterX = distance(minX, maxX) / 2; @@ -4377,7 +4333,6 @@ class App extends React.Component { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { simultaneouslyUpdated: selectedElements, - zoom: this.state.zoom, }); }); @@ -5365,6 +5320,11 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); + let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + event, + this.state, + ); + if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if ( event[KEYS.CTRL_OR_CMD] && @@ -5378,6 +5338,64 @@ class App extends React.Component { editingLinearElement: new LinearElementEditor(selectedElements[0]), }); return; + } else if ( + this.state.selectedLinearElement && + isElbowArrow(selectedElements[0]) + ) { + const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords( + this.state.selectedLinearElement, + { x: sceneX, y: sceneY }, + this.state, + this.scene.getNonDeletedElementsMap(), + ); + const midPoint = hitCoords + ? LinearElementEditor.getSegmentMidPointIndex( + this.state.selectedLinearElement, + this.state, + hitCoords, + this.scene.getNonDeletedElementsMap(), + ) + : -1; + + if (midPoint && midPoint > -1) { + this.store.shouldCaptureIncrement(); + LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint); + + const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( + { + ...this.state.selectedLinearElement, + segmentMidPointHoveredCoords: null, + }, + { x: sceneX, y: sceneY }, + this.state, + this.scene.getNonDeletedElementsMap(), + ); + const nextIndex = nextCoords + ? LinearElementEditor.getSegmentMidPointIndex( + this.state.selectedLinearElement, + this.state, + nextCoords, + this.scene.getNonDeletedElementsMap(), + ) + : null; + + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement.pointerDownState, + segmentMidpoint: { + index: nextIndex, + value: hitCoords, + added: false, + }, + }, + segmentMidPointHoveredCoords: nextCoords, + }, + }); + + return; + } } } @@ -5388,11 +5406,6 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); - let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - event, - this.state, - ); - const selectedGroupIds = getSelectedGroupIds(this.state); if (selectedGroupIds.length > 0) { @@ -5849,41 +5862,23 @@ class App extends React.Component { if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - if (isElbowArrow(multiElement)) { - mutateElbowArrow( - multiElement, - this.scene.getNonDeletedElementsMap(), - [ + // update last uncommitted point + mutateElement( + multiElement, + { + points: [ ...points.slice(0, -1), pointFrom( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, ), ], - undefined, - undefined, - { - isDragging: true, - informMutation: false, - zoom: this.state.zoom, - }, - ); - } else { - // update last uncommitted point - mutateElement( - multiElement, - { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], - }, - false, - ); - } + }, + false, + { + isDragging: true, + }, + ); // in this path, we're mutating multiElement to reflect // how it will be after adding pointer position as the next point @@ -6049,7 +6044,7 @@ class App extends React.Component { this.setState({ activeEmbeddable: { element: hitElement, state: "hover" }, }); - } else { + } else if (!hitElement || !isElbowArrow(hitElement)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (this.state.activeEmbeddable?.state === "hover") { this.setState({ activeEmbeddable: null }); @@ -6235,14 +6230,18 @@ class App extends React.Component { this.state, this.scene.getNonDeletedElementsMap(), ); - - if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { + const isHoveringAPointHandle = isElbowArrow(element) + ? hoverPointIndex === 0 || + hoverPointIndex === element.points.length - 1 + : hoverPointIndex >= 0; + if (isHoveringAPointHandle || segmentMidPointHoveredCoords) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if (this.hitElement(scenePointerX, scenePointerY, element)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( + // Ebow arrows can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { @@ -6972,6 +6971,7 @@ class App extends React.Component { if ( selectedElements.length === 1 && !this.state.editingLinearElement && + !isElbowArrow(selectedElements[0]) && !( this.state.selectedLinearElement && this.state.selectedLinearElement.hoverPointIndex !== -1 @@ -7673,6 +7673,10 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, + fixedSegments: + this.state.currentItemArrowType === ARROW_TYPE.elbow + ? [] + : null, }) : newLinearElement({ type: elementType, @@ -7913,6 +7917,63 @@ class App extends React.Component { return; } const pointerCoords = viewportCoordsToSceneCoords(event, this.state); + + if ( + this.state.selectedLinearElement && + this.state.selectedLinearElement.elbowed && + this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index + ) { + const [gridX, gridY] = getGridPoint( + pointerCoords.x, + pointerCoords.y, + event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), + ); + + let index = + this.state.selectedLinearElement.pointerDownState.segmentMidpoint + .index; + if (index < 0) { + const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( + { + ...this.state.selectedLinearElement, + segmentMidPointHoveredCoords: null, + }, + { x: gridX, y: gridY }, + this.state, + this.scene.getNonDeletedElementsMap(), + ); + index = nextCoords + ? LinearElementEditor.getSegmentMidPointIndex( + this.state.selectedLinearElement, + this.state, + nextCoords, + this.scene.getNonDeletedElementsMap(), + ) + : -1; + } + + const ret = LinearElementEditor.moveFixedSegment( + this.state.selectedLinearElement, + index, + gridX, + gridY, + this.scene.getNonDeletedElementsMap(), + ); + + flushSync(() => { + if (this.state.selectedLinearElement) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, + pointerDownState: ret.pointerDownState, + }, + }); + } + }); + return; + } + const lastPointerCoords = this.lastPointerMoveCoords ?? pointerDownState.origin; this.lastPointerMoveCoords = pointerCoords; @@ -8265,7 +8326,7 @@ class App extends React.Component { // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input - !this.state.editingFrame && + if (!this.state.editingFrame) { dragSelectedElements( pointerDownState, selectedElements, @@ -8274,6 +8335,7 @@ class App extends React.Component { snapOffset, event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); + } this.setState({ selectedElementsAreBeingDragged: true, @@ -8449,26 +8511,17 @@ class App extends React.Component { }, false, ); - } else if (points.length > 1 && isElbowArrow(newElement)) { - mutateElbowArrow( - newElement, - elementsMap, - [...points.slice(0, -1), pointFrom(dx, dy)], - vector(0, 0), - undefined, - { - isDragging: true, - informMutation: false, - zoom: this.state.zoom, - }, - ); - } else if (points.length === 2) { + } else if ( + points.length === 2 || + (points.length > 1 && isElbowArrow(newElement)) + ) { mutateElement( newElement, { points: [...points.slice(0, -1), pointFrom(dx, dy)], }, false, + { isDragging: true }, ); } @@ -8663,6 +8716,24 @@ class App extends React.Component { selectedElementsAreBeingDragged: false, }); const elementsMap = this.scene.getNonDeletedElementsMap(); + + if ( + pointerDownState.drag.hasOccurred && + pointerDownState.hit?.element?.id + ) { + const element = elementsMap.get(pointerDownState.hit.element.id); + if (isBindableElement(element)) { + // Renormalize elbow arrows when they are changed via indirect move + element.boundElements + ?.filter((e) => e.type === "arrow") + .map((e) => elementsMap.get(e.id)) + .filter((e) => isElbowArrow(e)) + .forEach((e) => { + !!e && mutateElement(e, {}, true); + }); + } + } + // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { @@ -8687,6 +8758,17 @@ class App extends React.Component { } } } else if (this.state.selectedLinearElement) { + // Normalize elbow arrow points, remove close parallel segments + if (this.state.selectedLinearElement.elbowed) { + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + this.scene.getNonDeletedElementsMap(), + ); + if (element) { + mutateElement(element, {}, true); + } + } + if ( pointerDownState.hit?.element?.id !== this.state.selectedLinearElement.elementId @@ -9126,10 +9208,10 @@ class App extends React.Component { this.state.selectedLinearElement?.elementId !== hitElement?.id && isLinearElement(hitElement) ) { - const selectedELements = this.scene.getSelectedElements(this.state); + const selectedElements = this.scene.getSelectedElements(this.state); // set selectedLinearElement when no other element selected except // the one we've hit - if (selectedELements.length === 1) { + if (selectedElements.length === 1) { this.setState({ selectedLinearElement: new LinearElementEditor(hitElement), }); @@ -9337,6 +9419,8 @@ class App extends React.Component { } if ( + // not elbow midpoint dragged + !(hitElement && isElbowArrow(hitElement)) && // not dragged !pointerDownState.drag.hasOccurred && // not resized diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 7f6bab535..42695b413 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -1,5 +1,6 @@ import type { ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, ExcalidrawElement, ExcalidrawElementType, ExcalidrawLinearElement, @@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { return DEFAULT_FONT_FAMILY; }; -const repairBinding = ( - element: ExcalidrawLinearElement, +const repairBinding = ( + element: T, binding: PointBinding | FixedPointBinding | null, -): PointBinding | FixedPointBinding | null => { +): T extends ExcalidrawElbowArrowElement + ? FixedPointBinding | null + : PointBinding | FixedPointBinding | null => { if (!binding) { return null; } - return { - ...binding, - focus: binding.focus || 0, - ...(isElbowArrow(element) && isFixedPointBinding(binding) + 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; + + return fixedPointBinding; + } + + return { + ...binding, + focus, + } as T extends ExcalidrawElbowArrowElement + ? FixedPointBinding | null + : PointBinding | FixedPointBinding | null; }; const restoreElementWithProperties = < @@ -308,8 +324,7 @@ const restoreElement = ( ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); } - // TODO: Separate arrow from linear element - return restoreElementWithProperties(element as ExcalidrawArrowElement, { + const base = { type: element.type, startBinding: repairBinding(element, element.startBinding), endBinding: repairBinding(element, element.endBinding), @@ -321,7 +336,20 @@ const restoreElement = ( y, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), - }); + } as const; + + // TODO: Separate arrow from linear element + return isElbowArrow(element) + ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, { + ...base, + elbowed: true, + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), + fixedSegments: element.fixedSegments, + startIsSpecial: element.startIsSpecial, + endIsSpecial: element.endIsSpecial, + }) + : restoreElementWithProperties(element as ExcalidrawArrowElement, base); } // generic elements diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 897641bfc..615a484a4 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -623,11 +623,9 @@ export const updateBoundElements = ( simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; changedElements?: Map; - zoom?: AppState["zoom"]; }, ) => { - const { newSize, simultaneouslyUpdated, changedElements, zoom } = - options ?? {}; + const { newSize, simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -661,7 +659,7 @@ export const updateBoundElements = ( // `linearElement` is being moved/scaled already, just update the binding if (simultaneouslyUpdatedElementIds.has(element.id)) { - mutateElement(element, bindings); + mutateElement(element, bindings, true); return; } @@ -703,23 +701,14 @@ export const updateBoundElements = ( }> => update !== null, ); - LinearElementEditor.movePoints( - element, - updates, - elementsMap, - { - ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } - : {}), - ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } - : {}), - }, - { - changedElements, - zoom, - }, - ); + LinearElementEditor.movePoints(element, updates, { + ...(changedElement.id === element.startBinding?.elementId + ? { startBinding: bindings.startBinding } + : {}), + ...(changedElement.id === element.endBinding?.elementId + ? { endBinding: bindings.endBinding } + : {}), + }); const boundText = getBoundTextElement(element, elementsMap); if (boundText && !boundText.isDeleted) { @@ -778,9 +767,7 @@ export const getHeadingForElbowArrowSnap = ( ); } - const pointHeading = headingForPointFromElement(bindableElement, aabb, p); - - return pointHeading; + return headingForPointFromElement(bindableElement, aabb, p); }; const getDistanceForBinding = ( @@ -2283,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = ( ); }; -const getGlobalFixedPoints = ( +export const getGlobalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, ): [GlobalPoint, GlobalPoint] => { diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index f773a7a06..1fd771ba4 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -42,9 +42,20 @@ export const dragSelectedElements = ( return; } - const selectedElements = _selectedElements.filter( - (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), - ); + const selectedElements = _selectedElements.filter((element) => { + if (isElbowArrow(element) && element.startBinding && element.endBinding) { + const startElement = _selectedElements.find( + (el) => el.id === element.startBinding?.elementId, + ); + const endElement = _selectedElements.find( + (el) => el.id === element.endBinding?.elementId, + ); + + return startElement && endElement; + } + + return true; + }); // we do not want a frame and its elements to be selected at the same time // but when it happens (due to some bug), we want to avoid updating element @@ -78,10 +89,8 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); - if ( + if (!isArrowElement(element)) { // skip arrow labels since we calculate its position during render - !isArrowElement(element) - ) { const textElement = getBoundTextElement( element, scene.getNonDeletedElementsMap(), @@ -89,10 +98,10 @@ export const dragSelectedElements = ( if (textElement) { updateElementCoords(pointerDownState, textElement, adjustedOffset); } + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { + simultaneouslyUpdated: Array.from(elementsToUpdate), + }); } - updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { - simultaneouslyUpdated: Array.from(elementsToUpdate), - }); }); }; diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/elbowArrow.test.tsx similarity index 63% rename from packages/excalidraw/element/routing.test.tsx rename to packages/excalidraw/element/elbowArrow.test.tsx index fb6b23f28..fd5682d28 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/packages/excalidraw/element/elbowArrow.test.tsx @@ -9,20 +9,121 @@ import { render, } from "../tests/test-utils"; import { bindLinearElement } from "./binding"; -import { Excalidraw } from "../index"; -import { mutateElbowArrow } from "./routing"; +import { Excalidraw, mutateElement } from "../index"; import type { ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElbowArrowElement, } from "./types"; import { ARROW_TYPE } from "../constants"; +import type { LocalPoint } from "../../math"; import { pointFrom } from "../../math"; const { h } = window; const mouse = new Pointer("mouse"); +describe("elbow arrow segment move", () => { + beforeEach(async () => { + localStorage.clear(); + await render(); + }); + + it("can move the second segment of a fully connected elbow arrow", () => { + UI.createElement("rectangle", { + x: -100, + y: -50, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 200, + y: 150, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(0, 0); + mouse.click(); + mouse.moveTo(200, 200); + mouse.click(); + + mouse.reset(); + mouse.moveTo(100, 100); + mouse.down(); + mouse.moveTo(115, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawElbowArrowElement; + + expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true }); + expect(arrow.fixedSegments?.length).toBe(1); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [110, 0], + [110, 200], + [190, 200], + ]); + + mouse.reset(); + mouse.moveTo(105, 74.275); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [110, 0], + [110, 200], + [190, 200], + ]); + }); + + it("can move the second segment of an unconnected elbow arrow", () => { + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(0, 0); + mouse.click(); + mouse.moveTo(250, 200); + mouse.click(); + + mouse.reset(); + mouse.moveTo(125, 100); + mouse.down(); + mouse.moveTo(130, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [130, 0], + [130, 200], + [250, 200], + ]); + + mouse.reset(); + mouse.moveTo(130, 100); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [125, 0], + [125, 200], + [250, 200], + ]); + }); +}); + describe("elbow arrow routing", () => { it("can properly generate orthogonal arrow points", () => { const scene = new Scene(); @@ -31,10 +132,12 @@ describe("elbow arrow routing", () => { elbowed: true, }) as ExcalidrawElbowArrowElement; scene.insertElement(arrow); - mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ - pointFrom(-45 - arrow.x, -100.1 - arrow.y), - pointFrom(45 - arrow.x, 99.9 - arrow.y), - ]); + mutateElement(arrow, { + points: [ + pointFrom(-45 - arrow.x, -100.1 - arrow.y), + pointFrom(45 - arrow.x, 99.9 - arrow.y), + ], + }); expect(arrow.points).toEqual([ [0, 0], [0, 100], @@ -81,7 +184,9 @@ describe("elbow arrow routing", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]); + mutateElement(arrow, { + points: [pointFrom(0, 0), pointFrom(90, 200)], + }); expect(arrow.points).toEqual([ [0, 0], @@ -182,8 +287,6 @@ describe("elbow arrow ui", () => { expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ [0, 0], [35, 0], - [35, 90], - [35, 90], // Note that coordinates are rounded above! [35, 165], [103, 165], ]); diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts new file mode 100644 index 000000000..91b0bfe9b --- /dev/null +++ b/packages/excalidraw/element/elbowArrow.ts @@ -0,0 +1,2111 @@ +import { + pointDistance, + pointFrom, + pointScaleFromOrigin, + pointsEqual, + pointTranslate, + vector, + vectorCross, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "../../math"; +import BinaryHeap from "../binaryheap"; +import { getSizeFromPoints } from "../points"; +import { aabbForElement, pointInsideBounds } from "../shapes"; +import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import type { AppState } from "../types"; +import { + bindPointToSnapToElementOutline, + distanceToBindableElement, + avoidRectangularCorner, + getHoveredElementForBinding, + FIXED_BINDING_DISTANCE, + getHeadingForElbowArrowSnap, + getGlobalFixedPointForBindableElement, + snapToMid, +} from "./binding"; +import type { Bounds } from "./bounds"; +import type { Heading } from "./heading"; +import { + compareHeading, + flipHeading, + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, + headingForPointIsHorizontal, + headingIsHorizontal, + vectorToHeading, + headingForPoint, +} from "./heading"; +import { type ElementUpdate } from "./mutateElement"; +import { isBindableElement, isRectanguloidElement } from "./typeChecks"; +import { + type ExcalidrawElbowArrowElement, + type NonDeletedSceneElementsMap, + type SceneElementsMap, +} from "./types"; +import type { + Arrowhead, + ElementsMap, + ExcalidrawBindableElement, + FixedPointBinding, + FixedSegment, +} from "./types"; + +type GridAddress = [number, number] & { _brand: "gridaddress" }; + +type Node = { + f: number; + g: number; + h: number; + closed: boolean; + visited: boolean; + parent: Node | null; + pos: GlobalPoint; + addr: GridAddress; +}; + +type Grid = { + row: number; + col: number; + data: (Node | null)[]; +}; + +type ElbowArrowState = { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; +}; + +type ElbowArrowData = { + dynamicAABBs: Bounds[]; + startDonglePosition: GlobalPoint | null; + startGlobalPoint: GlobalPoint; + startHeading: Heading; + endDonglePosition: GlobalPoint | null; + endGlobalPoint: GlobalPoint; + endHeading: Heading; + commonBounds: Bounds; + hoveredStartElement: ExcalidrawBindableElement | null; + hoveredEndElement: ExcalidrawBindableElement | null; +}; + +const DEDUP_TRESHOLD = 1; +export const BASE_PADDING = 40; + +const handleSegmentRenormalization = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, +) => { + const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments + ? structuredClone(arrow.fixedSegments) + : null; + + if (nextFixedSegments) { + const _nextPoints: GlobalPoint[] = []; + + arrow.points + .map((p) => pointFrom(arrow.x + p[0], arrow.y + p[1])) + .forEach((p, i, points) => { + if (i < 2) { + return _nextPoints.push(p); + } + + const currentSegmentIsHorizontal = headingForPoint(p, points[i - 1]); + const prevSegmentIsHorizontal = headingForPoint( + points[i - 1], + points[i - 2], + ); + + if ( + // Check if previous two points are on the same line + compareHeading(currentSegmentIsHorizontal, prevSegmentIsHorizontal) + ) { + const prevSegmentIdx = + nextFixedSegments?.findIndex( + (segment) => segment.index === i - 1, + ) ?? -1; + const segmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i) ?? + -1; + + // If the current segment is a fixed segment, update its start point + if (segmentIdx !== -1) { + nextFixedSegments[segmentIdx].start = pointFrom( + points[i - 2][0] - arrow.x, + points[i - 2][1] - arrow.y, + ); + } + + // Remove the fixed segment status from the previous segment if it is + // a fixed segment, because we are going to unify that segment with + // the current one + if (prevSegmentIdx !== -1) { + nextFixedSegments.splice(prevSegmentIdx, 1); + } + + // Remove the duplicate point + _nextPoints.splice(-1, 1); + + // Update fixed point indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i - 1) { + segment.index -= 1; + } + }); + } + + return _nextPoints.push(p); + }); + + const nextPoints: GlobalPoint[] = []; + + _nextPoints.forEach((p, i, points) => { + if (i < 3) { + return nextPoints.push(p); + } + + if ( + // Remove segments that are too short + pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD + ) { + const prevPrevSegmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ?? + -1; + const prevSegmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i - 1) ?? + -1; + + // Remove the previous fixed segment if it exists (i.e. the segment + // which will be removed due to being parallel or too short) + if (prevSegmentIdx !== -1) { + nextFixedSegments.splice(prevSegmentIdx, 1); + } + + // Remove the fixed segment status from the segment 2 steps back + // if it is a fixed segment, because we are going to unify that + // segment with the current one + if (prevPrevSegmentIdx !== -1) { + nextFixedSegments.splice(prevPrevSegmentIdx, 1); + } + + nextPoints.splice(-2, 2); + + // Since we have to remove two segments, update any fixed segment + nextFixedSegments.forEach((segment) => { + if (segment.index > i - 2) { + segment.index -= 2; + } + }); + + // Remove aligned segment points + const isHorizontal = headingForPointIsHorizontal(p, points[i - 1]); + + return nextPoints.push( + pointFrom( + !isHorizontal ? points[i - 2][0] : p[0], + isHorizontal ? points[i - 2][1] : p[1], + ), + ); + } + + nextPoints.push(p); + }); + + const filteredNextFixedSegments = nextFixedSegments.filter( + (segment) => + segment.index !== 1 && segment.index !== nextPoints.length - 1, + ); + if (filteredNextFixedSegments.length === 0) { + return normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow( + arrow, + getElbowArrowData( + arrow, + elementsMap, + nextPoints.map((p) => + pointFrom(p[0] - arrow.x, p[1] - arrow.y), + ), + ), + ) ?? [], + ), + ), + filteredNextFixedSegments, + null, + null, + ); + } + + return normalizeArrowElementUpdate( + nextPoints, + filteredNextFixedSegments, + arrow.startIsSpecial, + arrow.endIsSpecial, + ); + } + + return { + x: arrow.x, + y: arrow.y, + points: arrow.points, + fixedSegments: arrow.fixedSegments, + startIsSpecial: arrow.startIsSpecial, + endIsSpecial: arrow.endIsSpecial, + }; +}; + +const handleSegmentRelease = ( + arrow: ExcalidrawElbowArrowElement, + fixedSegments: FixedSegment[], + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, +) => { + const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index); + const oldFixedSegmentIndices = + arrow.fixedSegments?.map((segment) => segment.index) ?? []; + const deletedSegmentIdx = oldFixedSegmentIndices.findIndex( + (idx) => !newFixedSegmentIndices.includes(idx), + ); + + if (deletedSegmentIdx === -1 || !arrow.fixedSegments?.[deletedSegmentIdx]) { + return { + points: arrow.points, + }; + } + + const deletedIdx = arrow.fixedSegments[deletedSegmentIdx].index; + + // Find prev and next fixed segments + const prevSegment = arrow.fixedSegments[deletedSegmentIdx - 1]; + const nextSegment = arrow.fixedSegments[deletedSegmentIdx + 1]; + + // We need to render a sub-arrow path to restore deleted segments + const x = arrow.x + (prevSegment ? prevSegment.end[0] : 0); + const y = arrow.y + (prevSegment ? prevSegment.end[1] : 0); + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData( + { + x, + y, + startBinding: prevSegment ? null : arrow.startBinding, + endBinding: nextSegment ? null : arrow.endBinding, + startArrowhead: null, + endArrowhead: null, + }, + elementsMap, + [ + pointFrom(0, 0), + pointFrom( + arrow.x + + (nextSegment?.start[0] ?? arrow.points[arrow.points.length - 1][0]) - + x, + arrow.y + + (nextSegment?.start[1] ?? arrow.points[arrow.points.length - 1][1]) - + y, + ), + ], + { isDragging: false }, + ); + + const { points: restoredPoints } = normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow(arrow, { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest, + }) ?? [], + ), + ), + fixedSegments, + null, + null, + ); + + const nextPoints: GlobalPoint[] = []; + + // First part of the arrow are the old points + if (prevSegment) { + for (let i = 0; i < prevSegment.index; i++) { + nextPoints.push( + pointFrom( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + } + } + + restoredPoints.forEach((p) => { + nextPoints.push( + pointFrom( + arrow.x + (prevSegment ? prevSegment.end[0] : 0) + p[0], + arrow.y + (prevSegment ? prevSegment.end[1] : 0) + p[1], + ), + ); + }); + + // Last part of the arrow are the old points too + if (nextSegment) { + for (let i = nextSegment.index; i < arrow.points.length; i++) { + nextPoints.push( + pointFrom( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + } + } + + // Update nextFixedSegments + const originalSegmentCountDiff = + (nextSegment?.index ?? arrow.points.length) - (prevSegment?.index ?? 0) - 1; + + const nextFixedSegments = fixedSegments.map((segment) => { + if (segment.index > deletedIdx) { + return { + ...segment, + index: + segment.index - + originalSegmentCountDiff + + (restoredPoints.length - 1), + }; + } + + return segment; + }); + + const simplifiedPoints = nextPoints.flatMap((p, i) => { + const prev = nextPoints[i - 1]; + const next = nextPoints[i + 1]; + + if (prev && next) { + const prevHeading = headingForPoint(p, prev); + const nextHeading = headingForPoint(next, p); + + if (compareHeading(prevHeading, nextHeading)) { + // Update subsequent fixed segment indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i) { + segment.index -= 1; + } + }); + + return []; + } else if (compareHeading(prevHeading, flipHeading(nextHeading))) { + // Update subsequent fixed segment indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i) { + segment.index += 1; + } + }); + + return [p, p]; + } + } + + return [p]; + }); + + return normalizeArrowElementUpdate( + simplifiedPoints, + nextFixedSegments, + false, + false, + ); +}; + +/** + * + */ +const handleSegmentMove = ( + arrow: ExcalidrawElbowArrowElement, + fixedSegments: FixedSegment[], + startHeading: Heading, + endHeading: Heading, + hoveredStartElement: ExcalidrawBindableElement | null, + hoveredEndElement: ExcalidrawBindableElement | null, +): ElementUpdate => { + const activelyModifiedSegmentIdx = fixedSegments + .map((segment, i) => { + if ( + arrow.fixedSegments == null || + arrow.fixedSegments[i] === undefined || + arrow.fixedSegments[i].index !== segment.index + ) { + return i; + } + + return (segment.start[0] !== arrow.fixedSegments![i].start[0] && + segment.end[0] !== arrow.fixedSegments![i].end[0]) !== + (segment.start[1] !== arrow.fixedSegments![i].start[1] && + segment.end[1] !== arrow.fixedSegments![i].end[1]) + ? i + : null; + }) + .filter((idx) => idx !== null) + .shift(); + + if (activelyModifiedSegmentIdx == null) { + return { points: arrow.points }; + } + + const firstSegmentIdx = + arrow.fixedSegments?.findIndex((segment) => segment.index === 1) ?? -1; + const lastSegmentIdx = + arrow.fixedSegments?.findIndex( + (segment) => segment.index === arrow.points.length - 1, + ) ?? -1; + + // Handle special case for first segment move + const segmentLength = pointDistance( + fixedSegments[activelyModifiedSegmentIdx].start, + fixedSegments[activelyModifiedSegmentIdx].end, + ); + const segmentIsTooShort = segmentLength < BASE_PADDING + 5; + if ( + firstSegmentIdx === -1 && + fixedSegments[activelyModifiedSegmentIdx].index === 1 && + hoveredStartElement + ) { + const startIsHorizontal = headingIsHorizontal(startHeading); + const startIsPositive = startIsHorizontal + ? compareHeading(startHeading, HEADING_RIGHT) + : compareHeading(startHeading, HEADING_DOWN); + const padding = startIsPositive + ? segmentIsTooShort + ? segmentLength / 2 + : BASE_PADDING + : segmentIsTooShort + ? -segmentLength / 2 + : -BASE_PADDING; + fixedSegments[activelyModifiedSegmentIdx].start = pointFrom( + fixedSegments[activelyModifiedSegmentIdx].start[0] + + (startIsHorizontal ? padding : 0), + fixedSegments[activelyModifiedSegmentIdx].start[1] + + (!startIsHorizontal ? padding : 0), + ); + } + + // Handle special case for last segment move + if ( + lastSegmentIdx === -1 && + fixedSegments[activelyModifiedSegmentIdx].index === + arrow.points.length - 1 && + hoveredEndElement + ) { + const endIsHorizontal = headingIsHorizontal(endHeading); + const endIsPositive = endIsHorizontal + ? compareHeading(endHeading, HEADING_RIGHT) + : compareHeading(endHeading, HEADING_DOWN); + const padding = endIsPositive + ? segmentIsTooShort + ? segmentLength / 2 + : BASE_PADDING + : segmentIsTooShort + ? -segmentLength / 2 + : -BASE_PADDING; + fixedSegments[activelyModifiedSegmentIdx].end = pointFrom( + fixedSegments[activelyModifiedSegmentIdx].end[0] + + (endIsHorizontal ? padding : 0), + fixedSegments[activelyModifiedSegmentIdx].end[1] + + (!endIsHorizontal ? padding : 0), + ); + } + + // Translate all fixed segments to global coordinates + const nextFixedSegments = fixedSegments.map((segment) => ({ + ...segment, + start: pointFrom( + arrow.x + segment.start[0], + arrow.y + segment.start[1], + ), + end: pointFrom( + arrow.x + segment.end[0], + arrow.y + segment.end[1], + ), + })); + + // For start, clone old arrow points + const newPoints: GlobalPoint[] = arrow.points.map((p, i) => + pointFrom(arrow.x + p[0], arrow.y + p[1]), + ); + + const startIdx = nextFixedSegments[activelyModifiedSegmentIdx].index - 1; + const endIdx = nextFixedSegments[activelyModifiedSegmentIdx].index; + const start = nextFixedSegments[activelyModifiedSegmentIdx].start; + const end = nextFixedSegments[activelyModifiedSegmentIdx].end; + const prevSegmentIsHorizontal = + newPoints[startIdx - 1] && + !pointsEqual(newPoints[startIdx], newPoints[startIdx - 1]) + ? headingForPointIsHorizontal( + newPoints[startIdx - 1], + newPoints[startIdx], + ) + : undefined; + const nextSegmentIsHorizontal = + newPoints[endIdx + 1] && + !pointsEqual(newPoints[endIdx], newPoints[endIdx + 1]) + ? headingForPointIsHorizontal(newPoints[endIdx + 1], newPoints[endIdx]) + : undefined; + + // Override the segment points with the actively moved fixed segment + if (prevSegmentIsHorizontal !== undefined) { + const dir = prevSegmentIsHorizontal ? 1 : 0; + newPoints[startIdx - 1][dir] = start[dir]; + } + newPoints[startIdx] = start; + newPoints[endIdx] = end; + if (nextSegmentIsHorizontal !== undefined) { + const dir = nextSegmentIsHorizontal ? 1 : 0; + newPoints[endIdx + 1][dir] = end[dir]; + } + + // Override neighboring fixedSegment start/end points, if any + const prevSegmentIdx = nextFixedSegments.findIndex( + (segment) => segment.index === startIdx, + ); + if (prevSegmentIdx !== -1) { + // Align the next segment points with the moved segment + const dir = headingForPointIsHorizontal( + nextFixedSegments[prevSegmentIdx].end, + nextFixedSegments[prevSegmentIdx].start, + ) + ? 1 + : 0; + nextFixedSegments[prevSegmentIdx].start[dir] = start[dir]; + nextFixedSegments[prevSegmentIdx].end = start; + } + + const nextSegmentIdx = nextFixedSegments.findIndex( + (segment) => segment.index === endIdx + 1, + ); + if (nextSegmentIdx !== -1) { + // Align the next segment points with the moved segment + const dir = headingForPointIsHorizontal( + nextFixedSegments[nextSegmentIdx].end, + nextFixedSegments[nextSegmentIdx].start, + ) + ? 1 + : 0; + nextFixedSegments[nextSegmentIdx].end[dir] = end[dir]; + nextFixedSegments[nextSegmentIdx].start = end; + } + + // First segment move needs an additional segment + if (firstSegmentIdx === -1 && startIdx === 0) { + const startIsHorizontal = hoveredStartElement + ? headingIsHorizontal(startHeading) + : headingForPointIsHorizontal(newPoints[1], newPoints[0]); + newPoints.unshift( + pointFrom( + startIsHorizontal ? start[0] : arrow.x + arrow.points[0][0], + !startIsHorizontal ? start[1] : arrow.y + arrow.points[0][1], + ), + ); + + if (hoveredStartElement) { + newPoints.unshift( + pointFrom( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ), + ); + } + + for (const segment of nextFixedSegments) { + segment.index += hoveredStartElement ? 2 : 1; + } + } + + // Last segment move needs an additional segment + if (lastSegmentIdx === -1 && endIdx === arrow.points.length - 1) { + const endIsHorizontal = headingIsHorizontal(endHeading); + newPoints.push( + pointFrom( + endIsHorizontal + ? end[0] + : arrow.x + arrow.points[arrow.points.length - 1][0], + !endIsHorizontal + ? end[1] + : arrow.y + arrow.points[arrow.points.length - 1][1], + ), + ); + if (hoveredEndElement) { + newPoints.push( + pointFrom( + arrow.x + arrow.points[arrow.points.length - 1][0], + arrow.y + arrow.points[arrow.points.length - 1][1], + ), + ); + } + } + + return normalizeArrowElementUpdate( + newPoints, + nextFixedSegments.map((segment) => ({ + ...segment, + start: pointFrom( + segment.start[0] - arrow.x, + segment.start[1] - arrow.y, + ), + end: pointFrom( + segment.end[0] - arrow.x, + segment.end[1] - arrow.y, + ), + })), + false, // If you move a segment, there is no special point anymore + false, // If you move a segment, there is no special point anymore + ); +}; + +const handleEndpointDrag = ( + arrow: ExcalidrawElbowArrowElement, + updatedPoints: readonly LocalPoint[], + fixedSegments: FixedSegment[], + startHeading: Heading, + endHeading: Heading, + startGlobalPoint: GlobalPoint, + endGlobalPoint: GlobalPoint, + hoveredStartElement: ExcalidrawBindableElement | null, + hoveredEndElement: ExcalidrawBindableElement | null, +) => { + let startIsSpecial = arrow.startIsSpecial ?? null; + let endIsSpecial = arrow.endIsSpecial ?? null; + const globalUpdatedPoints = updatedPoints.map((p, i) => + i === 0 + ? pointFrom(arrow.x + p[0], arrow.y + p[1]) + : i === updatedPoints.length - 1 + ? pointFrom(arrow.x + p[0], arrow.y + p[1]) + : pointFrom( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + const nextFixedSegments = fixedSegments.map((segment) => ({ + ...segment, + start: pointFrom( + arrow.x + (segment.start[0] - updatedPoints[0][0]), + arrow.y + (segment.start[1] - updatedPoints[0][1]), + ), + end: pointFrom( + arrow.x + (segment.end[0] - updatedPoints[0][0]), + arrow.y + (segment.end[1] - updatedPoints[0][1]), + ), + })); + const newPoints: GlobalPoint[] = []; + + // Add the inside points + const offset = 2 + (startIsSpecial ? 1 : 0); + const endOffset = 2 + (endIsSpecial ? 1 : 0); + while (newPoints.length + offset < globalUpdatedPoints.length - endOffset) { + newPoints.push(globalUpdatedPoints[newPoints.length + offset]); + } + + // Calculate the moving second point connection and add the start point + { + const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; + const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; + const startIsHorizontal = headingIsHorizontal(startHeading); + const secondIsHorizontal = headingIsHorizontal( + vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), + ); + + if (hoveredStartElement && startIsHorizontal === secondIsHorizontal) { + const positive = startIsHorizontal + ? compareHeading(startHeading, HEADING_RIGHT) + : compareHeading(startHeading, HEADING_DOWN); + newPoints.unshift( + pointFrom( + !secondIsHorizontal + ? thirdPoint[0] + : startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdPoint[1] + : startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.unshift( + pointFrom( + startIsHorizontal + ? startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING) + : startGlobalPoint[0], + !startIsHorizontal + ? startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING) + : startGlobalPoint[1], + ), + ); + if (!startIsSpecial) { + startIsSpecial = true; + for (const segment of nextFixedSegments) { + if (segment.index > 1) { + segment.index += 1; + } + } + } + } else { + newPoints.unshift( + pointFrom( + !secondIsHorizontal ? secondPoint[0] : startGlobalPoint[0], + secondIsHorizontal ? secondPoint[1] : startGlobalPoint[1], + ), + ); + if (startIsSpecial) { + startIsSpecial = false; + for (const segment of nextFixedSegments) { + if (segment.index > 1) { + segment.index -= 1; + } + } + } + } + newPoints.unshift(startGlobalPoint); + } + + // Calculate the moving second to last point connection + { + const secondToLastPoint = + globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; + const thirdToLastPoint = + globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; + const endIsHorizontal = headingIsHorizontal(endHeading); + const secondIsHorizontal = headingForPointIsHorizontal( + thirdToLastPoint, + secondToLastPoint, + ); + if (hoveredEndElement && endIsHorizontal === secondIsHorizontal) { + const positive = endIsHorizontal + ? compareHeading(endHeading, HEADING_RIGHT) + : compareHeading(endHeading, HEADING_DOWN); + newPoints.push( + pointFrom( + !secondIsHorizontal + ? thirdToLastPoint[0] + : endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdToLastPoint[1] + : endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.push( + pointFrom( + endIsHorizontal + ? endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING) + : endGlobalPoint[0], + !endIsHorizontal + ? endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING) + : endGlobalPoint[1], + ), + ); + if (!endIsSpecial) { + endIsSpecial = true; + } + } else { + newPoints.push( + pointFrom( + !secondIsHorizontal ? secondToLastPoint[0] : endGlobalPoint[0], + secondIsHorizontal ? secondToLastPoint[1] : endGlobalPoint[1], + ), + ); + if (endIsSpecial) { + endIsSpecial = false; + } + } + } + + newPoints.push(endGlobalPoint); + + return normalizeArrowElementUpdate( + newPoints, + nextFixedSegments + .map(({ index }) => ({ + index, + start: newPoints[index - 1], + end: newPoints[index], + })) + .map((segment) => ({ + ...segment, + start: pointFrom( + segment.start[0] - startGlobalPoint[0], + segment.start[1] - startGlobalPoint[1], + ), + end: pointFrom( + segment.end[0] - startGlobalPoint[0], + segment.end[1] - startGlobalPoint[1], + ), + })), + startIsSpecial, + endIsSpecial, + ); +}; + +/** + * + */ +export const updateElbowArrowPoints = ( + arrow: Readonly, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + updates: { + points?: readonly LocalPoint[]; + fixedSegments?: FixedSegment[] | null; + }, + options?: { + isDragging?: boolean; + }, +): ElementUpdate => { + if (arrow.points.length < 2) { + return { points: updates.points ?? arrow.points }; + } + + if (!import.meta.env.PROD) { + invariant( + !updates.points || updates.points.length >= 2, + "Updated point array length must match the arrow point length, contain " + + "exactly the new start and end points or not be specified at all (i.e. " + + "you can't add new points between start and end manually to elbow arrows)", + ); + + invariant( + !arrow.fixedSegments || + arrow.fixedSegments + .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1]) + .every(Boolean), + "Fixed segments must be either horizontal or vertical", + ); + + invariant( + !updates.fixedSegments || + updates.fixedSegments + .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1]) + .every(Boolean), + "Updates to fixed segments must be either horizontal or vertical", + ); + + invariant( + arrow.points + .slice(1) + .map( + (p, i) => p[0] === arrow.points[i][0] || p[1] === arrow.points[i][1], + ), + "Elbow arrow segments must be either horizontal or vertical", + ); + } + + const updatedPoints: readonly LocalPoint[] = updates.points + ? updates.points && updates.points.length === 2 + ? arrow.points.map((p, idx) => + idx === 0 + ? updates.points![0] + : idx === arrow.points.length - 1 + ? updates.points![1] + : p, + ) + : structuredClone(updates.points) + : structuredClone(arrow.points); + + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData(arrow, elementsMap, updatedPoints, options); + + const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; + + //// + // 1. Renormalize the arrow + //// + if (!updates.points && !updates.fixedSegments) { + return handleSegmentRenormalization(arrow, elementsMap); + } + + //// + // 2. Just normal elbow arrow things + //// + if (fixedSegments.length === 0) { + return normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow(arrow, { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest, + }) ?? [], + ), + ), + fixedSegments, + null, + null, + ); + } + + //// + // 3. Handle releasing a fixed segment + if ((arrow.fixedSegments?.length ?? 0) > fixedSegments.length) { + return handleSegmentRelease(arrow, fixedSegments, elementsMap); + } + + //// + // 4. Handle manual segment move + //// + if (!updates.points) { + return handleSegmentMove( + arrow, + fixedSegments, + startHeading, + endHeading, + hoveredStartElement, + hoveredEndElement, + ); + } + + //// + // 5. Handle resize + if (updates.points && updates.fixedSegments) { + return updates; + } + + //// + // 6. One or more segments are fixed and endpoints are moved + // + // The key insights are: + // - When segments are fixed, the arrow will keep the exact amount of segments + // - Fixed segments are "replacements" for exactly one segment in the old arrow + //// + return handleEndpointDrag( + arrow, + updatedPoints, + fixedSegments, + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ); +}; + +/** + * Retrieves data necessary for calculating the elbow arrow path. + * + * @param arrow - The arrow object containing its properties. + * @param elementsMap - A map of elements in the scene. + * @param nextPoints - The next set of points for the arrow. + * @param options - Optional parameters for the calculation. + * @param options.isDragging - Indicates if the arrow is being dragged. + * @param options.startIsMidPoint - Indicates if the start point is a midpoint. + * @param options.endIsMidPoint - Indicates if the end point is a midpoint. + * + * @returns An object containing various properties needed for elbow arrow calculations: + * - dynamicAABBs: Dynamically generated axis-aligned bounding boxes. + * - startDonglePosition: The position of the start dongle. + * - startGlobalPoint: The global coordinates of the start point. + * - startHeading: The heading direction from the start point. + * - endDonglePosition: The position of the end dongle. + * - endGlobalPoint: The global coordinates of the end point. + * - endHeading: The heading direction from the end point. + * - commonBounds: The common bounding box that encompasses both start and end points. + * - hoveredStartElement: The element being hovered over at the start point. + * - hoveredEndElement: The element being hovered over at the end point. + */ +const getElbowArrowData = ( + arrow: { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + }, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + nextPoints: readonly LocalPoint[], + options?: { + isDragging?: boolean; + zoom?: AppState["zoom"]; + }, +) => { + const origStartGlobalPoint: GlobalPoint = pointTranslate< + LocalPoint, + GlobalPoint + >(nextPoints[0], vector(arrow.x, arrow.y)); + const origEndGlobalPoint: GlobalPoint = pointTranslate< + LocalPoint, + GlobalPoint + >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); + const startElement = + arrow.startBinding && + getBindableElementForId(arrow.startBinding.elementId, elementsMap); + const endElement = + arrow.endBinding && + getBindableElementForId(arrow.endBinding.elementId, elementsMap); + const [hoveredStartElement, hoveredEndElement] = options?.isDragging + ? getHoveredElements( + origStartGlobalPoint, + origEndGlobalPoint, + elementsMap, + options?.zoom, + ) + : [startElement, endElement]; + const startGlobalPoint = getGlobalPoint( + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + origEndGlobalPoint, + elementsMap, + startElement, + hoveredStartElement, + options?.isDragging, + ); + const endGlobalPoint = getGlobalPoint( + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + origStartGlobalPoint, + elementsMap, + endElement, + hoveredEndElement, + options?.isDragging, + ); + const startHeading = getBindPointHeading( + startGlobalPoint, + endGlobalPoint, + elementsMap, + hoveredStartElement, + origStartGlobalPoint, + ); + const endHeading = getBindPointHeading( + endGlobalPoint, + startGlobalPoint, + elementsMap, + hoveredEndElement, + origEndGlobalPoint, + ); + const startPointBounds = [ + startGlobalPoint[0] - 2, + startGlobalPoint[1] - 2, + startGlobalPoint[0] + 2, + startGlobalPoint[1] + 2, + ] as Bounds; + const endPointBounds = [ + endGlobalPoint[0] - 2, + endGlobalPoint[1] - 2, + endGlobalPoint[0] + 2, + endGlobalPoint[1] + 2, + ] as Bounds; + const startElementBounds = hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading( + startHeading, + arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : startPointBounds; + const endElementBounds = hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading( + endHeading, + arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : endPointBounds; + const boundsOverlap = + pointInsideBounds( + startGlobalPoint, + hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), + ) + : endPointBounds, + ) || + pointInsideBounds( + endGlobalPoint, + hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), + ) + : startPointBounds, + ); + const commonBounds = commonAABB( + boundsOverlap + ? [startPointBounds, endPointBounds] + : [startElementBounds, endElementBounds], + ); + const dynamicAABBs = generateDynamicAABBs( + boundsOverlap ? startPointBounds : startElementBounds, + boundsOverlap ? endPointBounds : endElementBounds, + commonBounds, + boundsOverlap + ? offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap + ? offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap, + hoveredStartElement && aabbForElement(hoveredStartElement), + hoveredEndElement && aabbForElement(hoveredEndElement), + ); + const startDonglePosition = getDonglePosition( + dynamicAABBs[0], + startHeading, + startGlobalPoint, + ); + const endDonglePosition = getDonglePosition( + dynamicAABBs[1], + endHeading, + endGlobalPoint, + ); + + return { + dynamicAABBs, + startDonglePosition, + startGlobalPoint, + startHeading, + endDonglePosition, + endGlobalPoint, + endHeading, + commonBounds, + hoveredStartElement, + hoveredEndElement, + boundsOverlap, + startElementBounds, + endElementBounds, + }; +}; + +/** + * Generate the elbow arrow segments + * + * @param arrow + * @param elementsMap + * @param nextPoints + * @param options + * @returns + */ +const routeElbowArrow = ( + arrow: ElbowArrowState, + elbowArrowData: ElbowArrowData, +): GlobalPoint[] | null => { + const { + dynamicAABBs, + startDonglePosition, + startGlobalPoint, + startHeading, + endDonglePosition, + endGlobalPoint, + endHeading, + commonBounds, + hoveredEndElement, + } = elbowArrowData; + + // Canculate Grid positions + const grid = calculateGrid( + dynamicAABBs, + startDonglePosition ? startDonglePosition : startGlobalPoint, + startHeading, + endDonglePosition ? endDonglePosition : endGlobalPoint, + endHeading, + commonBounds, + ); + + const startDongle = + startDonglePosition && pointToGridNode(startDonglePosition, grid); + const endDongle = + endDonglePosition && pointToGridNode(endDonglePosition, grid); + + // Do not allow stepping on the true end or true start points + const endNode = pointToGridNode(endGlobalPoint, grid); + if (endNode && hoveredEndElement) { + endNode.closed = true; + } + const startNode = pointToGridNode(startGlobalPoint, grid); + if (startNode && arrow.startBinding) { + startNode.closed = true; + } + const dongleOverlap = + startDongle && + endDongle && + (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || + pointInsideBounds(endDongle.pos, dynamicAABBs[0])); + + // Create path to end dongle from start dongle + const path = astar( + startDongle ? startDongle : startNode!, + endDongle ? endDongle : endNode!, + grid, + startHeading ? startHeading : HEADING_RIGHT, + endHeading ? endHeading : HEADING_RIGHT, + dongleOverlap ? [] : dynamicAABBs, + ); + + if (path) { + const points = path.map((node) => [ + node.pos[0], + node.pos[1], + ]) as GlobalPoint[]; + startDongle && points.unshift(startGlobalPoint); + endDongle && points.push(endGlobalPoint); + + return points; + } + + return null; +}; + +const offsetFromHeading = ( + heading: Heading, + head: number, + side: number, +): [number, number, number, number] => { + switch (heading) { + case HEADING_UP: + return [head, side, side, side]; + case HEADING_RIGHT: + return [side, head, side, side]; + case HEADING_DOWN: + return [side, side, head, side]; + } + + return [side, side, side, head]; +}; + +/** + * Routing algorithm based on the A* path search algorithm. + * @see https://www.geeksforgeeks.org/a-search-algorithm/ + * + * Binary heap is used to optimize node lookup. + * See {@link calculateGrid} for the grid calculation details. + * + * Additional modifications added due to aesthetic route reasons: + * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier) + * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment + */ +const astar = ( + start: Node, + end: Node, + grid: Grid, + startHeading: Heading, + endHeading: Heading, + aabbs: Bounds[], +) => { + const bendMultiplier = m_dist(start.pos, end.pos); + const open = new BinaryHeap((node) => node.f); + + open.push(start); + + while (open.size() > 0) { + // Grab the lowest f(x) to process next. Heap keeps this sorted for us. + const current = open.pop(); + + if (!current || current.closed) { + // Current is not passable, continue with next element + continue; + } + + // End case -- result has been found, return the traced path. + if (current === end) { + return pathTo(start, current); + } + + // Normal case -- move current from open to closed, process each of its neighbors. + current.closed = true; + + // Find all neighbors for the current node. + const neighbors = getNeighbors(current.addr, grid); + + for (let i = 0; i < 4; i++) { + const neighbor = neighbors[i]; + + if (!neighbor || neighbor.closed) { + // Not a valid node to process, skip to next neighbor. + continue; + } + + // Intersect + const neighborHalfPoint = pointScaleFromOrigin( + neighbor.pos, + current.pos, + 0.5, + ); + if ( + isAnyTrue( + ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)), + ) + ) { + continue; + } + + // The g score is the shortest distance from start to current node. + // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. + const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); + const previousDirection = current.parent + ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos)) + : startHeading; + + // Do not allow going in reverse + const reverseHeading = flipHeading(previousDirection); + const neighborIsReverseRoute = + compareHeading(reverseHeading, neighborHeading) || + (gridAddressesEqual(start.addr, neighbor.addr) && + compareHeading(neighborHeading, startHeading)) || + (gridAddressesEqual(end.addr, neighbor.addr) && + compareHeading(neighborHeading, endHeading)); + if (neighborIsReverseRoute) { + continue; + } + + const directionChange = previousDirection !== neighborHeading; + const gScore = + current.g + + m_dist(neighbor.pos, current.pos) + + (directionChange ? Math.pow(bendMultiplier, 3) : 0); + + const beenVisited = neighbor.visited; + + if (!beenVisited || gScore < neighbor.g) { + const estBendCount = estimateSegmentCount( + neighbor, + end, + neighborHeading, + endHeading, + ); + // Found an optimal (so far) path to this node. Take score for node to see how good it is. + neighbor.visited = true; + neighbor.parent = current; + neighbor.h = + m_dist(end.pos, neighbor.pos) + + estBendCount * Math.pow(bendMultiplier, 2); + neighbor.g = gScore; + neighbor.f = neighbor.g + neighbor.h; + if (!beenVisited) { + // Pushing to heap will put it in proper place based on the 'f' value. + open.push(neighbor); + } else { + // Already seen the node, but since it has been rescored we need to reorder it in the heap + open.rescoreElement(neighbor); + } + } + } + } + + return null; +}; + +const pathTo = (start: Node, node: Node) => { + let curr = node; + const path = []; + while (curr.parent) { + path.unshift(curr); + curr = curr.parent; + } + path.unshift(start); + + return path; +}; + +const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => + Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); + +/** + * Create dynamically resizing, always touching + * bounding boxes having a minimum extent represented + * by the given static bounds. + */ +const generateDynamicAABBs = ( + a: Bounds, + b: Bounds, + common: Bounds, + startDifference?: [number, number, number, number], + endDifference?: [number, number, number, number], + disableSideHack?: boolean, + startElementBounds?: Bounds | null, + endElementBounds?: Bounds | null, +): Bounds[] => { + const startEl = startElementBounds ?? a; + const endEl = endElementBounds ?? b; + const [startUp, startRight, startDown, startLeft] = startDifference ?? [ + 0, 0, 0, 0, + ]; + const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0]; + + const first = [ + a[0] > b[2] + ? a[1] > b[3] || a[3] < b[1] + ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft) + : (startEl[0] + endEl[2]) / 2 + : a[0] > b[0] + ? a[0] - startLeft + : common[0] - startLeft, + a[1] > b[3] + ? a[0] > b[2] || a[2] < b[0] + ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp) + : (startEl[1] + endEl[3]) / 2 + : a[1] > b[1] + ? a[1] - startUp + : common[1] - startUp, + a[2] < b[0] + ? a[1] > b[3] || a[3] < b[1] + ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight) + : (startEl[2] + endEl[0]) / 2 + : a[2] < b[2] + ? a[2] + startRight + : common[2] + startRight, + a[3] < b[1] + ? a[0] > b[2] || a[2] < b[0] + ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown) + : (startEl[3] + endEl[1]) / 2 + : a[3] < b[3] + ? a[3] + startDown + : common[3] + startDown, + ] as Bounds; + const second = [ + b[0] > a[2] + ? b[1] > a[3] || b[3] < a[1] + ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft) + : (endEl[0] + startEl[2]) / 2 + : b[0] > a[0] + ? b[0] - endLeft + : common[0] - endLeft, + b[1] > a[3] + ? b[0] > a[2] || b[2] < a[0] + ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp) + : (endEl[1] + startEl[3]) / 2 + : b[1] > a[1] + ? b[1] - endUp + : common[1] - endUp, + b[2] < a[0] + ? b[1] > a[3] || b[3] < a[1] + ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight) + : (endEl[2] + startEl[0]) / 2 + : b[2] < a[2] + ? b[2] + endRight + : common[2] + endRight, + b[3] < a[1] + ? b[0] > a[2] || b[2] < a[0] + ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown) + : (endEl[3] + startEl[1]) / 2 + : b[3] < a[3] + ? b[3] + endDown + : common[3] + endDown, + ] as Bounds; + + const c = commonAABB([first, second]); + if ( + !disableSideHack && + first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 && + first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001 + ) { + const [endCenterX, endCenterY] = [ + (second[0] + second[2]) / 2, + (second[1] + second[3]) / 2, + ]; + if (b[0] > a[2] && a[1] > b[3]) { + // BOTTOM LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } else if (a[2] < b[0] && a[3] < b[1]) { + // TOP LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } + + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[3] < b[1]) { + // TOP RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[1] > b[3]) { + // BOTTOM RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } + } + + return [first, second]; +}; + +/** + * Calculates the grid which is used as nodes at + * the grid line intersections by the A* algorithm. + * + * NOTE: This is not a uniform grid. It is built at + * various intersections of bounding boxes. + */ +const calculateGrid = ( + aabbs: Bounds[], + start: GlobalPoint, + startHeading: Heading, + end: GlobalPoint, + endHeading: Heading, + common: Bounds, +): Grid => { + const horizontal = new Set(); + const vertical = new Set(); + + if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) { + vertical.add(start[1]); + } else { + horizontal.add(start[0]); + } + if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) { + vertical.add(end[1]); + } else { + horizontal.add(end[0]); + } + + aabbs.forEach((aabb) => { + horizontal.add(aabb[0]); + horizontal.add(aabb[2]); + vertical.add(aabb[1]); + vertical.add(aabb[3]); + }); + + horizontal.add(common[0]); + horizontal.add(common[2]); + vertical.add(common[1]); + vertical.add(common[3]); + + const _vertical = Array.from(vertical).sort((a, b) => a - b); + const _horizontal = Array.from(horizontal).sort((a, b) => a - b); + + return { + row: _vertical.length, + col: _horizontal.length, + data: _vertical.flatMap((y, row) => + _horizontal.map( + (x, col): Node => ({ + f: 0, + g: 0, + h: 0, + closed: false, + visited: false, + parent: null, + addr: [col, row] as GridAddress, + pos: [x, y] as GlobalPoint, + }), + ), + ), + }; +}; + +const getDonglePosition = ( + bounds: Bounds, + heading: Heading, + p: GlobalPoint, +): GlobalPoint => { + switch (heading) { + case HEADING_UP: + return pointFrom(p[0], bounds[1]); + case HEADING_RIGHT: + return pointFrom(bounds[2], p[1]); + case HEADING_DOWN: + return pointFrom(p[0], bounds[3]); + } + return pointFrom(bounds[0], p[1]); +}; + +const estimateSegmentCount = ( + start: Node, + end: Node, + startHeading: Heading, + endHeading: Heading, +) => { + if (endHeading === HEADING_RIGHT) { + switch (startHeading) { + case HEADING_RIGHT: { + if (start.pos[0] >= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + } + } else if (endHeading === HEADING_LEFT) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[0] <= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + } else if (endHeading === HEADING_UP) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[1] >= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_DOWN: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } else if (endHeading === HEADING_DOWN) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_DOWN: + if (start.pos[1] <= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } + return 0; +}; + +/** + * Get neighboring points for a gived grid address + */ +const getNeighbors = ([col, row]: [number, number], grid: Grid) => + [ + gridNodeFromAddr([col, row - 1], grid), + gridNodeFromAddr([col + 1, row], grid), + gridNodeFromAddr([col, row + 1], grid), + gridNodeFromAddr([col - 1, row], grid), + ] as [Node | null, Node | null, Node | null, Node | null]; + +const gridNodeFromAddr = ( + [col, row]: [col: number, row: number], + grid: Grid, +): Node | null => { + if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) { + return null; + } + + return grid.data[row * grid.col + col] ?? null; +}; + +/** + * Get node for global point on canvas (if exists) + */ +const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => { + for (let col = 0; col < grid.col; col++) { + for (let row = 0; row < grid.row; row++) { + const candidate = gridNodeFromAddr([col, row], grid); + if ( + candidate && + point[0] === candidate.pos[0] && + point[1] === candidate.pos[1] + ) { + return candidate; + } + } + } + + return null; +}; + +const commonAABB = (aabbs: Bounds[]): Bounds => [ + Math.min(...aabbs.map((aabb) => aabb[0])), + Math.min(...aabbs.map((aabb) => aabb[1])), + Math.max(...aabbs.map((aabb) => aabb[2])), + Math.max(...aabbs.map((aabb) => aabb[3])), +]; + +/// #region Utils + +const getBindableElementForId = ( + id: string, + elementsMap: ElementsMap, +): ExcalidrawBindableElement | null => { + const element = elementsMap.get(id); + if (element && isBindableElement(element)) { + return element; + } + + return null; +}; + +const normalizeArrowElementUpdate = ( + global: GlobalPoint[], + nextFixedSegments: FixedSegment[] | null, + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], +): { + points: LocalPoint[]; + x: number; + y: number; + width: number; + height: number; + fixedSegments: FixedSegment[] | null; + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; +} => { + const offsetX = global[0][0]; + const offsetY = global[0][1]; + + const points = global.map((p) => + pointTranslate( + p, + vectorScale(vectorFromPoint(global[0]), -1), + ), + ); + + return { + points, + x: offsetX, + y: offsetY, + fixedSegments: + (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null, + ...getSizeFromPoints(points), + startIsSpecial, + endIsSpecial, + }; +}; + +const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => { + if (points.length > 1) { + let previousHorizontal = + Math.abs(points[0][1] - points[1][1]) < + Math.abs(points[0][0] - points[1][0]); + + return points.filter((p, idx) => { + // The very first and last points are always kept + if (idx === 0 || idx === points.length - 1) { + return true; + } + + const next = points[idx + 1]; + const nextHorizontal = + Math.abs(p[1] - next[1]) < Math.abs(p[0] - next[0]); + if (previousHorizontal === nextHorizontal) { + previousHorizontal = nextHorizontal; + return false; + } + + previousHorizontal = nextHorizontal; + return true; + }); + } + + return points; +}; + +const removeElbowArrowShortSegments = ( + points: GlobalPoint[], +): GlobalPoint[] => { + if (points.length >= 4) { + return points.filter((p, idx) => { + if (idx === 0 || idx === points.length - 1) { + return true; + } + + const prev = points[idx - 1]; + const prevDist = pointDistance(prev, p); + return prevDist > DEDUP_TRESHOLD; + }); + } + + return points; +}; + +const neighborIndexToHeading = (idx: number): Heading => { + switch (idx) { + case 0: + return HEADING_UP; + case 1: + return HEADING_RIGHT; + case 2: + return HEADING_DOWN; + } + return HEADING_LEFT; +}; + +const getGlobalPoint = ( + fixedPointRatio: [number, number] | undefined | null, + initialPoint: GlobalPoint, + otherPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + boundElement?: ExcalidrawBindableElement | null, + hoveredElement?: ExcalidrawBindableElement | null, + isDragging?: boolean, +): GlobalPoint => { + if (isDragging) { + if (hoveredElement) { + const snapPoint = getSnapPoint( + initialPoint, + otherPoint, + hoveredElement, + elementsMap, + ); + + return snapToMid(hoveredElement, snapPoint); + } + + return initialPoint; + } + + if (boundElement) { + const fixedGlobalPoint = getGlobalFixedPointForBindableElement( + fixedPointRatio || [0, 0], + boundElement, + ); + + // NOTE: Resize scales the binding position point too, so we need to update it + return Math.abs( + distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) - + FIXED_BINDING_DISTANCE, + ) > 0.01 + ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap) + : fixedGlobalPoint; + } + + return initialPoint; +}; + +const getSnapPoint = ( + p: GlobalPoint, + otherPoint: GlobalPoint, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => + bindPointToSnapToElementOutline( + isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, + otherPoint, + element, + elementsMap, + ); + +const getBindPointHeading = ( + p: GlobalPoint, + otherPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + hoveredElement: ExcalidrawBindableElement | null | undefined, + origPoint: GlobalPoint, +): Heading => + getHeadingForElbowArrowSnap( + p, + otherPoint, + hoveredElement, + hoveredElement && + aabbForElement( + hoveredElement, + Array(4).fill( + distanceToBindableElement(hoveredElement, p, elementsMap), + ) as [number, number, number, number], + ), + elementsMap, + origPoint, + ); + +const getHoveredElements = ( + origStartGlobalPoint: GlobalPoint, + origEndGlobalPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + zoom?: AppState["zoom"], +) => { + // TODO: Might be a performance bottleneck and the Map type + // remembers the insertion order anyway... + const nonDeletedSceneElementsMap = toBrandedType( + new Map([...elementsMap].filter((el) => !el[1].isDeleted)), + ); + const elements = Array.from(elementsMap.values()); + return [ + getHoveredElementForBinding( + tupleToCoors(origStartGlobalPoint), + elements, + nonDeletedSceneElementsMap, + zoom, + true, + ), + getHoveredElementForBinding( + tupleToCoors(origEndGlobalPoint), + elements, + nonDeletedSceneElementsMap, + zoom, + true, + ), + ]; +}; + +const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => + a[0] === b[0] && a[1] === b[1]; diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index 8c14bc01a..8df7543b5 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/packages/excalidraw/element/flowchart.ts @@ -452,20 +452,12 @@ const createBindingArrow = ( bindingArrow as OrderedExcalidrawElement, ); - LinearElementEditor.movePoints( - bindingArrow, - [ - { - index: 1, - point: bindingArrow.points[1], - }, - ], - elementsMap as NonDeletedSceneElementsMap, - undefined, + LinearElementEditor.movePoints(bindingArrow, [ { - changedElements, + index: 1, + point: bindingArrow.points[1], }, - ); + ]); return bindingArrow; }; diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index c17a077fc..ef54ac77d 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -11,6 +11,7 @@ import { pointScaleFromOrigin, radiansToDegrees, triangleIncludesPoint, + vectorFromPoint, } from "../../math"; import { getCenterForBounds, type Bounds } from "./bounds"; import type { ExcalidrawBindableElement } from "./types"; @@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => { return HEADING_UP; }; +export const headingForPoint =

( + p: P, + o: P, +) => vectorToHeading(vectorFromPoint

(p, o)); + +export const headingForPointIsHorizontal =

( + p: P, + o: P, +) => headingIsHorizontal(headingForPoint

(p, o)); + export const compareHeading = (a: Heading, b: Heading) => a[0] === b[0] && a[1] === b[1]; +export const headingIsHorizontal = (a: Heading) => + compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT); + +export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a); + // Gets the heading for the point by creating a bounding box around the rotated // close fitting bounding box, then creating 4 search cones around the center of // the external bbox. @@ -63,7 +79,7 @@ export const headingForPointFromElement = < >( element: Readonly, aabb: Readonly, - p: Readonly, + p: Readonly, ): Heading => { const SEARCH_CONE_MULTIPLIER = 2; @@ -117,14 +133,22 @@ export const headingForPointFromElement = < element.angle, ); - if (triangleIncludesPoint([top, right, midPoint] as Triangle, p)) { + if ( + triangleIncludesPoint([top, right, midPoint] as Triangle, p) + ) { return headingForDiamond(top, right); } else if ( - triangleIncludesPoint([right, bottom, midPoint] as Triangle, p) + triangleIncludesPoint( + [right, bottom, midPoint] as Triangle, + p, + ) ) { return headingForDiamond(right, bottom); } else if ( - triangleIncludesPoint([bottom, left, midPoint] as Triangle, p) + triangleIncludesPoint( + [bottom, left, midPoint] as Triangle, + p, + ) ) { return headingForDiamond(bottom, left); } @@ -153,17 +177,17 @@ export const headingForPointFromElement = < SEARCH_CONE_MULTIPLIER, ) as Point; - return triangleIncludesPoint( + return triangleIncludesPoint( [topLeft, topRight, midPoint] as Triangle, p, ) ? HEADING_UP - : triangleIncludesPoint( + : triangleIncludesPoint( [topRight, bottomRight, midPoint] as Triangle, p, ) ? HEADING_RIGHT - : triangleIncludesPoint( + : triangleIncludesPoint( [bottomRight, bottomLeft, midPoint] as Triangle, p, ) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index ea3644704..99b369a76 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -7,9 +7,10 @@ import type { ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, - OrderedExcalidrawElement, FixedPointBinding, SceneElementsMap, + FixedSegment, + ExcalidrawElbowArrowElement, } from "./types"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; @@ -24,6 +25,7 @@ import type { InteractiveCanvasAppState, AppClassProperties, NullableGridSize, + Zoom, } from "../types"; import { mutateElement } from "./mutateElement"; @@ -32,7 +34,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { invariant, toBrandedType, tupleToCoors } from "../utils"; +import { invariant, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; import type { Store } from "../store"; -import { mutateElbowArrow } from "./routing"; import type Scene from "../scene/Scene"; import type { Radians } from "../../math"; import { @@ -56,6 +57,8 @@ import { type GlobalPoint, type LocalPoint, pointDistance, + pointTranslate, + vectorFromPoint, } from "../../math"; import { getBezierCurveLength, @@ -65,6 +68,7 @@ import { mapIntervalToBezierT, } from "../shapes"; import { getGridPoint } from "../snapping"; +import { headingIsHorizontal, vectorToHeading } from "./heading"; const editorMidPointsCache: { version: number | null; @@ -144,13 +148,13 @@ export class LinearElementEditor { * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) */ - static getElement( + static getElement( id: InstanceType["elementId"], elementsMap: ElementsMap, - ) { + ): T | null { const element = elementsMap.get(id); if (element) { - return element as NonDeleted; + return element as NonDeleted; } return null; } @@ -291,20 +295,16 @@ export class LinearElementEditor { event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - LinearElementEditor.movePoints( - element, - [ - { - index: selectedIndex, - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - isDragging: selectedIndex === lastClickedPoint, - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: selectedIndex, + point: pointFrom( + width + referencePoint[0], + height + referencePoint[1], + ), + isDragging: selectedIndex === lastClickedPoint, + }, + ]); } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, @@ -339,7 +339,6 @@ export class LinearElementEditor { isDragging: pointIndex === lastClickedPoint, }; }), - elementsMap, ); } @@ -422,19 +421,15 @@ export class LinearElementEditor { selectedPoint === element.points.length - 1 ) { if (isPathALoop(element.points, appState.zoom.value)) { - LinearElementEditor.movePoints( - element, - [ - { - index: selectedPoint, - point: - selectedPoint === 0 - ? element.points[element.points.length - 1] - : element.points[0], - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: selectedPoint, + point: + selectedPoint === 0 + ? element.points[element.points.length - 1] + : element.points[0], + }, + ]); } const bindingElement = isBindingEnabled(appState) @@ -495,6 +490,7 @@ export class LinearElementEditor { // Since its not needed outside editor unless 2 pointer lines or bound text if ( + !isElbowArrow(element) && !appState.editingLinearElement && element.points.length > 2 && !boundText @@ -533,6 +529,7 @@ export class LinearElementEditor { element, element.points[index], element.points[index + 1], + index, appState.zoom, ) ) { @@ -573,19 +570,23 @@ export class LinearElementEditor { scenePointer.x, scenePointer.y, ); - if (clickedPointIndex >= 0) { + if (!isElbowArrow(element) && clickedPointIndex >= 0) { return null; } const points = LinearElementEditor.getPointsGlobalCoordinates( element, elementsMap, ); - if (points.length >= 3 && !appState.editingLinearElement) { + if ( + points.length >= 3 && + !appState.editingLinearElement && + !isElbowArrow(element) + ) { return null; } const threshold = - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value; + (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value; const existingSegmentMidpointHitCoords = linearElementEditor.segmentMidPointHoveredCoords; @@ -604,10 +605,11 @@ export class LinearElementEditor { let index = 0; const midPoints: typeof editorMidPointsCache["points"] = LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); + while (index < midPoints.length) { if (midPoints[index] !== null) { const distance = pointDistance( - pointFrom(midPoints[index]![0], midPoints[index]![1]), + midPoints[index]!, pointFrom(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { @@ -620,16 +622,25 @@ export class LinearElementEditor { return null; }; - static isSegmentTooShort( + static isSegmentTooShort

( element: NonDeleted, - startPoint: GlobalPoint | LocalPoint, - endPoint: GlobalPoint | LocalPoint, - zoom: AppState["zoom"], + startPoint: P, + endPoint: P, + index: number, + zoom: Zoom, ) { - let distance = pointDistance( - pointFrom(startPoint[0], startPoint[1]), - pointFrom(endPoint[0], endPoint[1]), - ); + if (isElbowArrow(element)) { + if (index >= 0 && index < element.points.length) { + return ( + pointDistance(startPoint, endPoint) * zoom.value < + LinearElementEditor.POINT_HANDLE_SIZE / 2 + ); + } + + return false; + } + + let distance = pointDistance(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { distance = getBezierCurveLength(element, endPoint); } @@ -748,12 +759,8 @@ export class LinearElementEditor { segmentMidpoint, elementsMap, ); - } - if (event.altKey && appState.editingLinearElement) { - if ( - linearElementEditor.lastUncommittedPoint == null && - !isElbowArrow(element) - ) { + } else if (event.altKey && appState.editingLinearElement) { + if (linearElementEditor.lastUncommittedPoint == null) { mutateElement(element, { points: [ ...element.points, @@ -909,12 +916,7 @@ export class LinearElementEditor { if (!event.altKey) { if (lastPoint === lastUncommittedPoint) { - LinearElementEditor.deletePoints( - element, - [points.length - 1], - elementsMap, - app.state.zoom, - ); + LinearElementEditor.deletePoints(element, [points.length - 1]); } return { ...appState.editingLinearElement, @@ -952,23 +954,14 @@ export class LinearElementEditor { } if (lastPoint === lastUncommittedPoint) { - LinearElementEditor.movePoints( - element, - [ - { - index: element.points.length - 1, - point: newPoint, - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: newPoint, + }, + ]); } else { - LinearElementEditor.addPoints( - element, - [{ point: newPoint }], - elementsMap, - app.state.zoom, - ); + LinearElementEditor.addPoints(element, [{ point: newPoint }]); } return { ...appState.editingLinearElement, @@ -1197,16 +1190,12 @@ export class LinearElementEditor { // potentially expanding the bounding box if (pointAddedToEnd) { const lastPoint = element.points[element.points.length - 1]; - LinearElementEditor.movePoints( - element, - [ - { - index: element.points.length - 1, - point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), + }, + ]); } return { @@ -1221,8 +1210,6 @@ export class LinearElementEditor { static deletePoints( element: NonDeleted, pointIndices: readonly number[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - zoom: AppState["zoom"], ) { let offsetX = 0; let offsetY = 0; @@ -1252,47 +1239,27 @@ export class LinearElementEditor { return acc; }, []); - LinearElementEditor._updatePoints( - element, - nextPoints, - offsetX, - offsetY, - elementsMap, - ); + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); } static addPoints( element: NonDeleted, targetPoints: { point: LocalPoint }[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - zoom: AppState["zoom"], ) { const offsetX = 0; const offsetY = 0; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; - LinearElementEditor._updatePoints( - element, - nextPoints, - offsetX, - offsetY, - elementsMap, - ); + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); } static movePoints( element: NonDeleted, targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; }, - options?: { - changedElements?: Map; - isDragging?: boolean; - zoom?: AppState["zoom"]; - }, ) { const { points } = element; @@ -1335,7 +1302,6 @@ export class LinearElementEditor { nextPoints, offsetX, offsetY, - elementsMap, otherUpdates, { isDragging: targetPoints.reduce( @@ -1343,8 +1309,6 @@ export class LinearElementEditor { dragging || targetPoint.isDragging === true, false, ), - changedElements: options?.changedElements, - zoom: options?.zoom, }, ); } @@ -1451,54 +1415,49 @@ export class LinearElementEditor { nextPoints: readonly LocalPoint[], offsetX: number, offsetY: number, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; }, options?: { - changedElements?: Map; isDragging?: boolean; zoom?: AppState["zoom"]; }, ) { if (isElbowArrow(element)) { - const bindings: { + const updates: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; + points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { - bindings.startBinding = + updates.startBinding = otherUpdates.startBinding !== null && isFixedPointBinding(otherUpdates.startBinding) ? otherUpdates.startBinding : null; } if (otherUpdates?.endBinding !== undefined) { - bindings.endBinding = + updates.endBinding = otherUpdates.endBinding !== null && isFixedPointBinding(otherUpdates.endBinding) ? otherUpdates.endBinding : null; } - const mergedElementsMap = options?.changedElements - ? toBrandedType( - new Map([...elementsMap, ...options.changedElements]), - ) - : elementsMap; - - mutateElbowArrow( - element, - mergedElementsMap, - nextPoints, + updates.points = Array.from(nextPoints); + updates.points[0] = pointTranslate( + updates.points[0], + vector(offsetX, offsetY), + ); + updates.points[updates.points.length - 1] = pointTranslate( + updates.points[updates.points.length - 1], vector(offsetX, offsetY), - bindings, - { - isDragging: options?.isDragging, - zoom: options?.zoom, - }, ); + + mutateElement(element, updates, true, { + isDragging: options?.isDragging, + }); } else { const nextCoords = getElementPointsCoords(element, nextPoints); const prevCoords = getElementPointsCoords(element, element.points); @@ -1773,6 +1732,99 @@ export class LinearElementEditor { return coords; }; + + static moveFixedSegment( + linearElement: LinearElementEditor, + index: number, + x: number, + y: number, + elementsMap: ElementsMap, + ): LinearElementEditor { + const element = LinearElementEditor.getElement( + linearElement.elementId, + elementsMap, + ); + + if (!element || !isElbowArrow(element)) { + return linearElement; + } + + if (index && index > 0 && index < element.points.length) { + const isHorizontal = headingIsHorizontal( + vectorToHeading( + vectorFromPoint(element.points[index], element.points[index - 1]), + ), + ); + + const fixedSegments = (element.fixedSegments ?? []).reduce( + (segments, s) => { + segments[s.index] = s; + return segments; + }, + {} as Record, + ); + fixedSegments[index] = { + index, + start: pointFrom( + !isHorizontal ? x - element.x : element.points[index - 1][0], + isHorizontal ? y - element.y : element.points[index - 1][1], + ), + end: pointFrom( + !isHorizontal ? x - element.x : element.points[index][0], + isHorizontal ? y - element.y : element.points[index][1], + ), + }; + const nextFixedSegments = Object.values(fixedSegments).sort( + (a, b) => a.index - b.index, + ); + + const offset = nextFixedSegments + .map((segment) => segment.index) + .reduce((count, idx) => (idx < index ? count + 1 : count), 0); + + mutateElement(element, { + fixedSegments: nextFixedSegments, + }); + + const point = pointFrom( + element.x + + (element.fixedSegments![offset].start[0] + + element.fixedSegments![offset].end[0]) / + 2, + element.y + + (element.fixedSegments![offset].start[1] + + element.fixedSegments![offset].end[1]) / + 2, + ); + + return { + ...linearElement, + segmentMidPointHoveredCoords: point, + pointerDownState: { + ...linearElement.pointerDownState, + segmentMidpoint: { + added: false, + index: element.fixedSegments![offset].index, + value: point, + }, + }, + }; + } + + return linearElement; + } + + static deleteFixedSegment( + element: ExcalidrawElbowArrowElement, + index: number, + ): void { + mutateElement(element, { + fixedSegments: element.fixedSegments?.filter( + (segment) => segment.index !== index, + ), + }); + mutateElement(element, {}, true); + } } const normalizeSelectedPoints = ( diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index ef84854f9..d5fcc1ff3 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -1,10 +1,13 @@ -import type { ExcalidrawElement } from "./types"; +import type { ExcalidrawElement, SceneElementsMap } from "./types"; import Scene from "../scene/Scene"; import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; -import { getUpdatedTimestamp } from "../utils"; +import { getUpdatedTimestamp, toBrandedType } from "../utils"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; +import { isElbowArrow } from "./typeChecks"; +import { updateElbowArrowPoints } from "./elbowArrow"; +import type { Radians } from "../../math"; export type ElementUpdate = Omit< Partial, @@ -19,14 +22,49 @@ export const mutateElement = >( element: TElement, updates: ElementUpdate, informMutation = true, + options?: { + // Currently only for elbow arrows. + // If true, the elbow arrow tries to bind to the nearest element. If false + // it tries to keep the same bound element, if any. + isDragging?: boolean; + }, ): TElement => { let didChange = false; // casting to any because can't use `in` operator // (see https://github.com/microsoft/TypeScript/issues/21732) - const { points, fileId } = updates as any; + const { points, fixedSegments, fileId } = updates as any; - if (typeof points !== "undefined") { + if ( + isElbowArrow(element) && + (Object.keys(updates).length === 0 || // normalization case + typeof points !== "undefined" || // repositioning + typeof fixedSegments !== "undefined") // segment fixing + ) { + const elementsMap = toBrandedType( + Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(), + ); + + updates = { + ...updates, + angle: 0 as Radians, + ...updateElbowArrowPoints( + { + ...element, + x: updates.x || element.x, + y: updates.y || element.y, + }, + elementsMap, + { + fixedSegments, + points, + }, + { + isDragging: options?.isDragging, + }, + ), + }; + } else if (typeof points !== "undefined") { updates = { ...getSizeFromPoints(points), ...updates }; } diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index daeb06d5d..a79f077aa 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -18,6 +18,8 @@ import type { ExcalidrawIframeElement, ElementsMap, ExcalidrawArrowElement, + FixedSegment, + ExcalidrawElbowArrowElement, } from "./types"; import { arrayToMap, @@ -450,15 +452,34 @@ export const newLinearElement = ( }; }; -export const newArrowElement = ( +export const newArrowElement = ( opts: { type: ExcalidrawArrowElement["type"]; startArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null; points?: ExcalidrawArrowElement["points"]; - elbowed?: boolean; + elbowed?: T; + fixedSegments?: FixedSegment[] | null; } & ElementConstructorOpts, -): NonDeleted => { +): T extends true + ? NonDeleted + : NonDeleted => { + if (opts.elbowed) { + return { + ..._newElementBase(opts.type, opts), + points: opts.points || [], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: opts.startArrowhead || null, + endArrowhead: opts.endArrowhead || null, + elbowed: true, + fixedSegments: opts.fixedSegments || [], + startIsSpecial: false, + endIsSpecial: false, + } as NonDeleted; + } + return { ..._newElementBase(opts.type, opts), points: opts.points || [], @@ -467,8 +488,10 @@ export const newArrowElement = ( endBinding: null, startArrowhead: opts.startArrowhead || null, endArrowhead: opts.endArrowhead || null, - elbowed: opts.elbowed || false, - }; + elbowed: false, + } as T extends true + ? NonDeleted + : NonDeleted; }; export const newImageElement = ( diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 568cb1e8f..9789f721a 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -10,6 +10,7 @@ import type { ExcalidrawImageElement, ElementsMap, SceneElementsMap, + ExcalidrawElbowArrowElement, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -53,7 +54,6 @@ import { import { wrapText } from "./textWrapping"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; -import { mutateElbowArrow } from "./routing"; import type { GlobalPoint } from "../../math"; import { pointCenter, @@ -177,10 +177,10 @@ export const transformElements = ( elementsMap, transformHandleType, scene, + originalElements, { shouldResizeFromCenter, shouldMaintainAspectRatio, - originalElementsMap: originalElements, flipByX, flipByY, nextWidth, @@ -531,8 +531,10 @@ const rotateMultipleElements = ( ); if (isElbowArrow(element)) { - const points = getArrowLocalFixedPoints(element, elementsMap); - mutateElbowArrow(element, elementsMap, points); + // Needed to re-route the arrow + mutateElement(element, { + points: getArrowLocalFixedPoints(element, elementsMap), + }); } else { mutateElement( element, @@ -1201,6 +1203,7 @@ export const resizeMultipleElements = ( elementsMap: ElementsMap, handleDirection: TransformHandleDirection, scene: Scene, + originalElementsMap: ElementsMap, { shouldMaintainAspectRatio = false, shouldResizeFromCenter = false, @@ -1208,7 +1211,6 @@ export const resizeMultipleElements = ( flipByY = false, nextHeight, nextWidth, - originalElementsMap, originalBoundingBox, }: { nextWidth?: number; @@ -1217,7 +1219,6 @@ export const resizeMultipleElements = ( shouldResizeFromCenter?: boolean; flipByX?: boolean; flipByY?: boolean; - originalElementsMap?: ElementsMap; // added to improve performance originalBoundingBox?: BoundingBox; } = {}, @@ -1387,6 +1388,9 @@ export const resizeMultipleElements = ( fontSize?: ExcalidrawTextElement["fontSize"]; scale?: ExcalidrawImageElement["scale"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"]; + startBinding?: ExcalidrawElbowArrowElement["startBinding"]; + endBinding?: ExcalidrawElbowArrowElement["endBinding"]; + fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"]; }; }[] = []; @@ -1427,6 +1431,44 @@ export const resizeMultipleElements = ( ...rescaledPoints, }; + if (isElbowArrow(orig)) { + // Mirror fixed point binding for elbow arrows + // when resize goes into the negative direction + if (orig.startBinding) { + update.startBinding = { + ...orig.startBinding, + fixedPoint: [ + flipByX + ? -orig.startBinding.fixedPoint[0] + 1 + : orig.startBinding.fixedPoint[0], + flipByY + ? -orig.startBinding.fixedPoint[1] + 1 + : orig.startBinding.fixedPoint[1], + ], + }; + } + if (orig.endBinding) { + update.endBinding = { + ...orig.endBinding, + fixedPoint: [ + flipByX + ? -orig.endBinding.fixedPoint[0] + 1 + : orig.endBinding.fixedPoint[0], + flipByY + ? -orig.endBinding.fixedPoint[1] + 1 + : orig.endBinding.fixedPoint[1], + ], + }; + } + if (orig.fixedSegments && rescaledPoints.points) { + update.fixedSegments = orig.fixedSegments.map((segment) => ({ + ...segment, + start: rescaledPoints.points[segment.index - 1], + end: rescaledPoints.points[segment.index], + })); + } + } + if (isImageElement(orig)) { update.scale = [ orig.scale[0] * flipFactorX, @@ -1472,7 +1514,10 @@ export const resizeMultipleElements = ( } of elementsAndUpdates) { const { width, height, angle } = update; - mutateElement(element, update, false); + mutateElement(element, update, false, { + // needed for the fixed binding point udpate to take effect + isDragging: true, + }); updateBoundElements(element, elementsMap as SceneElementsMap, { simultaneouslyUpdated: elementsToUpdate, diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts deleted file mode 100644 index acf4f849d..000000000 --- a/packages/excalidraw/element/routing.ts +++ /dev/null @@ -1,1110 +0,0 @@ -import type { Radians } from "../../math"; -import { - pointFrom, - pointScaleFromOrigin, - pointTranslate, - vector, - vectorCross, - vectorFromPoint, - vectorScale, - type GlobalPoint, - type LocalPoint, - type Vector, -} from "../../math"; -import BinaryHeap from "../binaryheap"; -import { getSizeFromPoints } from "../points"; -import { aabbForElement, pointInsideBounds } from "../shapes"; -import type { AppState } from "../types"; -import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { - bindPointToSnapToElementOutline, - distanceToBindableElement, - avoidRectangularCorner, - getHoveredElementForBinding, - FIXED_BINDING_DISTANCE, - getHeadingForElbowArrowSnap, - getGlobalFixedPointForBindableElement, - snapToMid, -} from "./binding"; -import type { Bounds } from "./bounds"; -import type { Heading } from "./heading"; -import { - compareHeading, - flipHeading, - HEADING_DOWN, - HEADING_LEFT, - HEADING_RIGHT, - HEADING_UP, - vectorToHeading, -} from "./heading"; -import type { ElementUpdate } from "./mutateElement"; -import { mutateElement } from "./mutateElement"; -import { isBindableElement, isRectanguloidElement } from "./typeChecks"; -import type { - ExcalidrawElbowArrowElement, - NonDeletedSceneElementsMap, - SceneElementsMap, -} from "./types"; -import type { ElementsMap, ExcalidrawBindableElement } from "./types"; - -type GridAddress = [number, number] & { _brand: "gridaddress" }; - -type Node = { - f: number; - g: number; - h: number; - closed: boolean; - visited: boolean; - parent: Node | null; - pos: GlobalPoint; - addr: GridAddress; -}; - -type Grid = { - row: number; - col: number; - data: (Node | null)[]; -}; - -const BASE_PADDING = 40; - -export const mutateElbowArrow = ( - arrow: ExcalidrawElbowArrowElement, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly LocalPoint[], - offset?: Vector, - otherUpdates?: Omit< - ElementUpdate, - "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points" - >, - options?: { - isDragging?: boolean; - informMutation?: boolean; - zoom?: AppState["zoom"]; - }, -) => { - const update = updateElbowArrow( - arrow, - elementsMap, - nextPoints, - offset, - options, - ); - if (update) { - mutateElement( - arrow, - { - ...otherUpdates, - ...update, - angle: 0 as Radians, - }, - options?.informMutation, - ); - } else { - console.error("Elbow arrow cannot find a route"); - } -}; - -export const updateElbowArrow = ( - arrow: ExcalidrawElbowArrowElement, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly LocalPoint[], - offset?: Vector, - options?: { - isDragging?: boolean; - disableBinding?: boolean; - informMutation?: boolean; - zoom?: AppState["zoom"]; - }, -): ElementUpdate | null => { - const origStartGlobalPoint: GlobalPoint = pointTranslate( - pointTranslate( - nextPoints[0], - vector(arrow.x, arrow.y), - ), - offset, - ); - const origEndGlobalPoint: GlobalPoint = pointTranslate( - pointTranslate( - nextPoints[nextPoints.length - 1], - vector(arrow.x, arrow.y), - ), - offset, - ); - - const startElement = - arrow.startBinding && - getBindableElementForId(arrow.startBinding.elementId, elementsMap); - const endElement = - arrow.endBinding && - getBindableElementForId(arrow.endBinding.elementId, elementsMap); - const [hoveredStartElement, hoveredEndElement] = options?.isDragging - ? getHoveredElements( - origStartGlobalPoint, - origEndGlobalPoint, - elementsMap, - options?.zoom, - ) - : [startElement, endElement]; - const startGlobalPoint = getGlobalPoint( - arrow.startBinding?.fixedPoint, - origStartGlobalPoint, - origEndGlobalPoint, - elementsMap, - startElement, - hoveredStartElement, - options?.isDragging, - ); - const endGlobalPoint = getGlobalPoint( - arrow.endBinding?.fixedPoint, - origEndGlobalPoint, - origStartGlobalPoint, - elementsMap, - endElement, - hoveredEndElement, - options?.isDragging, - ); - const startHeading = getBindPointHeading( - startGlobalPoint, - endGlobalPoint, - elementsMap, - hoveredStartElement, - origStartGlobalPoint, - ); - const endHeading = getBindPointHeading( - endGlobalPoint, - startGlobalPoint, - elementsMap, - hoveredEndElement, - origEndGlobalPoint, - ); - const startPointBounds = [ - startGlobalPoint[0] - 2, - startGlobalPoint[1] - 2, - startGlobalPoint[0] + 2, - startGlobalPoint[1] + 2, - ] as Bounds; - const endPointBounds = [ - endGlobalPoint[0] - 2, - endGlobalPoint[1] - 2, - endGlobalPoint[0] + 2, - endGlobalPoint[1] + 2, - ] as Bounds; - const startElementBounds = hoveredStartElement - ? aabbForElement( - hoveredStartElement, - offsetFromHeading( - startHeading, - arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, - 1, - ), - ) - : startPointBounds; - const endElementBounds = hoveredEndElement - ? aabbForElement( - hoveredEndElement, - offsetFromHeading( - endHeading, - arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, - 1, - ), - ) - : endPointBounds; - const boundsOverlap = - pointInsideBounds( - startGlobalPoint, - hoveredEndElement - ? aabbForElement( - hoveredEndElement, - offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), - ) - : endPointBounds, - ) || - pointInsideBounds( - endGlobalPoint, - hoveredStartElement - ? aabbForElement( - hoveredStartElement, - offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), - ) - : startPointBounds, - ); - const commonBounds = commonAABB( - boundsOverlap - ? [startPointBounds, endPointBounds] - : [startElementBounds, endElementBounds], - ); - const dynamicAABBs = generateDynamicAABBs( - boundsOverlap ? startPointBounds : startElementBounds, - boundsOverlap ? endPointBounds : endElementBounds, - commonBounds, - boundsOverlap - ? offsetFromHeading( - startHeading, - !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, - 0, - ) - : offsetFromHeading( - startHeading, - !hoveredStartElement && !hoveredEndElement - ? 0 - : BASE_PADDING - - (arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - boundsOverlap - ? offsetFromHeading( - endHeading, - !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, - 0, - ) - : offsetFromHeading( - endHeading, - !hoveredStartElement && !hoveredEndElement - ? 0 - : BASE_PADDING - - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - boundsOverlap, - hoveredStartElement && aabbForElement(hoveredStartElement), - hoveredEndElement && aabbForElement(hoveredEndElement), - ); - const startDonglePosition = getDonglePosition( - dynamicAABBs[0], - startHeading, - startGlobalPoint, - ); - const endDonglePosition = getDonglePosition( - dynamicAABBs[1], - endHeading, - endGlobalPoint, - ); - - // Canculate Grid positions - const grid = calculateGrid( - dynamicAABBs, - startDonglePosition ? startDonglePosition : startGlobalPoint, - startHeading, - endDonglePosition ? endDonglePosition : endGlobalPoint, - endHeading, - commonBounds, - ); - - const startDongle = - startDonglePosition && pointToGridNode(startDonglePosition, grid); - const endDongle = - endDonglePosition && pointToGridNode(endDonglePosition, grid); - - // Do not allow stepping on the true end or true start points - const endNode = pointToGridNode(endGlobalPoint, grid); - if (endNode && hoveredEndElement) { - endNode.closed = true; - } - const startNode = pointToGridNode(startGlobalPoint, grid); - if (startNode && arrow.startBinding) { - startNode.closed = true; - } - const dongleOverlap = - startDongle && - endDongle && - (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || - pointInsideBounds(endDongle.pos, dynamicAABBs[0])); - - // Create path to end dongle from start dongle - const path = astar( - startDongle ? startDongle : startNode!, - endDongle ? endDongle : endNode!, - grid, - startHeading ? startHeading : HEADING_RIGHT, - endHeading ? endHeading : HEADING_RIGHT, - dongleOverlap ? [] : dynamicAABBs, - ); - - if (path) { - const points = path.map((node) => [ - node.pos[0], - node.pos[1], - ]) as GlobalPoint[]; - startDongle && points.unshift(startGlobalPoint); - endDongle && points.push(endGlobalPoint); - - return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0); - } - - return null; -}; - -const offsetFromHeading = ( - heading: Heading, - head: number, - side: number, -): [number, number, number, number] => { - switch (heading) { - case HEADING_UP: - return [head, side, side, side]; - case HEADING_RIGHT: - return [side, head, side, side]; - case HEADING_DOWN: - return [side, side, head, side]; - } - - return [side, side, side, head]; -}; - -/** - * Routing algorithm based on the A* path search algorithm. - * @see https://www.geeksforgeeks.org/a-search-algorithm/ - * - * Binary heap is used to optimize node lookup. - * See {@link calculateGrid} for the grid calculation details. - * - * Additional modifications added due to aesthetic route reasons: - * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier) - * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment - */ -const astar = ( - start: Node, - end: Node, - grid: Grid, - startHeading: Heading, - endHeading: Heading, - aabbs: Bounds[], -) => { - const bendMultiplier = m_dist(start.pos, end.pos); - const open = new BinaryHeap((node) => node.f); - - open.push(start); - - while (open.size() > 0) { - // Grab the lowest f(x) to process next. Heap keeps this sorted for us. - const current = open.pop(); - - if (!current || current.closed) { - // Current is not passable, continue with next element - continue; - } - - // End case -- result has been found, return the traced path. - if (current === end) { - return pathTo(start, current); - } - - // Normal case -- move current from open to closed, process each of its neighbors. - current.closed = true; - - // Find all neighbors for the current node. - const neighbors = getNeighbors(current.addr, grid); - - for (let i = 0; i < 4; i++) { - const neighbor = neighbors[i]; - - if (!neighbor || neighbor.closed) { - // Not a valid node to process, skip to next neighbor. - continue; - } - - // Intersect - const neighborHalfPoint = pointScaleFromOrigin( - neighbor.pos, - current.pos, - 0.5, - ); - if ( - isAnyTrue( - ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)), - ) - ) { - continue; - } - - // The g score is the shortest distance from start to current node. - // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. - const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); - const previousDirection = current.parent - ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos)) - : startHeading; - - // Do not allow going in reverse - const reverseHeading = flipHeading(previousDirection); - const neighborIsReverseRoute = - compareHeading(reverseHeading, neighborHeading) || - (gridAddressesEqual(start.addr, neighbor.addr) && - compareHeading(neighborHeading, startHeading)) || - (gridAddressesEqual(end.addr, neighbor.addr) && - compareHeading(neighborHeading, endHeading)); - if (neighborIsReverseRoute) { - continue; - } - - const directionChange = previousDirection !== neighborHeading; - const gScore = - current.g + - m_dist(neighbor.pos, current.pos) + - (directionChange ? Math.pow(bendMultiplier, 3) : 0); - - const beenVisited = neighbor.visited; - - if (!beenVisited || gScore < neighbor.g) { - const estBendCount = estimateSegmentCount( - neighbor, - end, - neighborHeading, - endHeading, - ); - // Found an optimal (so far) path to this node. Take score for node to see how good it is. - neighbor.visited = true; - neighbor.parent = current; - neighbor.h = - m_dist(end.pos, neighbor.pos) + - estBendCount * Math.pow(bendMultiplier, 2); - neighbor.g = gScore; - neighbor.f = neighbor.g + neighbor.h; - if (!beenVisited) { - // Pushing to heap will put it in proper place based on the 'f' value. - open.push(neighbor); - } else { - // Already seen the node, but since it has been rescored we need to reorder it in the heap - open.rescoreElement(neighbor); - } - } - } - } - - return null; -}; - -const pathTo = (start: Node, node: Node) => { - let curr = node; - const path = []; - while (curr.parent) { - path.unshift(curr); - curr = curr.parent; - } - path.unshift(start); - - return path; -}; - -const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => - Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); - -/** - * Create dynamically resizing, always touching - * bounding boxes having a minimum extent represented - * by the given static bounds. - */ -const generateDynamicAABBs = ( - a: Bounds, - b: Bounds, - common: Bounds, - startDifference?: [number, number, number, number], - endDifference?: [number, number, number, number], - disableSideHack?: boolean, - startElementBounds?: Bounds | null, - endElementBounds?: Bounds | null, -): Bounds[] => { - const startEl = startElementBounds ?? a; - const endEl = endElementBounds ?? b; - const [startUp, startRight, startDown, startLeft] = startDifference ?? [ - 0, 0, 0, 0, - ]; - const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0]; - - const first = [ - a[0] > b[2] - ? a[1] > b[3] || a[3] < b[1] - ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft) - : (startEl[0] + endEl[2]) / 2 - : a[0] > b[0] - ? a[0] - startLeft - : common[0] - startLeft, - a[1] > b[3] - ? a[0] > b[2] || a[2] < b[0] - ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp) - : (startEl[1] + endEl[3]) / 2 - : a[1] > b[1] - ? a[1] - startUp - : common[1] - startUp, - a[2] < b[0] - ? a[1] > b[3] || a[3] < b[1] - ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight) - : (startEl[2] + endEl[0]) / 2 - : a[2] < b[2] - ? a[2] + startRight - : common[2] + startRight, - a[3] < b[1] - ? a[0] > b[2] || a[2] < b[0] - ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown) - : (startEl[3] + endEl[1]) / 2 - : a[3] < b[3] - ? a[3] + startDown - : common[3] + startDown, - ] as Bounds; - const second = [ - b[0] > a[2] - ? b[1] > a[3] || b[3] < a[1] - ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft) - : (endEl[0] + startEl[2]) / 2 - : b[0] > a[0] - ? b[0] - endLeft - : common[0] - endLeft, - b[1] > a[3] - ? b[0] > a[2] || b[2] < a[0] - ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp) - : (endEl[1] + startEl[3]) / 2 - : b[1] > a[1] - ? b[1] - endUp - : common[1] - endUp, - b[2] < a[0] - ? b[1] > a[3] || b[3] < a[1] - ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight) - : (endEl[2] + startEl[0]) / 2 - : b[2] < a[2] - ? b[2] + endRight - : common[2] + endRight, - b[3] < a[1] - ? b[0] > a[2] || b[2] < a[0] - ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown) - : (endEl[3] + startEl[1]) / 2 - : b[3] < a[3] - ? b[3] + endDown - : common[3] + endDown, - ] as Bounds; - - const c = commonAABB([first, second]); - if ( - !disableSideHack && - first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 && - first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001 - ) { - const [endCenterX, endCenterY] = [ - (second[0] + second[2]) / 2, - (second[1] + second[3]) / 2, - ]; - if (b[0] > a[2] && a[1] > b[3]) { - // BOTTOM LEFT - const cX = first[2] + (second[0] - first[2]) / 2; - const cY = second[3] + (first[1] - second[3]) / 2; - - if ( - vectorCross( - vector(a[2] - endCenterX, a[1] - endCenterY), - vector(a[0] - endCenterX, a[3] - endCenterY), - ) > 0 - ) { - return [ - [first[0], first[1], cX, first[3]], - [cX, second[1], second[2], second[3]], - ]; - } - - return [ - [first[0], cY, first[2], first[3]], - [second[0], second[1], second[2], cY], - ]; - } else if (a[2] < b[0] && a[3] < b[1]) { - // TOP LEFT - const cX = first[2] + (second[0] - first[2]) / 2; - const cY = first[3] + (second[1] - first[3]) / 2; - - if ( - vectorCross( - vector(a[0] - endCenterX, a[1] - endCenterY), - vector(a[2] - endCenterX, a[3] - endCenterY), - ) > 0 - ) { - return [ - [first[0], first[1], first[2], cY], - [second[0], cY, second[2], second[3]], - ]; - } - - return [ - [first[0], first[1], cX, first[3]], - [cX, second[1], second[2], second[3]], - ]; - } else if (a[0] > b[2] && a[3] < b[1]) { - // TOP RIGHT - const cX = second[2] + (first[0] - second[2]) / 2; - const cY = first[3] + (second[1] - first[3]) / 2; - - if ( - vectorCross( - vector(a[2] - endCenterX, a[1] - endCenterY), - vector(a[0] - endCenterX, a[3] - endCenterY), - ) > 0 - ) { - return [ - [cX, first[1], first[2], first[3]], - [second[0], second[1], cX, second[3]], - ]; - } - - return [ - [first[0], first[1], first[2], cY], - [second[0], cY, second[2], second[3]], - ]; - } else if (a[0] > b[2] && a[1] > b[3]) { - // BOTTOM RIGHT - const cX = second[2] + (first[0] - second[2]) / 2; - const cY = second[3] + (first[1] - second[3]) / 2; - - if ( - vectorCross( - vector(a[0] - endCenterX, a[1] - endCenterY), - vector(a[2] - endCenterX, a[3] - endCenterY), - ) > 0 - ) { - return [ - [cX, first[1], first[2], first[3]], - [second[0], second[1], cX, second[3]], - ]; - } - - return [ - [first[0], cY, first[2], first[3]], - [second[0], second[1], second[2], cY], - ]; - } - } - - return [first, second]; -}; - -/** - * Calculates the grid which is used as nodes at - * the grid line intersections by the A* algorithm. - * - * NOTE: This is not a uniform grid. It is built at - * various intersections of bounding boxes. - */ -const calculateGrid = ( - aabbs: Bounds[], - start: GlobalPoint, - startHeading: Heading, - end: GlobalPoint, - endHeading: Heading, - common: Bounds, -): Grid => { - const horizontal = new Set(); - const vertical = new Set(); - - if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) { - vertical.add(start[1]); - } else { - horizontal.add(start[0]); - } - if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) { - vertical.add(end[1]); - } else { - horizontal.add(end[0]); - } - - aabbs.forEach((aabb) => { - horizontal.add(aabb[0]); - horizontal.add(aabb[2]); - vertical.add(aabb[1]); - vertical.add(aabb[3]); - }); - - horizontal.add(common[0]); - horizontal.add(common[2]); - vertical.add(common[1]); - vertical.add(common[3]); - - const _vertical = Array.from(vertical).sort((a, b) => a - b); - const _horizontal = Array.from(horizontal).sort((a, b) => a - b); - - return { - row: _vertical.length, - col: _horizontal.length, - data: _vertical.flatMap((y, row) => - _horizontal.map( - (x, col): Node => ({ - f: 0, - g: 0, - h: 0, - closed: false, - visited: false, - parent: null, - addr: [col, row] as GridAddress, - pos: [x, y] as GlobalPoint, - }), - ), - ), - }; -}; - -const getDonglePosition = ( - bounds: Bounds, - heading: Heading, - p: GlobalPoint, -): GlobalPoint => { - switch (heading) { - case HEADING_UP: - return pointFrom(p[0], bounds[1]); - case HEADING_RIGHT: - return pointFrom(bounds[2], p[1]); - case HEADING_DOWN: - return pointFrom(p[0], bounds[3]); - } - return pointFrom(bounds[0], p[1]); -}; - -const estimateSegmentCount = ( - start: Node, - end: Node, - startHeading: Heading, - endHeading: Heading, -) => { - if (endHeading === HEADING_RIGHT) { - switch (startHeading) { - case HEADING_RIGHT: { - if (start.pos[0] >= end.pos[0]) { - return 4; - } - if (start.pos[1] === end.pos[1]) { - return 0; - } - return 2; - } - case HEADING_UP: - if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { - return 1; - } - return 3; - case HEADING_DOWN: - if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { - return 1; - } - return 3; - case HEADING_LEFT: - if (start.pos[1] === end.pos[1]) { - return 4; - } - return 2; - } - } else if (endHeading === HEADING_LEFT) { - switch (startHeading) { - case HEADING_RIGHT: - if (start.pos[1] === end.pos[1]) { - return 4; - } - return 2; - case HEADING_UP: - if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { - return 1; - } - return 3; - case HEADING_DOWN: - if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { - return 1; - } - return 3; - case HEADING_LEFT: - if (start.pos[0] <= end.pos[0]) { - return 4; - } - if (start.pos[1] === end.pos[1]) { - return 0; - } - return 2; - } - } else if (endHeading === HEADING_UP) { - switch (startHeading) { - case HEADING_RIGHT: - if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { - return 1; - } - return 3; - case HEADING_UP: - if (start.pos[1] >= end.pos[1]) { - return 4; - } - if (start.pos[0] === end.pos[0]) { - return 0; - } - return 2; - case HEADING_DOWN: - if (start.pos[0] === end.pos[0]) { - return 4; - } - return 2; - case HEADING_LEFT: - if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { - return 1; - } - return 3; - } - } else if (endHeading === HEADING_DOWN) { - switch (startHeading) { - case HEADING_RIGHT: - if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { - return 1; - } - return 3; - case HEADING_UP: - if (start.pos[0] === end.pos[0]) { - return 4; - } - return 2; - case HEADING_DOWN: - if (start.pos[1] <= end.pos[1]) { - return 4; - } - if (start.pos[0] === end.pos[0]) { - return 0; - } - return 2; - case HEADING_LEFT: - if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { - return 1; - } - return 3; - } - } - return 0; -}; - -/** - * Get neighboring points for a gived grid address - */ -const getNeighbors = ([col, row]: [number, number], grid: Grid) => - [ - gridNodeFromAddr([col, row - 1], grid), - gridNodeFromAddr([col + 1, row], grid), - gridNodeFromAddr([col, row + 1], grid), - gridNodeFromAddr([col - 1, row], grid), - ] as [Node | null, Node | null, Node | null, Node | null]; - -const gridNodeFromAddr = ( - [col, row]: [col: number, row: number], - grid: Grid, -): Node | null => { - if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) { - return null; - } - - return grid.data[row * grid.col + col] ?? null; -}; - -/** - * Get node for global point on canvas (if exists) - */ -const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => { - for (let col = 0; col < grid.col; col++) { - for (let row = 0; row < grid.row; row++) { - const candidate = gridNodeFromAddr([col, row], grid); - if ( - candidate && - point[0] === candidate.pos[0] && - point[1] === candidate.pos[1] - ) { - return candidate; - } - } - } - - return null; -}; - -const commonAABB = (aabbs: Bounds[]): Bounds => [ - Math.min(...aabbs.map((aabb) => aabb[0])), - Math.min(...aabbs.map((aabb) => aabb[1])), - Math.max(...aabbs.map((aabb) => aabb[2])), - Math.max(...aabbs.map((aabb) => aabb[3])), -]; - -/// #region Utils - -const getBindableElementForId = ( - id: string, - elementsMap: ElementsMap, -): ExcalidrawBindableElement | null => { - const element = elementsMap.get(id); - if (element && isBindableElement(element)) { - return element; - } - - return null; -}; - -const normalizedArrowElementUpdate = ( - global: GlobalPoint[], - externalOffsetX?: number, - externalOffsetY?: number, -): { - points: LocalPoint[]; - x: number; - y: number; - width: number; - height: number; -} => { - const offsetX = global[0][0]; - const offsetY = global[0][1]; - - const points = global.map((p) => - pointTranslate( - p, - vectorScale(vectorFromPoint(global[0]), -1), - ), - ); - - return { - points, - x: offsetX + (externalOffsetX ?? 0), - y: offsetY + (externalOffsetY ?? 0), - ...getSizeFromPoints(points), - }; -}; - -/// If last and current segments have the same heading, skip the middle point -const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] => - points - .slice(2) - .reduce( - (result, p) => - compareHeading( - vectorToHeading( - vectorFromPoint( - result[result.length - 1], - result[result.length - 2], - ), - ), - vectorToHeading(vectorFromPoint(p, result[result.length - 1])), - ) - ? [...result.slice(0, -1), p] - : [...result, p], - [points[0] ?? [0, 0], points[1] ?? [1, 0]], - ); - -const neighborIndexToHeading = (idx: number): Heading => { - switch (idx) { - case 0: - return HEADING_UP; - case 1: - return HEADING_RIGHT; - case 2: - return HEADING_DOWN; - } - return HEADING_LEFT; -}; - -const getGlobalPoint = ( - fixedPointRatio: [number, number] | undefined | null, - initialPoint: GlobalPoint, - otherPoint: GlobalPoint, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - boundElement?: ExcalidrawBindableElement | null, - hoveredElement?: ExcalidrawBindableElement | null, - isDragging?: boolean, -): GlobalPoint => { - if (isDragging) { - if (hoveredElement) { - const snapPoint = getSnapPoint( - initialPoint, - otherPoint, - hoveredElement, - elementsMap, - ); - - return snapToMid(hoveredElement, snapPoint); - } - - return initialPoint; - } - - if (boundElement) { - const fixedGlobalPoint = getGlobalFixedPointForBindableElement( - fixedPointRatio || [0, 0], - boundElement, - ); - - // NOTE: Resize scales the binding position point too, so we need to update it - return Math.abs( - distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) - - FIXED_BINDING_DISTANCE, - ) > 0.01 - ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap) - : fixedGlobalPoint; - } - - return initialPoint; -}; - -const getSnapPoint = ( - p: GlobalPoint, - otherPoint: GlobalPoint, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, -) => - bindPointToSnapToElementOutline( - isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, - otherPoint, - element, - elementsMap, - ); - -const getBindPointHeading = ( - p: GlobalPoint, - otherPoint: GlobalPoint, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - hoveredElement: ExcalidrawBindableElement | null | undefined, - origPoint: GlobalPoint, -) => - getHeadingForElbowArrowSnap( - p, - otherPoint, - hoveredElement, - hoveredElement && - aabbForElement( - hoveredElement, - Array(4).fill( - distanceToBindableElement(hoveredElement, p, elementsMap), - ) as [number, number, number, number], - ), - elementsMap, - origPoint, - ); - -const getHoveredElements = ( - origStartGlobalPoint: GlobalPoint, - origEndGlobalPoint: GlobalPoint, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - zoom?: AppState["zoom"], -) => { - // TODO: Might be a performance bottleneck and the Map type - // remembers the insertion order anyway... - const nonDeletedSceneElementsMap = toBrandedType( - new Map([...elementsMap].filter((el) => !el[1].isDeleted)), - ); - const elements = Array.from(elementsMap.values()); - return [ - getHoveredElementForBinding( - tupleToCoors(origStartGlobalPoint), - elements, - nonDeletedSceneElementsMap, - zoom, - true, - ), - getHoveredElementForBinding( - tupleToCoors(origEndGlobalPoint), - elements, - nonDeletedSceneElementsMap, - zoom, - true, - ), - ]; -}; - -const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => - a[0] === b[0] && a[1] === b[1]; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 9f6d8e0b8..0ab88358d 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & endArrowhead: Arrowhead | null; }>; +export type FixedSegment = { + start: LocalPoint; + end: LocalPoint; + index: number; +}; + export type ExcalidrawArrowElement = ExcalidrawLinearElement & Readonly<{ type: "arrow"; @@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge< elbowed: true; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; + fixedSegments: 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 + * the data from the points array. It allows creating the expected arrow + * path when the arrow with fixed segments is bound on a horizontal side and + * moved to a vertical and vica versa. + */ + startIsSpecial: boolean | null; + /** + * Marks that the 3rd point backwards from the end should be used as the 2nd + * point of the arrow in order to temporarily hide the last segment of the + * arrow without losing the data from the points array. It allows creating + * the expected arrow path when the arrow with fixed segments is bound on a + * horizontal side and moved to a vertical and vica versa. + */ + endIsSpecial: boolean | null; } >; diff --git a/packages/excalidraw/fractionalIndex.ts b/packages/excalidraw/fractionalIndex.ts index e594a1358..dfb8a4672 100644 --- a/packages/excalidraw/fractionalIndex.ts +++ b/packages/excalidraw/fractionalIndex.ts @@ -190,7 +190,6 @@ export const syncInvalidIndices = ( ): OrderedExcalidrawElement[] => { const indicesGroups = getInvalidIndicesGroups(elements); const elementsUpdates = generateIndices(elements, indicesGroups); - for (const [element, update] of elementsUpdates) { mutateElement(element, update, false); } diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 0cbf636fb..fdbda46b8 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -117,6 +117,7 @@ "fonteditor-core": "2.4.1", "harfbuzzjs": "0.3.6", "import-meta-loader": "1.1.0", + "jest-diff": "29.7.0", "mini-css-extract-plugin": "2.6.1", "postcss-loader": "7.0.1", "sass-loader": "13.0.2", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index b474a0d80..3a070e667 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -29,7 +29,7 @@ import { getOmitSidesForDevice, shouldShowBoundingBox, } from "../element/transformHandles"; -import { arrayToMap, throttleRAF } from "../utils"; +import { arrayToMap, invariant, throttleRAF } from "../utils"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE, @@ -78,9 +78,32 @@ import type { InteractiveSceneRenderConfig, RenderableElementsMap, } from "../scene/types"; -import type { GlobalPoint, LocalPoint, Radians } from "../../math"; +import { + pointFrom, + type GlobalPoint, + type LocalPoint, + type Radians, +} from "../../math"; import { getCornerRadius } from "../shapes"; +const renderElbowArrowMidPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + invariant(appState.selectedLinearElement, "selectedLinearElement is null"); + + const { segmentMidPointHoveredCoords } = appState.selectedLinearElement; + + invariant(segmentMidPointHoveredCoords, "midPointCoords is null"); + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(segmentMidPointHoveredCoords, context, appState); + + context.restore(); +}; + const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -490,7 +513,7 @@ const renderLinearPointHandles = ( context.save(); context.translate(appState.scrollX, appState.scrollY); context.lineWidth = 1 / appState.zoom.value; - const points = LinearElementEditor.getPointsGlobalCoordinates( + const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates( element, elementsMap, ); @@ -510,55 +533,57 @@ const renderLinearPointHandles = ( renderSingleLinearPoint(context, appState, point, radius, isSelected); }); - //Rendering segment mid points - const midPoints = LinearElementEditor.getEditorMidPoints( - element, - elementsMap, - appState, - ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); - - midPoints.forEach((segmentMidPoint) => { - if ( - appState?.selectedLinearElement?.segmentMidPointHoveredCoords && - LinearElementEditor.arePointsEqual( - segmentMidPoint, - appState.selectedLinearElement.segmentMidPointHoveredCoords, - ) - ) { - // The order of renderingSingleLinearPoint and highLight points is different - // inside vs outside editor as hover states are different, - // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the - // editor original point is visible and hover state is just an outer circle. - if (appState.editingLinearElement) { + // Rendering segment mid points + if (isElbowArrow(element)) { + const fixedSegments = + element.fixedSegments?.map((segment) => segment.index) || []; + points.slice(0, -1).forEach((p, idx) => { + if ( + !LinearElementEditor.isSegmentTooShort( + element, + points[idx + 1], + points[idx], + idx, + appState.zoom, + ) + ) { renderSingleLinearPoint( context, appState, - segmentMidPoint, - radius, + pointFrom( + (p[0] + points[idx + 1][0]) / 2, + (p[1] + points[idx + 1][1]) / 2, + ), + POINT_HANDLE_SIZE / 2, false, + !fixedSegments.includes(idx + 1), ); - highlightPoint(segmentMidPoint, context, appState); - } else { - highlightPoint(segmentMidPoint, context, appState); + } + }); + } else { + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ).filter( + (midPoint, idx, midPoints): midPoint is GlobalPoint => + midPoint !== null && + !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), + ); + + midPoints.forEach((segmentMidPoint) => { + if (appState.editingLinearElement || points.length === 2) { renderSingleLinearPoint( context, appState, segmentMidPoint, - radius, + POINT_HANDLE_SIZE / 2, false, + true, ); } - } else if (appState.editingLinearElement || points.length === 2) { - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - POINT_HANDLE_SIZE / 2, - false, - true, - ); - } - }); + }); + } context.restore(); }; @@ -864,6 +889,12 @@ const _renderInteractiveScene = ({ } if ( + isElbowArrow(selectedElements[0]) && + appState.selectedLinearElement && + appState.selectedLinearElement.segmentMidPointHoveredCoords + ) { + renderElbowArrowMidPointHighlight(context, appState); + } else if ( appState.selectedLinearElement && appState.selectedLinearElement.hoverPointIndex >= 0 && !( @@ -875,6 +906,7 @@ const _renderInteractiveScene = ({ ) { renderLinearElementPointHighlight(context, appState, elementsMap); } + // Paint selected elements if (!appState.multiElement && !appState.editingLinearElement) { const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 00adfb1eb..ba55b2f60 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -23,13 +23,9 @@ import { } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; import type { EmbedsValidationStatus } from "../types"; -import { - pointFrom, - pointDistance, - type GlobalPoint, - type LocalPoint, -} from "../../math"; +import { pointFrom, pointDistance, type LocalPoint } from "../../math"; import { getCornerRadius, isPathALoop } from "../shapes"; +import { headingForPointIsHorizontal } from "../element/heading"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -527,45 +523,53 @@ export const _generateElementShape = ( } }; -const generateElbowArrowShape = ( - points: readonly Point[], +const generateElbowArrowShape = ( + points: readonly LocalPoint[], radius: number, ) => { const subpoints = [] as [number, number][]; for (let i = 1; i < points.length - 1; i += 1) { const prev = points[i - 1]; const next = points[i + 1]; + const point = points[i]; + const prevIsHorizontal = headingForPointIsHorizontal(point, prev); + const nextIsHorizontal = headingForPointIsHorizontal(next, point); const corner = Math.min( radius, pointDistance(points[i], next) / 2, pointDistance(points[i], prev) / 2, ); - if (prev[0] < points[i][0] && prev[1] === points[i][1]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else if (prev[0] === points[i][0] && prev[1] < points[i][1]) { + if (prevIsHorizontal) { + if (prev[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } + } else if (prev[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); - } else if (prev[0] > points[i][0] && prev[1] === points[i][1]) { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); } else { subpoints.push([points[i][0], points[i][1] + corner]); } subpoints.push(points[i] as [number, number]); - if (next[0] < points[i][0] && next[1] === points[i][1]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else if (next[0] === points[i][0] && next[1] < points[i][1]) { + if (nextIsHorizontal) { + if (next[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } + } else if (next[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); - } else if (next[0] > points[i][0] && next[1] === points[i][1]) { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); } else { + // DOWN subpoints.push([points[i][0], points[i][1] + corner]); } } diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 1d1466547..02f1763cf 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -10983,7 +10983,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "focus": "-0.00161", "gap": "3.53708", }, + "endIsSpecial": null, "fillStyle": "solid", + "fixedSegments": null, "frameId": null, "groupIds": [], "height": "448.10100", @@ -11000,9 +11002,13 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o 0, ], [ - "451.90000", + "225.95000", 0, ], + [ + "225.95000", + "448.10100", + ], [ "451.90000", "448.10100", @@ -11022,6 +11028,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "focus": "-0.00159", "gap": 5, }, + "startIsSpecial": null, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -11147,7 +11154,9 @@ History { "focus": "-0.00161", "gap": "3.53708", }, + "endIsSpecial": false, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "236.10000", @@ -11185,6 +11194,7 @@ History { "focus": "-0.00159", "gap": 5, }, + "startIsSpecial": false, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx index 8163b0e01..ca89b47b4 100644 --- a/packages/excalidraw/tests/cropElement.test.tsx +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -171,8 +171,8 @@ describe("Crop an image", () => { // test corner handle aspect ratio preserving UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true); expect(image.width / image.height).toBe(resizedWidth / resizedHeight); - expect(image.width).toBeLessThanOrEqual(initialWidth); - expect(image.height).toBeLessThanOrEqual(initialHeight); + expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001); + expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001); // reset image = API.createElement({ type: "image", width: 200, height: 100 }); @@ -194,7 +194,7 @@ describe("Crop an image", () => { expect(image.width).toBeCloseTo(image.height); // max height should be reached expect(image.height).toBeCloseTo(initialHeight); - expect(image.width).toBe(initialHeight); + expect(image.width).toBeCloseTo(initialHeight); }); }); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index fd36a29d1..f0876611e 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -11,6 +11,7 @@ import type { ExcalidrawMagicFrameElement, ExcalidrawElbowArrowElement, ExcalidrawArrowElement, + FixedSegment, } from "../../element/types"; import { newElement, newTextElement, newLinearElement } from "../../element"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; @@ -197,6 +198,7 @@ export class API { ? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"] : never; elbowed?: boolean; + fixedSegments?: FixedSegment[] | null; }): T extends "arrow" | "line" ? ExcalidrawLinearElement : T extends "freedraw" diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 78f3cde92..5408f4d89 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -2084,7 +2084,8 @@ describe("history", () => { )[0] as ExcalidrawElbowArrowElement; expect(modifiedArrow.points).toEqual([ [0, 0], - [451.9000000000001, 0], + [225.95000000000005, 0], + [225.95000000000005, 448.10100010002003], [451.9000000000001, 448.10100010002003], ]); }); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index a6abfcdc9..3f5acbf63 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -5,7 +5,6 @@ import type { ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, FontString, - SceneElementsMap, } from "../element/types"; import { Excalidraw, mutateElement } from "../index"; import { reseed } from "../random"; @@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => { const [origStartX, origStartY] = [line.x, line.y]; act(() => { - LinearElementEditor.movePoints( - line, - [ - { - index: 0, - point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), - }, - { - index: line.points.length - 1, - point: pointFrom( - line.points[line.points.length - 1][0] - 10, - line.points[line.points.length - 1][1] - 10, - ), - }, - ], - new Map() as SceneElementsMap, - ); + LinearElementEditor.movePoints(line, [ + { + index: 0, + point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), + }, + { + index: line.points.length - 1, + point: pointFrom( + line.points[line.points.length - 1][0] - 10, + line.points[line.points.length - 1][1] - 10, + ), + }, + ]); }); expect(line.x).toBe(origStartX + 10); expect(line.y).toBe(origStartY + 10); diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 381629c8f..431f695cb 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -535,7 +535,7 @@ describe("arrow element", () => { UI.resize([rectangle, arrow], "nw", [300, 350]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); }); }); diff --git a/packages/excalidraw/tests/test-utils.ts b/packages/excalidraw/tests/test-utils.ts index 42cc7784f..4c0eacee6 100644 --- a/packages/excalidraw/tests/test-utils.ts +++ b/packages/excalidraw/tests/test-utils.ts @@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants"; import { getSelectedElements } from "../scene/selection"; import type { ExcalidrawElement } from "../element/types"; import { UI } from "./helpers/ui"; +import { diffStringsUnified } from "jest-diff"; const customQueries = { ...queries, @@ -246,6 +247,36 @@ expect.extend({ pass: false, }; }, + + toCloselyEqualPoints(received, expected, precision) { + if (!Array.isArray(received) || !Array.isArray(expected)) { + throw new Error("expected and received are not point arrays"); + } + + const COMPARE = 1 / Math.pow(10, precision || 2); + const pass = received.every( + (point, idx) => + Math.abs(expected[idx]?.[0] - point[0]) < COMPARE && + Math.abs(expected[idx]?.[1] - point[1]) < COMPARE, + ); + + if (!pass) { + return { + message: () => ` The provided array of points are not close enough. + +${diffStringsUnified( + JSON.stringify(expected, undefined, 2), + JSON.stringify(received, undefined, 2), +)}`, + pass: false, + }; + } + + return { + message: () => `expected ${received} to not be close to ${expected}`, + pass: true, + }; + }, }); /** diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index baddeeadc..96befa731 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -3,6 +3,7 @@ import { lineSegment, pointFrom, type GlobalPoint, + type LocalPoint, } from "../math"; import type { LineSegment } from "../utils"; import type { BoundingBox, Bounds } from "./element/bounds"; @@ -15,6 +16,8 @@ declare global { data: DebugElement[][]; currentFrame?: number; }; + debugDrawPoint: typeof debugDrawPoint; + debugDrawLine: typeof debugDrawLine; } } @@ -147,6 +150,23 @@ export const debugDrawBounds = ( ); }; +export const debugDrawPoints = ( + { + x, + y, + points, + }: { + x: number; + y: number; + points: LocalPoint[]; + }, + options?: any, +) => { + points.forEach((p) => + debugDrawPoint(pointFrom(x + p[0], y + p[1]), options), + ); +}; + export const debugCloseFrame = () => { window.visualDebug?.data.push([]); }; diff --git a/packages/math/line.ts b/packages/math/line.ts index c646e04d4..89999baa9 100644 --- a/packages/math/line.ts +++ b/packages/math/line.ts @@ -1,4 +1,4 @@ -import { pointCenter, pointRotateRads } from "./point"; +import { pointCenter, pointFrom, pointRotateRads } from "./point"; import type { GlobalPoint, Line, LocalPoint, Radians } from "./types"; /** @@ -38,8 +38,16 @@ export function lineFromPointArray

( : undefined; } -// return the coordinates resulting from rotating the given line about an origin by an angle in degrees -// note that when the origin is not given, the midpoint of the given line is used as the origin +/** + * Return the coordinates resulting from rotating the given line about an + * origin by an angle in degrees note that when the origin is not given, + * the midpoint of the given line is used as the origin + * + * @param l + * @param angle + * @param origin + * @returns + */ export const lineRotate = ( l: Line, angle: Radians, @@ -50,3 +58,29 @@ export const lineRotate = ( pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), ); }; + +/** + * Determines the intersection point (unless the lines are parallel) of two + * lines + * + * @param a + * @param b + * @returns + */ +export const linesIntersectAt = ( + a: Line, + b: Line, +): Point | null => { + const A1 = a[1][1] - a[0][1]; + const B1 = a[0][0] - a[1][0]; + const A2 = b[1][1] - b[0][1]; + const B2 = b[0][0] - b[1][0]; + const D = A1 * B2 - A2 * B1; + if (D !== 0) { + const C1 = A1 * a[0][0] + B1 * a[0][1]; + const C2 = A2 * b[0][0] + B2 * b[0][1]; + return pointFrom((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D); + } + + return null; +}; diff --git a/packages/math/point.ts b/packages/math/point.ts index 61de8f139..40f178efb 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -61,6 +61,22 @@ export function pointFromVector

( return v as unknown as P; } +/** + * Convert the coordiante object to a point. + * + * @param coords The coordinate object with x and y properties + * @returns + */ +export function pointFromCoords({ + x, + y, +}: { + x: number; + y: number; +}) { + return [x, y] as Point; +} + /** * Checks if the provided value has the shape of a Point. * @@ -217,7 +233,10 @@ export function pointDistanceSq

( a: P, b: P, ): number { - return Math.hypot(b[0] - a[0], b[1] - a[1]); + const xDiff = b[0] - a[0]; + const yDiff = b[1] - a[1]; + + return xDiff * xDiff + yDiff * yDiff; } /** diff --git a/yarn.lock b/yarn.lock index 7eb706e64..79ad50f23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7263,6 +7263,16 @@ jest-canvas-mock@~2.5.2: cssfontparser "^1.2.1" moo-color "^1.0.2" +jest-diff@29.7.0, jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-diff@^27.0.0: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" @@ -7273,16 +7283,6 @@ jest-diff@^27.0.0: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - jest-get-type@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"