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
2280 lines
62 KiB
TypeScript
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);
|