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/elbowArrow.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/elbowArrow.ts')
| -rw-r--r-- | packages/excalidraw/element/elbowArrow.ts | 2280 |
1 files changed, 2280 insertions, 0 deletions
diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts new file mode 100644 index 0000000..ce8c3d5 --- /dev/null +++ b/packages/excalidraw/element/elbowArrow.ts @@ -0,0 +1,2280 @@ +import { + clamp, + pointDistance, + pointFrom, + pointScaleFromOrigin, + pointsEqual, + pointTranslate, + vector, + vectorCross, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "@excalidraw/math"; +import BinaryHeap from "../binaryheap"; +import { getSizeFromPoints } from "../points"; +import { aabbForElement, pointInsideBounds } from "../shapes"; +import { invariant, isAnyTrue, tupleToCoors } from "../utils"; +import type { AppState } from "../types"; +import { + bindPointToSnapToElementOutline, + FIXED_BINDING_DISTANCE, + getHeadingForElbowArrowSnap, + getGlobalFixedPointForBindableElement, + snapToMid, + getHoveredElementForBinding, +} 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 } from "./typeChecks"; +import { + type ExcalidrawElbowArrowElement, + type NonDeletedSceneElementsMap, + type SceneElementsMap, +} from "./types"; +import type { + Arrowhead, + ElementsMap, + ExcalidrawBindableElement, + FixedPointBinding, + FixedSegment, + NonDeletedExcalidrawElement, +} from "./types"; +import { distanceToBindableElement } from "./distance"; + +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, +) => { + const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments + ? arrow.fixedSegments.slice() + : null; + + if (nextFixedSegments) { + const _nextPoints: GlobalPoint[] = []; + + arrow.points + .map((p) => pointFrom<GlobalPoint>(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<LocalPoint>( + 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<GlobalPoint>( + !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<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), + ), + arrow.startBinding && + getBindableElementForId( + arrow.startBinding.elementId, + elementsMap, + ), + arrow.endBinding && + getBindableElementForId( + arrow.endBinding.elementId, + elementsMap, + ), + ), + ) ?? [], + ), + ), + filteredNextFixedSegments, + null, + null, + ); + } + + import.meta.env.DEV && + invariant( + validateElbowPoints(nextPoints), + "Invalid elbow points with fixed segments", + ); + + 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: readonly FixedSegment[], + elementsMap: NonDeletedSceneElementsMap, +) => { + 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 startBinding = prevSegment ? null : arrow.startBinding; + const endBinding = nextSegment ? null : arrow.endBinding; + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData( + { + x, + y, + startBinding, + endBinding, + startArrowhead: null, + endArrowhead: null, + points: arrow.points, + }, + elementsMap, + [ + pointFrom<LocalPoint>(0, 0), + pointFrom<LocalPoint>( + 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, + ), + ], + startBinding && + getBindableElementForId(startBinding.elementId, elementsMap), + endBinding && getBindableElementForId(endBinding.elementId, elementsMap), + { 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<GlobalPoint>( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + } + } + + restoredPoints.forEach((p) => { + nextPoints.push( + pointFrom<GlobalPoint>( + 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<GlobalPoint>( + 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: readonly FixedSegment[], + startHeading: Heading, + endHeading: Heading, + hoveredStartElement: ExcalidrawBindableElement | null, + hoveredEndElement: ExcalidrawBindableElement | null, +): ElementUpdate<ExcalidrawElbowArrowElement> => { + 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<LocalPoint>( + 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<LocalPoint>( + 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<GlobalPoint>( + arrow.x + segment.start[0], + arrow.y + segment.start[1], + ), + end: pointFrom<GlobalPoint>( + 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<GlobalPoint>(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<GlobalPoint>( + startIsHorizontal ? start[0] : arrow.x + arrow.points[0][0], + !startIsHorizontal ? start[1] : arrow.y + arrow.points[0][1], + ), + ); + + if (hoveredStartElement) { + newPoints.unshift( + pointFrom<GlobalPoint>( + 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<GlobalPoint>( + 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<GlobalPoint>( + 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<LocalPoint>( + segment.start[0] - arrow.x, + segment.start[1] - arrow.y, + ), + end: pointFrom<LocalPoint>( + 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: readonly 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<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]) + : i === updatedPoints.length - 1 + ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]) + : pointFrom<GlobalPoint>( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + const nextFixedSegments = fixedSegments.map((segment) => ({ + ...segment, + start: pointFrom<GlobalPoint>( + arrow.x + (segment.start[0] - updatedPoints[0][0]), + arrow.y + (segment.start[1] - updatedPoints[0][1]), + ), + end: pointFrom<GlobalPoint>( + 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<GlobalPoint>( + !secondIsHorizontal + ? thirdPoint[0] + : startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdPoint[1] + : startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.unshift( + pointFrom<GlobalPoint>( + 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<GlobalPoint>( + !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<GlobalPoint>( + !secondIsHorizontal + ? thirdToLastPoint[0] + : endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdToLastPoint[1] + : endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.push( + pointFrom<GlobalPoint>( + 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<GlobalPoint>( + !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<LocalPoint>( + segment.start[0] - startGlobalPoint[0], + segment.start[1] - startGlobalPoint[1], + ), + end: pointFrom<LocalPoint>( + segment.end[0] - startGlobalPoint[0], + segment.end[1] - startGlobalPoint[1], + ), + })), + startIsSpecial, + endIsSpecial, + ); +}; + +const MAX_POS = 1e6; + +/** + * + */ +export const updateElbowArrowPoints = ( + arrow: Readonly<ExcalidrawElbowArrowElement>, + elementsMap: NonDeletedSceneElementsMap, + updates: { + points?: readonly LocalPoint[]; + fixedSegments?: FixedSegment[] | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + }, + options?: { + isDragging?: boolean; + }, +): ElementUpdate<ExcalidrawElbowArrowElement> => { + if (arrow.points.length < 2) { + return { points: updates.points ?? arrow.points }; + } + + // NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow + // arrow size is valid. This check will be removed once the issue is identified + if ( + arrow.x < -MAX_POS || + arrow.x > MAX_POS || + arrow.y < -MAX_POS || + arrow.y > MAX_POS || + arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) < + -MAX_POS || + arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) > + MAX_POS || + arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) < + -MAX_POS || + arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) > + MAX_POS || + arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) < + -MAX_POS || + arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) > + MAX_POS || + arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) < + -MAX_POS || + arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS + ) { + console.error( + "Elbow arrow (or update) is outside reasonable bounds (> 1e6)", + { + arrow, + updates, + }, + ); + } + // @ts-ignore See above note + arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS); + // @ts-ignore See above note + arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS); + if (updates.points) { + updates.points = updates.points.map(([x, y]) => + pointFrom<LocalPoint>( + clamp(x, -MAX_POS, MAX_POS), + clamp(y, -MAX_POS, MAX_POS), + ), + ); + } + + 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, + ) + : updates.points.slice() + : arrow.points.slice(); + + // 0. During all element replacement in the scene, we just need to renormalize + // the arrow + // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed + const startBinding = + typeof updates.startBinding !== "undefined" + ? updates.startBinding + : arrow.startBinding; + const endBinding = + typeof updates.endBinding !== "undefined" + ? updates.endBinding + : arrow.endBinding; + const startElement = + startBinding && + getBindableElementForId(startBinding.elementId, elementsMap); + const endElement = + endBinding && getBindableElementForId(endBinding.elementId, elementsMap); + if ( + (elementsMap.size === 0 && validateElbowPoints(updatedPoints)) || + startElement?.id !== startBinding?.elementId || + endElement?.id !== endBinding?.elementId + ) { + return normalizeArrowElementUpdate( + updatedPoints.map((p) => + pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]), + ), + arrow.fixedSegments, + arrow.startIsSpecial, + arrow.endIsSpecial, + ); + } + + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData( + { + x: arrow.x, + y: arrow.y, + startBinding, + endBinding, + startArrowhead: arrow.startArrowhead, + endArrowhead: arrow.endArrowhead, + points: arrow.points, + }, + elementsMap, + updatedPoints, + startElement, + endElement, + options, + ); + + const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; + + //// + // 1. Renormalize the arrow + //// + if ( + !updates.points && + !updates.fixedSegments && + !updates.startBinding && + !updates.endBinding + ) { + return handleSegmentRenormalization(arrow, elementsMap); + } + + // Short circuit on no-op to avoid huge performance hit + if ( + updates.startBinding === arrow.startBinding && + updates.endBinding === arrow.endBinding && + (updates.points ?? []).every((p, i) => + pointsEqual( + p, + arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), + ), + ) + ) { + return {}; + } + + //// + // 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; + points: readonly LocalPoint[]; + }, + elementsMap: NonDeletedSceneElementsMap, + nextPoints: readonly LocalPoint[], + startElement: ExcalidrawBindableElement | null, + endElement: ExcalidrawBindableElement | null, + 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)); + + let hoveredStartElement = startElement; + let hoveredEndElement = endElement; + if (options?.isDragging) { + const elements = Array.from(elementsMap.values()); + hoveredStartElement = + getHoveredElement( + origStartGlobalPoint, + elementsMap, + elements, + options?.zoom, + ) || startElement; + hoveredEndElement = + getHoveredElement( + origEndGlobalPoint, + elementsMap, + elements, + options?.zoom, + ) || endElement; + } + + const startGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "start", + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + startElement, + hoveredStartElement, + options?.isDragging, + ); + const endGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "end", + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + 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) => 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<number>(); + const vertical = new Set<number>(); + + 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: readonly FixedSegment[] | null, + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], +): { + points: LocalPoint[]; + x: number; + y: number; + width: number; + height: number; + fixedSegments: readonly FixedSegment[] | null; + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; +} => { + const offsetX = global[0][0]; + const offsetY = global[0][1]; + let points = global.map((p) => + pointTranslate<GlobalPoint, LocalPoint>( + p, + vectorScale(vectorFromPoint(global[0]), -1), + ), + ); + + // NOTE (mtolmacs): This is a temporary check to see if the normalization + // creates an overly large arrow. This should be removed once we have an answer. + if ( + offsetX < -MAX_POS || + offsetX > MAX_POS || + offsetY < -MAX_POS || + offsetY > MAX_POS || + offsetX + points[points.length - 1][0] < -MAX_POS || + offsetY + points[points.length - 1][0] > MAX_POS || + offsetX + points[points.length - 1][1] < -MAX_POS || + offsetY + points[points.length - 1][1] > MAX_POS + ) { + console.error( + "Elbow arrow normalization is outside reasonable bounds (> 1e6)", + { + x: offsetX, + y: offsetY, + points, + ...getSizeFromPoints(points), + }, + ); + } + + points = points.map(([x, y]) => + pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)), + ); + + return { + points, + x: clamp(offsetX, -1e6, 1e6), + y: clamp(offsetY, -1e6, 1e6), + 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 = ( + arrow: ExcalidrawElbowArrowElement, + startOrEnd: "start" | "end", + fixedPointRatio: [number, number] | undefined | null, + initialPoint: GlobalPoint, + boundElement?: ExcalidrawBindableElement | null, + hoveredElement?: ExcalidrawBindableElement | null, + isDragging?: boolean, +): GlobalPoint => { + if (isDragging) { + if (hoveredElement) { + const snapPoint = bindPointToSnapToElementOutline( + arrow, + hoveredElement, + startOrEnd, + ); + + 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) - + FIXED_BINDING_DISTANCE, + ) > 0.01 + ? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) + : fixedGlobalPoint; + } + + return initialPoint; +}; + +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)) as [ + number, + number, + number, + number, + ], + ), + elementsMap, + origPoint, + ); + +const getHoveredElement = ( + origPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + zoom?: AppState["zoom"], +) => { + return getHoveredElementForBinding( + tupleToCoors(origPoint), + elements, + elementsMap, + zoom, + true, + true, + ); +}; + +const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => + a[0] === b[0] && a[1] === b[1]; + +export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>( + points: readonly P[], + tolerance: number = DEDUP_TRESHOLD, +) => + points + .slice(1) + .map( + (p, i) => + Math.abs(p[0] - points[i][0]) < tolerance || + Math.abs(p[1] - points[i][1]) < tolerance, + ) + .every(Boolean); |
