excalidraw/packages/element/src/elbowArrow.ts
Mark Tolmacs 4438137a57
Fixed point binding for simple arrows
Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point at finalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix type errors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

New arrow binding rules

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix cyclical dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix all tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

chore: Trigger build

Remove binding gap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix delayed bind mode for multiElement arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix multi-point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes for arrow dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug
2025-09-01 21:56:20 +02:00

2280 lines
62 KiB
TypeScript

import {
clamp,
pointDistance,
pointFrom,
pointScaleFromOrigin,
pointsEqual,
pointTranslate,
vector,
vectorCross,
vectorFromPoint,
vectorScale,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import {
BinaryHeap,
invariant,
isAnyTrue,
getSizeFromPoints,
isDevEnv,
arrayToMap,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
bindPointToSnapToElementOutline,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
getFixedBindingDistance,
} from "./binding";
import { distanceToElement } from "./distance";
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,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import type {
Arrowhead,
ElementsMap,
ExcalidrawBindableElement,
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
Ordered,
} from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = {
f: number;
g: number;
h: number;
closed: boolean;
visited: boolean;
parent: Node | null;
pos: GlobalPoint;
addr: GridAddress;
};
type Grid = {
row: number;
col: number;
data: (Node | null)[];
};
type ElbowArrowState = {
x: number;
y: number;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
};
type ElbowArrowData = {
dynamicAABBs: Bounds[];
startDonglePosition: GlobalPoint | null;
startGlobalPoint: GlobalPoint;
startHeading: Heading;
endDonglePosition: GlobalPoint | null;
endGlobalPoint: GlobalPoint;
endHeading: Heading;
commonBounds: Bounds;
hoveredStartElement: ExcalidrawBindableElement | null;
hoveredEndElement: ExcalidrawBindableElement | null;
};
const DEDUP_TRESHOLD = 1;
export const BASE_PADDING = 40;
const handleSegmentRenormalization = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap,
) => {
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),
),
),
) ?? [],
),
),
filteredNextFixedSegments,
null,
null,
);
}
isDevEnv() &&
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,
),
],
{ isDragging: false },
);
const { points: restoredPoints } = normalizeArrowElementUpdate(
getElbowArrowCornerPoints(
removeElbowArrowShortSegments(
routeElbowArrow(arrow, {
startHeading,
endHeading,
startGlobalPoint,
endGlobalPoint,
hoveredStartElement,
hoveredEndElement,
...rest,
}) ?? [],
),
),
fixedSegments,
null,
null,
);
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
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,
): ElementUpdate<ExcalidrawElbowArrowElement> => {
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.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
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.at(
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
);
const thirdToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
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?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
options?: {
isDragging?: boolean;
},
): ElementUpdate<ExcalidrawElbowArrowElement> => {
if (arrow.points.length < 2) {
return { points: updates.points ?? arrow.points };
}
if (!import.meta.env.PROD) {
invariant(
!updates.points || updates.points.length >= 2,
"Updated point array length must match the arrow point length, contain " +
"exactly the new start and end points or not be specified at all (i.e. " +
"you can't add new points between start and end manually to elbow arrows)",
);
invariant(
!arrow.fixedSegments ||
arrow.fixedSegments
.map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
.every(Boolean),
"Fixed segments must be either horizontal or vertical",
);
invariant(
!updates.fixedSegments ||
updates.fixedSegments
.map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
.every(Boolean),
"Updates to fixed segments must be either horizontal or vertical",
);
invariant(
arrow.points
.slice(1)
.map(
(p, i) => p[0] === arrow.points[i][0] || p[1] === arrow.points[i][1],
),
"Elbow arrow segments must be either horizontal or vertical",
);
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
}
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
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();
// 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: updatedStartBinding,
endBinding: updatedEndBinding,
...restOfTheUpdates
} = updates;
const startBinding =
typeof updatedStartBinding !== "undefined"
? updatedStartBinding
: arrow.startBinding;
const endBinding =
typeof updatedEndBinding !== "undefined"
? updatedEndBinding
: arrow.endBinding;
const startElement =
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap);
const endElement =
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
if (
(startBinding && !startElement && areUpdatedPointsValid) ||
(endBinding && !endElement && areUpdatedPointsValid) ||
(elementsMap.size === 0 && areUpdatedPointsValid) ||
(Object.keys(restOfTheUpdates).length === 0 &&
(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,
options,
);
// 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
if (elementsMap.size === 0 && areUpdatedPointsValid) {
return normalizeArrowElementUpdate(
updatedPoints.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
////
// 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),
),
) &&
areUpdatedPointsValid
) {
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[],
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 = null;
let hoveredEndElement = null;
if (options?.isDragging) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(origStartGlobalPoint, elementsMap, elements) || null;
hoveredEndElement =
getHoveredElement(origEndGlobalPoint, elementsMap, elements) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
null
: null;
hoveredEndElement = arrow.endBinding
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
: null;
}
const startGlobalPoint = getGlobalPoint(
{
...arrow,
type: "arrow",
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
hoveredStartElement,
elementsMap,
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
{
...arrow,
type: "arrow",
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
hoveredEndElement,
elementsMap,
options?.isDragging,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
hoveredStartElement,
origStartGlobalPoint,
elementsMap,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
hoveredEndElement,
origEndGlobalPoint,
elementsMap,
);
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,
elementsMap,
offsetFromHeading(
startHeading,
arrow.startArrowhead
? getFixedBindingDistance(hoveredStartElement) * 6
: getFixedBindingDistance(hoveredStartElement) * 2,
1,
),
)
: startPointBounds;
const endElementBounds = hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(
endHeading,
arrow.endArrowhead
? getFixedBindingDistance(hoveredEndElement) * 6
: getFixedBindingDistance(hoveredEndElement) * 2,
1,
),
)
: endPointBounds;
const boundsOverlap =
pointInsideBounds(
startGlobalPoint,
hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
)
: endPointBounds,
) ||
pointInsideBounds(
endGlobalPoint,
hoveredStartElement
? aabbForElement(
hoveredStartElement,
elementsMap,
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, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
);
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"],
): ElementUpdate<ExcalidrawElbowArrowElement> => {
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,
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
): GlobalPoint => {
if (isDragging) {
if (element && elementsMap) {
return bindPointToSnapToElementOutline(
arrow,
element,
startOrEnd,
elementsMap,
);
}
return initialPoint;
}
if (element) {
return getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0],
element,
elementsMap ?? arrayToMap([element]),
);
}
return initialPoint;
};
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading =>
getHeadingForElbowArrowSnap(
p,
otherPoint,
hoveredElement,
hoveredElement &&
aabbForElement(
hoveredElement,
elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number,
number,
number,
number,
],
),
origPoint,
elementsMap,
);
const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
) => {
return getHoveredElementForBinding(
origPoint,
elements,
elementsMap,
(element) => getFixedBindingDistance(element) + 1,
);
};
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);