diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/linearElementEditor.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/linearElementEditor.ts')
| -rw-r--r-- | packages/excalidraw/element/linearElementEditor.ts | 1824 |
1 files changed, 1824 insertions, 0 deletions
diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts new file mode 100644 index 0000000..b616268 --- /dev/null +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -0,0 +1,1824 @@ +import type { + NonDeleted, + ExcalidrawLinearElement, + ExcalidrawElement, + PointBinding, + ExcalidrawBindableElement, + ExcalidrawTextElementWithContainer, + ElementsMap, + NonDeletedSceneElementsMap, + FixedPointBinding, + SceneElementsMap, + FixedSegment, + ExcalidrawElbowArrowElement, +} from "./types"; +import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; +import type { Bounds } from "./bounds"; +import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds"; +import type { + AppState, + PointerCoords, + InteractiveCanvasAppState, + AppClassProperties, + NullableGridSize, + Zoom, +} from "../types"; +import { mutateElement } from "./mutateElement"; + +import { + bindOrUnbindLinearElement, + getHoveredElementForBinding, + isBindingEnabled, +} from "./binding"; +import { invariant, tupleToCoors } from "../utils"; +import { + isBindingElement, + isElbowArrow, + isFixedPointBinding, +} from "./typeChecks"; +import { KEYS, shouldRotateWithDiscreteAngle } from "../keys"; +import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { DRAGGING_THRESHOLD } from "../constants"; +import type { Mutable } from "../utility-types"; +import { ShapeCache } from "../scene/ShapeCache"; +import type { Store } from "../store"; +import type Scene from "../scene/Scene"; +import type { Radians } from "@excalidraw/math"; +import { + pointCenter, + pointFrom, + pointRotateRads, + pointsEqual, + type GlobalPoint, + type LocalPoint, + pointDistance, + vectorFromPoint, +} from "@excalidraw/math"; +import { + getBezierCurveLength, + getBezierXY, + getControlPointsForBezierCurve, + isPathALoop, + mapIntervalToBezierT, +} from "../shapes"; +import { getGridPoint } from "../snapping"; +import { headingIsHorizontal, vectorToHeading } from "./heading"; +import { getCurvePathOps } from "@excalidraw/utils/geometry/shape"; + +const editorMidPointsCache: { + version: number | null; + points: (GlobalPoint | null)[]; + zoom: number | null; +} = { version: null, points: [], zoom: null }; +export class LinearElementEditor { + public readonly elementId: ExcalidrawElement["id"] & { + _brand: "excalidrawLinearElementId"; + }; + /** indices */ + public readonly selectedPointsIndices: readonly number[] | null; + + public readonly pointerDownState: Readonly<{ + prevSelectedPointsIndices: readonly number[] | null; + /** index */ + lastClickedPoint: number; + lastClickedIsEndPoint: boolean; + origin: Readonly<{ x: number; y: number }> | null; + segmentMidpoint: { + value: GlobalPoint | null; + index: number | null; + added: boolean; + }; + }>; + + /** whether you're dragging a point */ + public readonly isDragging: boolean; + public readonly lastUncommittedPoint: LocalPoint | null; + public readonly pointerOffset: Readonly<{ x: number; y: number }>; + public readonly startBindingElement: + | ExcalidrawBindableElement + | null + | "keep"; + public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; + public readonly hoverPointIndex: number; + public readonly segmentMidPointHoveredCoords: GlobalPoint | null; + public readonly elbowed: boolean; + + constructor(element: NonDeleted<ExcalidrawLinearElement>) { + this.elementId = element.id as string & { + _brand: "excalidrawLinearElementId"; + }; + if (!pointsEqual(element.points[0], pointFrom(0, 0))) { + console.error("Linear element is not normalized", Error().stack); + } + + this.selectedPointsIndices = null; + this.lastUncommittedPoint = null; + this.isDragging = false; + this.pointerOffset = { x: 0, y: 0 }; + this.startBindingElement = "keep"; + this.endBindingElement = "keep"; + this.pointerDownState = { + prevSelectedPointsIndices: null, + lastClickedPoint: -1, + lastClickedIsEndPoint: false, + origin: null, + + segmentMidpoint: { + value: null, + index: null, + added: false, + }, + }; + this.hoverPointIndex = -1; + this.segmentMidPointHoveredCoords = null; + this.elbowed = isElbowArrow(element) && element.elbowed; + } + + // --------------------------------------------------------------------------- + // static methods + // --------------------------------------------------------------------------- + + static POINT_HANDLE_SIZE = 10; + /** + * @param id the `elementId` from the instance of this class (so that we can + * statically guarantee this method returns an ExcalidrawLinearElement) + */ + static getElement<T extends ExcalidrawLinearElement>( + id: InstanceType<typeof LinearElementEditor>["elementId"], + elementsMap: ElementsMap, + ): T | null { + const element = elementsMap.get(id); + if (element) { + return element as NonDeleted<T>; + } + return null; + } + + static handleBoxSelection( + event: PointerEvent, + appState: AppState, + setState: React.Component<any, AppState>["setState"], + elementsMap: NonDeletedSceneElementsMap, + ) { + if (!appState.editingLinearElement || !appState.selectionElement) { + return false; + } + const { editingLinearElement } = appState; + const { selectedPointsIndices, elementId } = editingLinearElement; + + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return false; + } + + const [selectionX1, selectionY1, selectionX2, selectionY2] = + getElementAbsoluteCoords(appState.selectionElement, elementsMap); + + const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + + const nextSelectedPoints = pointsSceneCoords + .reduce((acc: number[], point, index) => { + if ( + (point[0] >= selectionX1 && + point[0] <= selectionX2 && + point[1] >= selectionY1 && + point[1] <= selectionY2) || + (event.shiftKey && selectedPointsIndices?.includes(index)) + ) { + acc.push(index); + } + + return acc; + }, []) + .filter((index) => { + if ( + isElbowArrow(element) && + index !== 0 && + index !== element.points.length - 1 + ) { + return false; + } + return true; + }); + + setState({ + editingLinearElement: { + ...editingLinearElement, + selectedPointsIndices: nextSelectedPoints.length + ? nextSelectedPoints + : null, + }, + }); + } + + /** + * @returns whether point was dragged + */ + static handlePointDragging( + event: PointerEvent, + app: AppClassProperties, + scenePointerX: number, + scenePointerY: number, + maybeSuggestBinding: ( + element: NonDeleted<ExcalidrawLinearElement>, + pointSceneCoords: { x: number; y: number }[], + ) => void, + linearElementEditor: LinearElementEditor, + scene: Scene, + ): boolean { + if (!linearElementEditor) { + return false; + } + const { elementId } = linearElementEditor; + const elementsMap = scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return false; + } + + if ( + isElbowArrow(element) && + !linearElementEditor.pointerDownState.lastClickedIsEndPoint && + linearElementEditor.pointerDownState.lastClickedPoint !== 0 + ) { + return false; + } + + const selectedPointsIndices = isElbowArrow(element) + ? linearElementEditor.selectedPointsIndices + ?.reduce( + (startEnd, index) => + (index === 0 + ? [0, startEnd[1]] + : [startEnd[0], element.points.length - 1]) as [ + boolean | number, + boolean | number, + ], + [false, false] as [number | boolean, number | boolean], + ) + .filter( + (idx: number | boolean): idx is number => typeof idx === "number", + ) + : linearElementEditor.selectedPointsIndices; + const lastClickedPoint = isElbowArrow(element) + ? linearElementEditor.pointerDownState.lastClickedPoint > 0 + ? element.points.length - 1 + : 0 + : linearElementEditor.pointerDownState.lastClickedPoint; + + // point that's being dragged (out of all selected points) + const draggingPoint = element.points[lastClickedPoint] as + | [number, number] + | undefined; + + if (selectedPointsIndices && draggingPoint) { + if ( + shouldRotateWithDiscreteAngle(event) && + selectedPointsIndices.length === 1 && + element.points.length > 1 + ) { + const selectedIndex = selectedPointsIndices[0]; + const referencePoint = + element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; + + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + referencePoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + LinearElementEditor.movePoints(element, [ + { + index: selectedIndex, + point: pointFrom( + width + referencePoint[0], + height + referencePoint[1], + ), + isDragging: selectedIndex === lastClickedPoint, + }, + ]); + } else { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; + + LinearElementEditor.movePoints( + element, + selectedPointsIndices.map((pointIndex) => { + const newPointPosition: LocalPoint = + pointIndex === lastClickedPoint + ? LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ) + : pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ); + return { + index: pointIndex, + point: newPointPosition, + isDragging: pointIndex === lastClickedPoint, + }; + }), + ); + } + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + handleBindTextResize(element, elementsMap, false); + } + + // suggest bindings for first and last point if selected + if (isBindingElement(element, false)) { + const coords: { x: number; y: number }[] = []; + + const firstSelectedIndex = selectedPointsIndices[0]; + if (firstSelectedIndex === 0) { + coords.push( + tupleToCoors( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[0], + elementsMap, + ), + ), + ); + } + + const lastSelectedIndex = + selectedPointsIndices[selectedPointsIndices.length - 1]; + if (lastSelectedIndex === element.points.length - 1) { + coords.push( + tupleToCoors( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[lastSelectedIndex], + elementsMap, + ), + ), + ); + } + + if (coords.length) { + maybeSuggestBinding(element, coords); + } + } + + return true; + } + + return false; + } + + static handlePointerUp( + event: PointerEvent, + editingLinearElement: LinearElementEditor, + appState: AppState, + scene: Scene, + ): LinearElementEditor { + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + + const { elementId, selectedPointsIndices, isDragging, pointerDownState } = + editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return editingLinearElement; + } + + const bindings: Mutable< + Partial< + Pick< + InstanceType<typeof LinearElementEditor>, + "startBindingElement" | "endBindingElement" + > + > + > = {}; + + if (isDragging && selectedPointsIndices) { + for (const selectedPoint of selectedPointsIndices) { + if ( + selectedPoint === 0 || + 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], + }, + ]); + } + + const bindingElement = isBindingEnabled(appState) + ? getHoveredElementForBinding( + tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + selectedPoint!, + elementsMap, + ), + ), + elements, + elementsMap, + appState.zoom, + isElbowArrow(element), + isElbowArrow(element), + ) + : null; + + bindings[ + selectedPoint === 0 ? "startBindingElement" : "endBindingElement" + ] = bindingElement; + } + } + } + + return { + ...editingLinearElement, + ...bindings, + // if clicking without previously dragging a point(s), and not holding + // shift, deselect all points except the one clicked. If holding shift, + // toggle the point. + selectedPointsIndices: + isDragging || event.shiftKey + ? !isDragging && + event.shiftKey && + pointerDownState.prevSelectedPointsIndices?.includes( + pointerDownState.lastClickedPoint, + ) + ? selectedPointsIndices && + selectedPointsIndices.filter( + (pointIndex) => + pointIndex !== pointerDownState.lastClickedPoint, + ) + : selectedPointsIndices + : selectedPointsIndices?.includes(pointerDownState.lastClickedPoint) + ? [pointerDownState.lastClickedPoint] + : selectedPointsIndices, + isDragging: false, + pointerOffset: { x: 0, y: 0 }, + }; + } + + static getEditorMidPoints = ( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + appState: InteractiveCanvasAppState, + ): typeof editorMidPointsCache["points"] => { + const boundText = getBoundTextElement(element, elementsMap); + + // Since its not needed outside editor unless 2 pointer lines or bound text + if ( + !isElbowArrow(element) && + !appState.editingLinearElement && + element.points.length > 2 && + !boundText + ) { + return []; + } + if ( + editorMidPointsCache.version === element.version && + editorMidPointsCache.zoom === appState.zoom.value + ) { + return editorMidPointsCache.points; + } + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + return editorMidPointsCache.points!; + }; + + static updateEditorMidPointsCache = ( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + appState: InteractiveCanvasAppState, + ) => { + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + + let index = 0; + const midpoints: (GlobalPoint | null)[] = []; + while (index < points.length - 1) { + if ( + LinearElementEditor.isSegmentTooShort( + element, + element.points[index], + element.points[index + 1], + index, + appState.zoom, + ) + ) { + midpoints.push(null); + index++; + continue; + } + const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + midpoints.push(segmentMidPoint); + index++; + } + editorMidPointsCache.points = midpoints; + editorMidPointsCache.version = element.version; + editorMidPointsCache.zoom = appState.zoom.value; + }; + + static getSegmentMidpointHitCoords = ( + linearElementEditor: LinearElementEditor, + scenePointer: { x: number; y: number }, + appState: AppState, + elementsMap: ElementsMap, + ): GlobalPoint | null => { + const { elementId } = linearElementEditor; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return null; + } + const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( + element, + elementsMap, + appState.zoom, + scenePointer.x, + scenePointer.y, + ); + if (!isElbowArrow(element) && clickedPointIndex >= 0) { + return null; + } + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + if ( + points.length >= 3 && + !appState.editingLinearElement && + !isElbowArrow(element) + ) { + return null; + } + + const threshold = + (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value; + + const existingSegmentMidpointHitCoords = + linearElementEditor.segmentMidPointHoveredCoords; + if (existingSegmentMidpointHitCoords) { + const distance = pointDistance( + pointFrom( + existingSegmentMidpointHitCoords[0], + existingSegmentMidpointHitCoords[1], + ), + pointFrom(scenePointer.x, scenePointer.y), + ); + if (distance <= threshold) { + return existingSegmentMidpointHitCoords; + } + } + let index = 0; + const midPoints: typeof editorMidPointsCache["points"] = + LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); + + while (index < midPoints.length) { + if (midPoints[index] !== null) { + const distance = pointDistance( + midPoints[index]!, + pointFrom(scenePointer.x, scenePointer.y), + ); + if (distance <= threshold) { + return midPoints[index]; + } + } + + index++; + } + return null; + }; + + static isSegmentTooShort<P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + startPoint: P, + endPoint: P, + index: number, + zoom: Zoom, + ) { + 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); + } + + return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; + } + + static getSegmentMidPoint( + element: NonDeleted<ExcalidrawLinearElement>, + startPoint: GlobalPoint, + endPoint: GlobalPoint, + endPointIndex: number, + elementsMap: ElementsMap, + ): GlobalPoint { + let segmentMidPoint = pointCenter(startPoint, endPoint); + if (element.points.length > 2 && element.roundness) { + const controlPoints = getControlPointsForBezierCurve( + element, + element.points[endPointIndex], + ); + if (controlPoints) { + const t = mapIntervalToBezierT( + element, + element.points[endPointIndex], + 0.5, + ); + + segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ), + elementsMap, + ); + } + } + + return segmentMidPoint; + } + + static getSegmentMidPointIndex( + linearElementEditor: LinearElementEditor, + appState: AppState, + midPoint: GlobalPoint, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + if (!element) { + return -1; + } + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ); + let index = 0; + while (index < midPoints.length) { + if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { + return index + 1; + } + index++; + } + return -1; + } + + static handlePointerDown( + event: React.PointerEvent<HTMLElement>, + app: AppClassProperties, + store: Store, + scenePointer: { x: number; y: number }, + linearElementEditor: LinearElementEditor, + scene: Scene, + ): { + didAddPoint: boolean; + hitElement: NonDeleted<ExcalidrawElement> | null; + linearElementEditor: LinearElementEditor | null; + } { + const appState = app.state; + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + + const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { + didAddPoint: false, + hitElement: null, + linearElementEditor: null, + }; + + if (!linearElementEditor) { + return ret; + } + + const { elementId } = linearElementEditor; + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return ret; + } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( + linearElementEditor, + scenePointer, + appState, + elementsMap, + ); + let segmentMidpointIndex = null; + if (segmentMidpoint) { + segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( + linearElementEditor, + appState, + segmentMidpoint, + elementsMap, + ); + } else if (event.altKey && appState.editingLinearElement) { + if (linearElementEditor.lastUncommittedPoint == null) { + mutateElement(element, { + points: [ + ...element.points, + LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointer.x, + scenePointer.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ), + ], + }); + ret.didAddPoint = true; + } + store.shouldCaptureIncrement(); + ret.linearElementEditor = { + ...linearElementEditor, + pointerDownState: { + prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, + lastClickedPoint: -1, + lastClickedIsEndPoint: false, + origin: { x: scenePointer.x, y: scenePointer.y }, + segmentMidpoint: { + value: segmentMidpoint, + index: segmentMidpointIndex, + added: false, + }, + }, + selectedPointsIndices: [element.points.length - 1], + lastUncommittedPoint: null, + endBindingElement: getHoveredElementForBinding( + scenePointer, + elements, + elementsMap, + app.state.zoom, + linearElementEditor.elbowed, + ), + }; + + ret.didAddPoint = true; + return ret; + } + + const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( + element, + elementsMap, + appState.zoom, + scenePointer.x, + scenePointer.y, + ); + // if we clicked on a point, set the element as hitElement otherwise + // it would get deselected if the point is outside the hitbox area + if (clickedPointIndex >= 0 || segmentMidpoint) { + ret.hitElement = element; + } else { + // You might be wandering why we are storing the binding elements on + // LinearElementEditor and passing them in, instead of calculating them + // from the end points of the `linearElement` - this is to allow disabling + // binding (which needs to happen at the point the user finishes moving + // the point). + const { startBindingElement, endBindingElement } = linearElementEditor; + if (isBindingEnabled(appState) && isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + elementsMap, + scene, + ); + } + } + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const targetPoint = + clickedPointIndex > -1 && + pointRotateRads( + pointFrom( + element.x + element.points[clickedPointIndex][0], + element.y + element.points[clickedPointIndex][1], + ), + pointFrom(cx, cy), + element.angle, + ); + + const nextSelectedPointsIndices = + clickedPointIndex > -1 || event.shiftKey + ? event.shiftKey || + linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex) + ? normalizeSelectedPoints([ + ...(linearElementEditor.selectedPointsIndices || []), + clickedPointIndex, + ]) + : [clickedPointIndex] + : null; + ret.linearElementEditor = { + ...linearElementEditor, + pointerDownState: { + prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, + lastClickedPoint: clickedPointIndex, + lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, + origin: { x: scenePointer.x, y: scenePointer.y }, + segmentMidpoint: { + value: segmentMidpoint, + index: segmentMidpointIndex, + added: false, + }, + }, + selectedPointsIndices: nextSelectedPointsIndices, + pointerOffset: targetPoint + ? { + x: scenePointer.x - targetPoint[0], + y: scenePointer.y - targetPoint[1], + } + : { x: 0, y: 0 }, + }; + + return ret; + } + + static arePointsEqual<Point extends LocalPoint | GlobalPoint>( + point1: Point | null, + point2: Point | null, + ) { + if (!point1 && !point2) { + return true; + } + if (!point1 || !point2) { + return false; + } + return pointsEqual(point1, point2); + } + + static handlePointerMove( + event: React.PointerEvent<HTMLCanvasElement>, + scenePointerX: number, + scenePointerY: number, + app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + ): LinearElementEditor | null { + const appState = app.state; + if (!appState.editingLinearElement) { + return null; + } + const { elementId, lastUncommittedPoint } = appState.editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return appState.editingLinearElement; + } + + const { points } = element; + const lastPoint = points[points.length - 1]; + + if (!event.altKey) { + if (lastPoint === lastUncommittedPoint) { + LinearElementEditor.deletePoints(element, [points.length - 1]); + } + return { + ...appState.editingLinearElement, + lastUncommittedPoint: null, + }; + } + + let newPoint: LocalPoint; + + if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { + const lastCommittedPoint = points[points.length - 2]; + + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + lastCommittedPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + newPoint = pointFrom( + width + lastCommittedPoint[0], + height + lastCommittedPoint[1], + ); + } else { + newPoint = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - appState.editingLinearElement.pointerOffset.x, + scenePointerY - appState.editingLinearElement.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) + ? null + : app.getEffectiveGridSize(), + ); + } + + if (lastPoint === lastUncommittedPoint) { + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: newPoint, + }, + ]); + } else { + LinearElementEditor.addPoints(element, [{ point: newPoint }]); + } + return { + ...appState.editingLinearElement, + lastUncommittedPoint: element.points[element.points.length - 1], + }; + } + + /** scene coords */ + static getPointGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + p: LocalPoint, + elementsMap: ElementsMap, + ): GlobalPoint { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + + const { x, y } = element; + return pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ); + } + + /** scene coords */ + static getPointsGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + ): GlobalPoint[] { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + return element.points.map((p) => { + const { x, y } = element; + return pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ); + }); + } + + static getPointAtIndexGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + + indexMaybeFromEnd: number, // -1 for last element + elementsMap: ElementsMap, + ): GlobalPoint { + const index = + indexMaybeFromEnd < 0 + ? element.points.length + indexMaybeFromEnd + : indexMaybeFromEnd; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const p = element.points[index]; + const { x, y } = element; + + return p + ? pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ) + : pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle); + } + + static pointFromAbsoluteCoords( + element: NonDeleted<ExcalidrawLinearElement>, + absoluteCoords: GlobalPoint, + elementsMap: ElementsMap, + ): LocalPoint { + if (isElbowArrow(element)) { + // No rotation for elbow arrows + return pointFrom( + absoluteCoords[0] - element.x, + absoluteCoords[1] - element.y, + ); + } + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [x, y] = pointRotateRads( + pointFrom(absoluteCoords[0], absoluteCoords[1]), + pointFrom(cx, cy), + -element.angle as Radians, + ); + return pointFrom(x - element.x, y - element.y); + } + + static getPointIndexUnderCursor( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + zoom: AppState["zoom"], + x: number, + y: number, + ) { + const pointHandles = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + let idx = pointHandles.length; + // loop from right to left because points on the right are rendered over + // points on the left, thus should take precedence when clicking, if they + // overlap + while (--idx > -1) { + const p = pointHandles[idx]; + if ( + pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value < + // +1px to account for outline stroke + LinearElementEditor.POINT_HANDLE_SIZE + 1 + ) { + return idx; + } + } + return -1; + } + + static createPointAt( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + scenePointerX: number, + scenePointerY: number, + gridSize: NullableGridSize, + ): LocalPoint { + const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [rotatedX, rotatedY] = pointRotateRads( + pointFrom(pointerOnGrid[0], pointerOnGrid[1]), + pointFrom(cx, cy), + -element.angle as Radians, + ); + + return pointFrom(rotatedX - element.x, rotatedY - element.y); + } + + /** + * Normalizes line points so that the start point is at [0,0]. This is + * expected in various parts of the codebase. Also returns new x/y to account + * for the potential normalization. + */ + static getNormalizedPoints(element: ExcalidrawLinearElement): { + points: LocalPoint[]; + x: number; + y: number; + } { + const { points } = element; + + const offsetX = points[0][0]; + const offsetY = points[0][1]; + + return { + points: points.map((p) => { + return pointFrom(p[0] - offsetX, p[1] - offsetY); + }), + x: element.x + offsetX, + y: element.y + offsetY, + }; + } + + // element-mutating methods + // --------------------------------------------------------------------------- + + static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) { + mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); + } + + static duplicateSelectedPoints( + appState: AppState, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + ): AppState { + invariant( + appState.editingLinearElement, + "Not currently editing a linear element", + ); + + const { selectedPointsIndices, elementId } = appState.editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + + invariant( + element, + "The linear element does not exist in the provided Scene", + ); + invariant( + selectedPointsIndices != null, + "There are no selected points to duplicate", + ); + + const { points } = element; + + const nextSelectedIndices: number[] = []; + + let pointAddedToEnd = false; + let indexCursor = -1; + const nextPoints = points.reduce((acc: LocalPoint[], p, index) => { + ++indexCursor; + acc.push(p); + + const isSelected = selectedPointsIndices.includes(index); + if (isSelected) { + const nextPoint = points[index + 1]; + + if (!nextPoint) { + pointAddedToEnd = true; + } + acc.push( + nextPoint + ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) + : pointFrom(p[0], p[1]), + ); + + nextSelectedIndices.push(indexCursor + 1); + ++indexCursor; + } + + return acc; + }, []); + + mutateElement(element, { points: nextPoints }); + + // temp hack to ensure the line doesn't move when adding point to the end, + // 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), + }, + ]); + } + + return { + ...appState, + editingLinearElement: { + ...appState.editingLinearElement, + selectedPointsIndices: nextSelectedIndices, + }, + }; + } + + static deletePoints( + element: NonDeleted<ExcalidrawLinearElement>, + pointIndices: readonly number[], + ) { + let offsetX = 0; + let offsetY = 0; + + const isDeletingOriginPoint = pointIndices.includes(0); + + // if deleting first point, make the next to be [0,0] and recalculate + // positions of the rest with respect to it + if (isDeletingOriginPoint) { + const firstNonDeletedPoint = element.points.find((point, idx) => { + return !pointIndices.includes(idx); + }); + if (firstNonDeletedPoint) { + offsetX = firstNonDeletedPoint[0]; + offsetY = firstNonDeletedPoint[1]; + } + } + + const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { + if (!pointIndices.includes(idx)) { + acc.push( + !acc.length + ? pointFrom(0, 0) + : pointFrom(p[0] - offsetX, p[1] - offsetY), + ); + } + return acc; + }, []); + + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + } + + static addPoints( + element: NonDeleted<ExcalidrawLinearElement>, + targetPoints: { point: LocalPoint }[], + ) { + const offsetX = 0; + const offsetY = 0; + + const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + } + + static movePoints( + element: NonDeleted<ExcalidrawLinearElement>, + targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + ) { + const { points } = element; + + // in case we're moving start point, instead of modifying its position + // which would break the invariant of it being at [0,0], we move + // all the other points in the opposite direction by delta to + // offset it. We do the same with actual element.x/y position, so + // this hacks are completely transparent to the user. + const [deltaX, deltaY] = + targetPoints.find(({ index }) => index === 0)?.point ?? + pointFrom<LocalPoint>(0, 0); + const [offsetX, offsetY] = pointFrom<LocalPoint>( + deltaX - points[0][0], + deltaY - points[0][1], + ); + + const nextPoints = isElbowArrow(element) + ? [ + targetPoints.find((t) => t.index === 0)?.point ?? points[0], + targetPoints.find((t) => t.index === points.length - 1)?.point ?? + points[points.length - 1], + ] + : points.map((p, idx) => { + const current = targetPoints.find((t) => t.index === idx)?.point ?? p; + + return pointFrom<LocalPoint>( + current[0] - offsetX, + current[1] - offsetY, + ); + }); + + LinearElementEditor._updatePoints( + element, + nextPoints, + offsetX, + offsetY, + otherUpdates, + { + isDragging: targetPoints.reduce( + (dragging, targetPoint): boolean => + dragging || targetPoint.isDragging === true, + false, + ), + }, + ); + } + + static shouldAddMidpoint( + linearElementEditor: LinearElementEditor, + pointerCoords: PointerCoords, + appState: AppState, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + + // Elbow arrows don't allow midpoints + if (element && isElbowArrow(element)) { + return false; + } + + if (!element) { + return false; + } + + const { segmentMidpoint } = linearElementEditor.pointerDownState; + + if ( + segmentMidpoint.added || + segmentMidpoint.value === null || + segmentMidpoint.index === null || + linearElementEditor.pointerDownState.origin === null + ) { + return false; + } + + const origin = linearElementEditor.pointerDownState.origin!; + const dist = pointDistance( + pointFrom(origin.x, origin.y), + pointFrom(pointerCoords.x, pointerCoords.y), + ); + if ( + !appState.editingLinearElement && + dist < DRAGGING_THRESHOLD / appState.zoom.value + ) { + return false; + } + return true; + } + + static addMidpoint( + linearElementEditor: LinearElementEditor, + pointerCoords: PointerCoords, + app: AppClassProperties, + snapToGrid: boolean, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + if (!element) { + return; + } + const { segmentMidpoint } = linearElementEditor.pointerDownState; + const ret: { + pointerDownState: LinearElementEditor["pointerDownState"]; + selectedPointsIndices: LinearElementEditor["selectedPointsIndices"]; + } = { + pointerDownState: linearElementEditor.pointerDownState, + selectedPointsIndices: linearElementEditor.selectedPointsIndices, + }; + + const midpoint = LinearElementEditor.createPointAt( + element, + elementsMap, + pointerCoords.x, + pointerCoords.y, + snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null, + ); + const points = [ + ...element.points.slice(0, segmentMidpoint.index!), + midpoint, + ...element.points.slice(segmentMidpoint.index!), + ]; + + mutateElement(element, { + points, + }); + + ret.pointerDownState = { + ...linearElementEditor.pointerDownState, + segmentMidpoint: { + ...linearElementEditor.pointerDownState.segmentMidpoint, + added: true, + }, + lastClickedPoint: segmentMidpoint.index!, + }; + ret.selectedPointsIndices = [segmentMidpoint.index!]; + return ret; + } + + private static _updatePoints( + element: NonDeleted<ExcalidrawLinearElement>, + nextPoints: readonly LocalPoint[], + offsetX: number, + offsetY: number, + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + options?: { + isDragging?: boolean; + zoom?: AppState["zoom"]; + }, + ) { + if (isElbowArrow(element)) { + const updates: { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + points?: LocalPoint[]; + } = {}; + if (otherUpdates?.startBinding !== undefined) { + updates.startBinding = + otherUpdates.startBinding !== null && + isFixedPointBinding(otherUpdates.startBinding) + ? otherUpdates.startBinding + : null; + } + if (otherUpdates?.endBinding !== undefined) { + updates.endBinding = + otherUpdates.endBinding !== null && + isFixedPointBinding(otherUpdates.endBinding) + ? otherUpdates.endBinding + : null; + } + + updates.points = Array.from(nextPoints); + + mutateElement(element, updates, true, { + isDragging: options?.isDragging, + }); + } else { + const nextCoords = getElementPointsCoords(element, nextPoints); + const prevCoords = getElementPointsCoords(element, element.points); + const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; + const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; + const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; + const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; + const dX = prevCenterX - nextCenterX; + const dY = prevCenterY - nextCenterY; + const rotated = pointRotateRads( + pointFrom(offsetX, offsetY), + pointFrom(dX, dY), + element.angle, + ); + mutateElement(element, { + ...otherUpdates, + points: nextPoints, + x: element.x + rotated[0], + y: element.y + rotated[1], + }); + } + } + + private static _getShiftLockedDelta( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + referencePoint: LocalPoint, + scenePointer: GlobalPoint, + gridSize: NullableGridSize, + ) { + const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( + element, + referencePoint, + elementsMap, + ); + + if (isElbowArrow(element)) { + return [ + scenePointer[0] - referencePointCoords[0], + scenePointer[1] - referencePointCoords[1], + ]; + } + + const [gridX, gridY] = getGridPoint( + scenePointer[0], + scenePointer[1], + gridSize, + ); + + const { width, height } = getLockedLinearCursorAlignSize( + referencePointCoords[0], + referencePointCoords[1], + gridX, + gridY, + ); + + return pointRotateRads( + pointFrom(width, height), + pointFrom(0, 0), + -element.angle as Radians, + ); + } + + static getBoundTextElementPosition = ( + element: ExcalidrawLinearElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, + ): { x: number; y: number } => { + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + if (points.length < 2) { + mutateElement(boundTextElement, { isDeleted: true }); + } + let x = 0; + let y = 0; + if (element.points.length % 2 === 1) { + const index = Math.floor(element.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[index], + elementsMap, + ); + x = midPoint[0] - boundTextElement.width / 2; + y = midPoint[1] - boundTextElement.height / 2; + } else { + const index = element.points.length / 2 - 1; + + let midSegmentMidpoint = editorMidPointsCache.points[index]; + if (element.points.length === 2) { + midSegmentMidpoint = pointCenter(points[0], points[1]); + } + if ( + !midSegmentMidpoint || + editorMidPointsCache.version !== element.version + ) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + } + x = midSegmentMidpoint[0] - boundTextElement.width / 2; + y = midSegmentMidpoint[1] - boundTextElement.height / 2; + } + return { x, y }; + }; + + static getMinMaxXYWithBoundText = ( + element: ExcalidrawLinearElement, + elementsMap: ElementsMap, + elementBounds: Bounds, + boundTextElement: ExcalidrawTextElementWithContainer, + ): [number, number, number, number, number, number] => { + let [x1, y1, x2, y2] = elementBounds; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const { x: boundTextX1, y: boundTextY1 } = + LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ); + const boundTextX2 = boundTextX1 + boundTextElement.width; + const boundTextY2 = boundTextY1 + boundTextElement.height; + const centerPoint = pointFrom(cx, cy); + + const topLeftRotatedPoint = pointRotateRads( + pointFrom(x1, y1), + centerPoint, + element.angle, + ); + const topRightRotatedPoint = pointRotateRads( + pointFrom(x2, y1), + centerPoint, + element.angle, + ); + + const counterRotateBoundTextTopLeft = pointRotateRads( + pointFrom(boundTextX1, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextTopRight = pointRotateRads( + pointFrom(boundTextX2, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomLeft = pointRotateRads( + pointFrom(boundTextX1, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomRight = pointRotateRads( + pointFrom(boundTextX2, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + + if ( + topLeftRotatedPoint[0] < topRightRotatedPoint[0] && + topLeftRotatedPoint[1] >= topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextBottomRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]); + } else if ( + topLeftRotatedPoint[0] >= topRightRotatedPoint[0] && + topLeftRotatedPoint[1] > topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopLeft[0], + counterRotateBoundTextTopRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopRight[1]); + } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) { + x1 = Math.min(x1, counterRotateBoundTextTopRight[0]); + x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]); + y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]); + } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) { + x1 = Math.min( + x1, + Math.min( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextTopLeft[0], + ), + ); + + x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]); + y1 = Math.min(y1, counterRotateBoundTextTopRight[1]); + y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]); + } + + return [x1, y1, x2, y2, cx, cy]; + }; + + static getElementAbsoluteCoords = ( + element: ExcalidrawLinearElement, + elementsMap: ElementsMap, + includeBoundText: boolean = false, + ): [number, number, number, number, number, number] => { + let coords: [number, number, number, number, number, number]; + let x1; + let y1; + let x2; + let y2; + if (element.points.length < 2 || !ShapeCache.get(element)) { + // XXX this is just a poor estimate and not very useful + const { minX, minY, maxX, maxY } = element.points.reduce( + (limits, [x, y]) => { + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } else { + const shape = ShapeCache.generateElementShape(element, null); + + // first element is always the curve + const ops = getCurvePathOps(shape[0]); + + const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + coords = [x1, y1, x2, y2, cx, cy]; + + if (!includeBoundText) { + return coords; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + coords = LinearElementEditor.getMinMaxXYWithBoundText( + element, + elementsMap, + [x1, y1, x2, y2], + boundTextElement, + ); + } + + 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<number, FixedSegment>, + ); + fixedSegments[index] = { + index, + start: pointFrom<LocalPoint>( + !isHorizontal ? x - element.x : element.points[index - 1][0], + isHorizontal ? y - element.y : element.points[index - 1][1], + ), + end: pointFrom<LocalPoint>( + !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<GlobalPoint>( + 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 = ( + points: (number | null)[], +): number[] | null => { + let nextPoints = [ + ...new Set(points.filter((p) => p !== null && p !== -1)), + ] as number[]; + nextPoints = nextPoints.sort((a, b) => a - b); + return nextPoints.length ? nextPoints : null; +}; |
