Compare commits

..

12 Commits

Author SHA1 Message Date
Mark Tolmacs 9dceb40a4f chore: Fix lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 16:22:54 +00:00
Mark Tolmacs 13cbaebac9 Merge branch 'master' into mtolmacs/feat/prettier-arrow-curves
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 16:01:28 +00:00
Mark Tolmacs 964b7b7b74 fix: Tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 20:02:43 +00:00
Mark Tolmacs 3e69b33a28 fix: Hit tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:59 +00:00
Mark Tolmacs cc1f502a0f fix: Balancing curve angles
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:59 +00:00
Mark Tolmacs eb6ab3f5b0 fix: Reimplement with C2/G2 continuity
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:58 +00:00
Mark Tolmacs 03d46aa62f fix: Midpoint controls
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:58 +00:00
Mark Tolmacs 8059518d85 fix: Diamond arrowheads
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:58 +00:00
Mark Tolmacs d04eef5a37 fix: Bisect tangent-based control point positions
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:57 +00:00
Mark Tolmacs 62aa998f9a fix: Dynamically calculated control point offsets per segment
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:57 +00:00
Mark Tolmacs 53a49e71a8 fix: Arrowheads
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:56 +00:00
Mark Tolmacs c94e05970d feat: Ghost points at the end of the splines
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:26:56 +00:00
25 changed files with 986 additions and 657 deletions
@@ -1822,7 +1822,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 187.75450000000004,
"x": 187.7545,
"y": 44.5,
}
`;
+6 -6
View File
@@ -10,6 +10,7 @@ import {
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
@@ -19,7 +20,7 @@ import {
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForBinding, hitElementItself } from "../collision";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
@@ -91,7 +92,7 @@ export const isFocusPointVisible = (
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement),
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
@@ -233,12 +234,12 @@ export const handleFocusPointDrag = (
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForBinding(
arrow,
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
appState.zoom,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
@@ -269,7 +270,6 @@ export const handleFocusPointDrag = (
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
appState.zoom,
point,
);
}
+134 -38
View File
@@ -60,7 +60,6 @@ import { updateElbowArrowPoints } from "./elbowArrow";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
getSnapOutlineMidPoint,
projectFixedPointOntoDiagonal,
} from "./utils";
@@ -111,13 +110,18 @@ export type BindingStrategy =
* IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/
export const BASE_BINDING_GAP = 5;
export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = (
bindTarget: ExcalidrawBindableElement,
opts: Pick<ExcalidrawArrowElement, "elbowed">,
): number => {
return BASE_BINDING_GAP + bindTarget.strokeWidth / 2;
return (
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
bindTarget.strokeWidth / 2
);
};
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
@@ -171,7 +175,6 @@ export const bindOrUnbindBindingElement = (
start,
"start",
scene,
appState.zoom,
appState.isBindingEnabled,
);
bindOrUnbindBindingElementEdge(
@@ -179,7 +182,6 @@ export const bindOrUnbindBindingElement = (
end,
"end",
scene,
appState.zoom,
appState.isBindingEnabled,
);
if (start.focusPoint || end.focusPoint) {
@@ -224,7 +226,6 @@ const bindOrUnbindBindingElementEdge = (
{ mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end",
scene: Scene,
zoom: AppState["zoom"],
shouldSnapToOutline = true,
): void => {
if (mode === null) {
@@ -237,7 +238,6 @@ const bindOrUnbindBindingElementEdge = (
mode,
startOrEnd,
scene,
zoom,
focusPoint,
shouldSnapToOutline,
);
@@ -270,11 +270,10 @@ const bindingStrategyForElbowArrowEndpointDragging = (
elementsMap,
);
const hit = getHoveredElementForBinding(
arrow,
globalPoint,
elements,
elementsMap,
zoom,
maxBindingDistance_simple(zoom),
);
const current = hit
@@ -322,7 +321,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
elementsMap,
);
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
const hit = getHoveredElementForBinding(point, elements, elementsMap);
// With new arrows this handles the binding at arrow creation
if (startDragged) {
@@ -367,12 +366,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
// Check and handle nested shapes
if (hit && arrow.startBinding) {
const startBinding = arrow.startBinding;
const allHits = getAllHoveredElementAtPoint(
arrow,
point,
elements,
elementsMap,
);
const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap);
if (allHits.find((el) => el.id === startBinding.elementId)) {
const otherElement = elementsMap.get(
@@ -474,9 +468,9 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
let other: BindingStrategy = { mode: undefined };
const isMultiPoint = arrow.points.length > 2;
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
const hit = getHoveredElementForBinding(point, elements, elementsMap);
const isOverlapping = oppositeBinding
? getAllHoveredElementAtPoint(arrow, point, elements, elementsMap).some(
? getAllHoveredElementAtPoint(point, elements, elementsMap).some(
(el) => el.id === oppositeBinding.elementId,
)
: false;
@@ -701,11 +695,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap,
);
const hit = getHoveredElementForBinding(
arrow,
globalPoint,
elements,
elementsMap,
appState.zoom,
maxBindingDistance_simple(appState.zoom),
);
const pointInElement =
hit &&
@@ -1026,7 +1019,6 @@ export const bindBindingElement = (
mode: BindMode,
startOrEnd: "start" | "end",
scene: Scene,
zoom: AppState["zoom"],
focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
): void => {
@@ -1043,7 +1035,6 @@ export const bindBindingElement = (
hoveredElement,
startOrEnd,
elementsMap,
zoom,
shouldSnapToOutline,
),
};
@@ -1268,7 +1259,6 @@ const updateArrowBindings = (
strategy[strategyName].mode,
strategyName,
scene,
appState.zoom,
strategy[strategyName].focusPoint,
);
}
@@ -1378,7 +1368,6 @@ export const bindPointToSnapToElementOutline = (
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true,
): GlobalPoint => {
@@ -1411,7 +1400,7 @@ export const bindPointToSnapToElementOutline = (
startOrEnd === "start" ? 1 : -2,
elementsMap,
);
const bindingGap = getBindingGap(bindableElement);
const bindingGap = getBindingGap(bindableElement, arrowElement);
const aabb = aabbForElement(bindableElement, elementsMap);
const bindableCenter = getCenterForBounds(aabb);
@@ -1421,13 +1410,7 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point),
);
const snapPoint = isMidpointSnappingEnabled
? getSnapOutlineMidPoint(
edgePoint,
bindableElement,
elementsMap,
zoom,
arrowElement,
)
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
: undefined;
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>(
@@ -1472,7 +1455,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement,
elementsMap,
anotherIntersector,
BASE_BINDING_GAP,
BASE_BINDING_GAP_ELBOW,
).sort(pointDistanceSq)[0];
}
} else {
@@ -1531,7 +1514,7 @@ export const avoidRectangularCorner = (
-bindTarget.angle as Radians,
);
const bindingGap = getBindingGap(bindTarget);
const bindingGap = getBindingGap(bindTarget, arrowElement);
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
// Top left
@@ -1609,6 +1592,121 @@ export const avoidRectangularCorner = (
return p;
};
export const snapToMid = (
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
arrowElement?: ExcalidrawArrowElement,
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) {
return undefined;
}
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
} else if (bindTarget.type === "diamond") {
const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return undefined;
};
const extractBinding = (
arrow: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding",
@@ -1709,7 +1807,7 @@ export const updateBoundPoint = (
otherBindable,
elementsMap,
intersector,
getBindingGap(otherBindable),
getBindingGap(otherBindable, arrow),
).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0];
@@ -1719,7 +1817,7 @@ export const updateBoundPoint = (
bindableElement,
elementsMap,
intersector,
getBindingGap(bindableElement),
getBindingGap(bindableElement, arrow),
).sort(
(a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
@@ -1747,7 +1845,7 @@ export const updateBoundPoint = (
element: otherBindable,
point: outlinePoint,
elementsMap,
threshold: getBindingGap(otherBindable),
threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true,
})
) {
@@ -1808,7 +1906,6 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => {
@@ -1824,7 +1921,6 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
startOrEnd,
elementsMap,
zoom,
undefined,
isMidpointSnappingEnabled,
)
+37 -43
View File
@@ -790,27 +790,41 @@ export const getArrowheadPoints = (
p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
}
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
// Ee know the last point of the arrow (or the first, if start arrowhead).
// We know the last point of the arrow (or the first, if start arrowhead).
const [x2, y2] = position === "start" ? p0 : p3;
// By using cubic bezier equation (B(t)) and the given parameters,
// we calculate a point that is closer to the last point.
// The value 0.3 is chosen arbitrarily and it works best for all
// the tested cases.
const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
// Find the normalized direction vector based on the
// previously calculated points.
const distance = Math.hypot(x2 - x1, y2 - y1);
const nx = (x2 - x1) / distance;
const ny = (y2 - y1) / distance;
// Use the analytic tangent at the Bézier endpoint for a precise arrowhead
// direction. For a cubic Bézier B(t) with control points p0p3:
// B'(1): (p3 p2) tangent at the end
// B'(0): (p1 p0) for start arrowhead, arrow points away: (p0 p1)
let dx: number;
let dy: number;
if (position === "end") {
dx = p3[0] - p2[0];
dy = p3[1] - p2[1];
if (Math.hypot(dx, dy) < 1e-6) {
dx = p3[0] - p1[0];
dy = p3[1] - p1[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p3[0] - p0[0];
dy = p3[1] - p0[1];
}
} else {
dx = p0[0] - p1[0];
dy = p0[1] - p1[1];
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p2[0];
dy = p0[1] - p2[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p3[0];
dy = p0[1] - p3[1];
}
}
const distance = Math.hypot(dx, dy);
const nx = dx / distance;
const ny = dy / distance;
const size = getArrowheadSize(arrowhead);
@@ -880,30 +894,10 @@ export const getArrowheadPoints = (
);
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point
let ox;
let oy;
if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(tx + minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(py - ty, px - tx) as Radians,
);
} else {
const [px, py] =
element.points.length > 1
? element.points[element.points.length - 2]
: [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(tx - minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(ty - py, tx - px) as Radians,
);
}
// point opposite to the arrowhead point, just mirrored across the (tx, ty)
// point
const ox = tx - nx * minSize * 2;
const oy = ty - ny * minSize * 2;
return [tx, ty, x3, y3, ox, oy, x4, y4];
}
+90 -96
View File
@@ -25,7 +25,7 @@ import type {
Radians,
} from "@excalidraw/math";
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
@@ -59,12 +59,13 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { getBindingGap, maxBindingDistance_simple } from "./binding";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
@@ -253,20 +254,25 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap);
};
const bindableElementBorderDistanceIfClose = (
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
point: GlobalPoint,
elementsMap: ElementsMap,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
) => {
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const [x, y] = point;
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return -Infinity;
return false;
}
// If the element is inside a frame, we should clip the element
@@ -277,29 +283,33 @@ const bindableElementBorderDistanceIfClose = (
enclosingFrame,
elementsMap,
);
if (!pointInsideBounds(point, enclosingFrameBounds)) {
return -Infinity;
if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false;
}
}
}
const distance = distanceToElement(element, elementsMap, point);
if (isPointInElement(point, element, elementsMap)) {
return distance;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return distance > tolerance ? -Infinity : -distance;
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
};
export const getAllHoveredElementAtPoint = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to do hit testing from front (end of the array) to back (beginning of the array)
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
@@ -312,13 +322,7 @@ export const getAllHoveredElementAtPoint = (
if (
isBindableElement(element, false) &&
hitElementItself({
element,
point,
elementsMap,
threshold: tolerance ?? getBindingGap(element),
overrideShouldTestInside: true,
})
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
@@ -335,92 +339,82 @@ export const getAllHoveredElementAtPoint = (
};
export const getHoveredElementForBinding = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => {
type Candidate = {
element: NonDeleted<ExcalidrawBindableElement>;
distance: number;
overlapPercent?: number;
relativeArea?: number;
};
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
tolerance,
);
const candidates: Candidate[] = [];
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (!isBindableElement(element, false)) {
continue;
}
const maxDistance = maxBindingDistance_simple(zoom);
const distance = bindableElementBorderDistanceIfClose(
element,
point,
elementsMap,
maxDistance,
);
if (distance > -maxDistance) {
candidates.push({ element, distance });
if (!isTransparent(element.backgroundColor) && distance >= 0) {
break;
}
}
}
if (candidates.length === 0) {
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidates.length === 1) {
return candidates[0].element;
if (candidateElements.length === 1) {
return candidateElements[0];
}
const closestElements = candidates.sort(
(a, b) => Math.abs(a.distance) - Math.abs(b.distance),
);
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
const candidate = closestElements[0];
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
const candidateArea = Math.max(
0.00001,
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
);
const overlaps = closestElements
.map((c) => {
if (c.element === candidate.element) {
return { ...c, overlapPercent: 0, relativeArea: 1 };
}
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
const overlapX1 = x1 > cx1 && x1 < cx2 ? x1 : cx1;
const overlapY1 = y1 > cy1 && y1 < cy2 ? y1 : cy1;
const overlapX2 = x2 < cx2 && x2 > cx1 ? x2 : cx2;
const overlapY2 = y2 < cy2 && y2 > cy1 ? y2 : cy2;
const overlapWdith =
overlapX1 !== cx1 || overlapX2 !== cx2 ? overlapX2 - overlapX1 : 0;
const overlapHeight =
overlapY1 !== cy1 || overlapY2 !== cy2 ? overlapY2 - overlapY1 : 0;
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
const overlapPercent = Math.abs(overlapHeight * overlapWdith) / area;
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
return {
...c,
overlapPercent,
relativeArea:
overlapPercent === 0 ? 1 : Math.min(area / candidateArea, 1),
};
})
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
return candidate.distance >= 0 && overlaps.length > 0
? overlaps[0].element
: candidate.element;
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
};
/**
+18 -8
View File
@@ -21,7 +21,7 @@ import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements";
import {
isArrowElement as isBindingElement,
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isImageElement,
@@ -108,7 +108,19 @@ export const dragSelectedElements = (
);
elementsToUpdate.forEach((element) => {
if (!isBindingElement(element)) {
const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render
@@ -131,6 +143,7 @@ export const dragSelectedElements = (
// NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it.
elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD ||
@@ -138,12 +151,9 @@ export const dragSelectedElements = (
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart = element.startBinding
? !elementsToUpdateIds.has(element.startBinding.elementId)
: true;
const shouldUnbindEnd = element.endBinding
? !elementsToUpdateIds.has(element.endBinding.elementId)
: true;
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
+20 -21
View File
@@ -30,7 +30,8 @@ import {
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
getBindingGap,
BASE_BINDING_GAP,
maxBindingDistance_simple,
BASE_BINDING_GAP_ELBOW,
} from "./binding";
import { distanceToElement } from "./distance";
import {
@@ -317,7 +318,6 @@ const handleSegmentRelease = (
...rest
} = getElbowArrowData(
{
...arrow,
x,
y,
startBinding,
@@ -1041,7 +1041,6 @@ export const updateElbowArrowPoints = (
...rest
} = getElbowArrowData(
{
...arrow,
x: arrow.x,
y: arrow.y,
startBinding,
@@ -1191,7 +1190,15 @@ export const updateElbowArrowPoints = (
* - hoveredEndElement: The element being hovered over at the end point.
*/
const getElbowArrowData = (
arrow: ExcalidrawElbowArrowElement,
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?: {
@@ -1216,7 +1223,6 @@ const getElbowArrowData = (
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
arrow,
origStartGlobalPoint,
elementsMap,
elements,
@@ -1224,7 +1230,6 @@ const getElbowArrowData = (
) || null;
hoveredEndElement =
getHoveredElement(
arrow,
origEndGlobalPoint,
elementsMap,
elements,
@@ -1251,7 +1256,6 @@ const getElbowArrowData = (
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredStartElement,
elementsMap,
options?.isDragging,
@@ -1269,7 +1273,6 @@ const getElbowArrowData = (
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredEndElement,
elementsMap,
options?.isDragging,
@@ -1311,8 +1314,8 @@ const getElbowArrowData = (
offsetFromHeading(
startHeading,
arrow.startArrowhead
? getBindingGap(hoveredStartElement) * 6
: getBindingGap(hoveredStartElement) * 2,
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
1,
),
)
@@ -1324,8 +1327,8 @@ const getElbowArrowData = (
offsetFromHeading(
endHeading,
arrow.endArrowhead
? getBindingGap(hoveredEndElement) * 6
: getBindingGap(hoveredEndElement) * 2,
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
1,
),
)
@@ -1372,8 +1375,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.startArrowhead
? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP * 2),
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING,
),
boundsOverlap
@@ -1388,8 +1391,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.endArrowhead
? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP * 2),
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING,
),
boundsOverlap,
@@ -2215,7 +2218,6 @@ const getGlobalPoint = (
startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
zoom: AppState["zoom"],
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
@@ -2229,7 +2231,6 @@ const getGlobalPoint = (
element,
startOrEnd,
elementsMap,
zoom,
undefined,
isMidpointSnappingEnabled,
);
@@ -2278,18 +2279,16 @@ const getBindPointHeading = (
);
const getHoveredElement = (
arrow: ExcalidrawElbowArrowElement,
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"],
) => {
return getHoveredElementForBinding(
arrow,
origPoint,
elements,
elementsMap,
zoom,
maxBindingDistance_simple(zoom),
);
};
+1 -9
View File
@@ -452,16 +452,8 @@ const createBindingArrow = (
"orbit",
"start",
scene,
appState.zoom,
);
bindBindingElement(
bindingArrow,
endBindingElement,
"orbit",
"end",
scene,
appState.zoom,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
+37 -37
View File
@@ -48,6 +48,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
snapToMid,
updateBoundPoint,
} from "./binding";
import {
@@ -343,7 +344,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates, hit } = pointDraggingUpdates(
const { positions, updates } = pointDraggingUpdates(
[idx],
deltaX,
deltaY,
@@ -381,20 +382,17 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element)) {
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[element.points.length - 1],
elementsMap,
),
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
elements,
element.points[element.points.length - 1],
elementsMap,
app.scene,
hit,
);
}
),
element,
elements,
elementsMap,
app.scene,
);
}
// PERF: Avoid state updates if not absolutely necessary
@@ -541,7 +539,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates, hit } = pointDraggingUpdates(
const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
@@ -580,22 +578,19 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
),
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
elements,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
app.scene,
hit,
);
}
),
element,
elements,
elementsMap,
app.scene,
);
}
// Attached text might need to update if arrow dimensions change
@@ -795,9 +790,20 @@ export class LinearElementEditor {
elementsMap,
);
const [lines, segCurves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const segmentCount = lines.length + segCurves.length;
let index = 0;
const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
if (segmentCount > 0 && index >= segmentCount) {
midpoints.push(null);
index++;
continue;
}
if (
LinearElementEditor.isSegmentTooShort(
element,
@@ -2141,7 +2147,6 @@ const pointDraggingUpdates = (
): {
positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates;
hit?: ExcalidrawBindableElement | null;
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
@@ -2199,15 +2204,13 @@ const pointDraggingUpdates = (
? {
element: suggestedBindingElement,
midPoint: app.state.isMidpointSnappingEnabled
? getSnapOutlineMidPoint(
? snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
suggestedBindingElement,
elementsMap,
app.state.zoom,
element,
)
: undefined,
}
@@ -2314,7 +2317,6 @@ const pointDraggingUpdates = (
start.element,
elementsMap,
app.state.zoom,
element,
),
}
: null;
@@ -2354,7 +2356,6 @@ const pointDraggingUpdates = (
end.element,
elementsMap,
app.state.zoom,
element,
),
}
: null;
@@ -2507,7 +2508,6 @@ const pointDraggingUpdates = (
];
}),
),
hit: startIsDragged ? start.element : end.element,
};
};
+306 -53
View File
@@ -78,6 +78,18 @@ import type {
import type { Drawable, Options } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
// Controls how handle distance scales with chord length.
// At 1.0 handles are exactly h/3 (standard Hermite). Values below 1 make
// short segments curvier and long segments more taut (sub-linear scaling).
const CP_CHORD_POWER = 1;
// At curved knots the C2 spline tangent can be tilted away from the
// bisector direction, making one side of the knot tight and the other taut.
// This factor [0, 1] controls how far the tangent direction is pulled toward
// the bisector (the chord-bisector normal) linearly with turn sharpness.
// 0 = pure C2 spline; 1 = tangent fully aligned with the bisector.
const CP_ANGLE_CORRECTION = 1;
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<
@@ -625,60 +637,144 @@ export const generateLinearCollisionShape = (
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
// Generate collision ops using the same bisector-based cubic Bézier
// algorithm as generateRoundedSimpleArrowShape so hit-testing matches rendering.
const rotateLocal = (lx: number, ly: number): LocalPoint => {
const g = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(element.x + lx, element.y + ly),
center,
element.angle,
);
return pointFrom<LocalPoint>(g[0] - element.x, g[1] - element.y);
};
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
const collisionOps: Array<{
op: string;
data: number[] | LocalPoint;
}> = [];
collisionOps.push({
op: "move",
data: rotateLocal(points[0][0], points[0][1]),
});
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
if (points.length === 2) {
collisionOps.push({
op: "lineTo",
data: rotateLocal(points[1][0], points[1][1]),
});
} else {
// Chord-length C2 spline. Mirrors generateRoundedSimpleArrowShape
// exactly so hit-testing matches rendering.
const n = points.length - 1;
const h = new Float64Array(n);
for (let i = 0; i < n; i++) {
h[i] = Math.max(
1e-10,
Math.hypot(
points[i + 1][0] - points[i][0],
points[i + 1][1] - points[i][1],
),
);
}
const mx = new Float64Array(n + 1);
const my = new Float64Array(n + 1);
const diag = new Float64Array(n + 1);
const rhsX = new Float64Array(n + 1);
const rhsY = new Float64Array(n + 1);
diag[0] = 2;
rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0];
rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0];
for (let i = 1; i < n; i++) {
diag[i] = 2 * (h[i - 1] + h[i]);
rhsX[i] =
3 *
((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] +
(h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]);
rhsY[i] =
3 *
((h[i] * (points[i][1] - points[i - 1][1])) / h[i - 1] +
(h[i - 1] * (points[i + 1][1] - points[i][1])) / h[i]);
}
diag[n] = 2;
rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1];
rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1];
for (let i = 1; i <= n; i++) {
const sub = i < n ? h[i] : 1;
const supPrev = i === 1 ? 1 : h[i - 2];
const w = sub / diag[i - 1];
diag[i] -= w * supPrev;
rhsX[i] -= w * rhsX[i - 1];
rhsY[i] -= w * rhsY[i - 1];
}
mx[n] = rhsX[n] / diag[n];
my[n] = rhsY[n] / diag[n];
for (let i = n - 1; i >= 0; i--) {
const sup = i === 0 ? 1 : h[i - 1];
mx[i] = (rhsX[i] - sup * mx[i + 1]) / diag[i];
my[i] = (rhsY[i] - sup * my[i + 1]) / diag[i];
}
// Normalised tangent directions; handle length scales sub-linearly with chord.
const mlen = new Float64Array(n + 1);
for (let i = 0; i <= n; i++) {
mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i]));
}
// At interior knots, blend the C2 tangent direction toward the
// bisector direction by a factor proportional to turn sharpness *
// CP_ANGLE_CORRECTION
for (let k = 1; k < n; k++) {
const d1x = (points[k][0] - points[k - 1][0]) / h[k - 1];
const d1y = (points[k][1] - points[k - 1][1]) / h[k - 1];
const d2x = (points[k + 1][0] - points[k][0]) / h[k];
const d2y = (points[k + 1][1] - points[k][1]) / h[k];
const dot = d1x * d2x + d1y * d2y;
const t = ((1 - dot) / 2) * CP_ANGLE_CORRECTION;
if (t < 1e-6) {
continue;
}
const bx = d1x + d2x;
const by = d1y + d2y;
const blen = Math.hypot(bx, by);
if (blen < 1e-10) {
continue;
}
let px = bx / blen;
let py = by / blen;
const tx = mx[k] / mlen[k];
const ty = my[k] / mlen[k];
if (tx * px + ty * py < 0) {
px = -px;
py = -py;
}
const blendX = tx + t * (px - tx);
const blendY = ty + t * (py - ty);
const blendLen = Math.max(1e-10, Math.hypot(blendX, blendY));
mx[k] = (blendX / blendLen) * mlen[k];
my[k] = (blendY / blendLen) * mlen[k];
}
for (let i = 0; i < n; i++) {
const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3;
const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist;
const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist;
const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist;
const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist;
const rcp1 = rotateLocal(cp1x, cp1y);
const rcp2 = rotateLocal(cp2x, cp2y);
const rend = rotateLocal(points[i + 1][0], points[i + 1][1]);
collisionOps.push({
op: "bcurveTo",
data: [rcp1[0], rcp1[1], rcp2[0], rcp2[1], rend[0], rend[1]],
});
}
}
return collisionOps;
}
case "freedraw": {
if (element.points.length < 2) {
@@ -920,7 +1016,12 @@ const _generateElementShape = (
];
}
} else {
shape = [generator.curve(points as unknown as RoughPoint[], options)];
shape = [
generator.path(
generateRoundedSimpleArrowShape(points),
generateRoughOptions(element, true, isDarkMode),
),
];
}
// add lines only in arrow
@@ -1004,10 +1105,162 @@ const _generateElementShape = (
}
};
const generateRoundedSimpleArrowShape = (
points: readonly LocalPoint[],
): string => {
if (points.length < 2) {
return "";
}
if (points.length === 2) {
return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`;
}
// Chord-length parameterised C2 natural cubic spline (Thomas's algorithm).
//
// Unknowns: tangent vectors m[0..n] at each knot (n = number of segments).
// Chord lengths h[i] = |K[i+1] K[i]| act as the parameter intervals so
// that tightly-spaced knots don't over-influence distant ones.
//
// Row 0: 2·m₀ + m₁ = 3·(K₁−K₀)/h₀
// Row i: h[i]·mᵢ₋₁ + 2·(h[i1]+h[i])·mᵢ + h[i1]·mᵢ₊₁
// = 3·(h[i]·(Kᵢ−Kᵢ₋₁)/h[i1]
// + h[i1]·(Kᵢ₊₁−Kᵢ)/h[i]) 1≤i≤n1
// Row n: mₙ₋₁ + 2·mₙ = 3·(Kₙ−Kₙ₋₁)/h[n1]
//
// Bézier control points from Hermite→Bézier identity:
// cp1ᵢ = Kᵢ + mᵢ · h[i] / 3
// cp2ᵢ = Kᵢ₊₁ mᵢ₊₁ · h[i] / 3
const n = points.length - 1; // number of segments
const h = new Float64Array(n);
for (let i = 0; i < n; i++) {
h[i] = Math.max(
1e-10,
Math.hypot(
points[i + 1][0] - points[i][0],
points[i + 1][1] - points[i][1],
),
);
}
const mx = new Float64Array(n + 1);
const my = new Float64Array(n + 1);
const diag = new Float64Array(n + 1);
const rhsX = new Float64Array(n + 1);
const rhsY = new Float64Array(n + 1);
// Row 0 natural BC (zero second derivative at start)
diag[0] = 2;
rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0];
rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0];
// Interior rows
for (let i = 1; i < n; i++) {
diag[i] = 2 * (h[i - 1] + h[i]);
rhsX[i] =
3 *
((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] +
(h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]);
rhsY[i] =
3 *
((h[i] * (points[i][1] - points[i - 1][1])) / h[i - 1] +
(h[i - 1] * (points[i + 1][1] - points[i][1])) / h[i]);
}
// Row n natural BC (zero second derivative at end)
diag[n] = 2;
rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1];
rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1];
// Forward sweep
// sub[i] = h[i] for i=1..n1, sub[n] = 1
// sup[i] = 1 for i=0, h[i1] for i=1..n1 (never modified)
for (let i = 1; i <= n; i++) {
const sub = i < n ? h[i] : 1;
const supPrev = i === 1 ? 1 : h[i - 2];
const w = sub / diag[i - 1];
diag[i] -= w * supPrev;
rhsX[i] -= w * rhsX[i - 1];
rhsY[i] -= w * rhsY[i - 1];
}
// Back substitution
mx[n] = rhsX[n] / diag[n];
my[n] = rhsY[n] / diag[n];
for (let i = n - 1; i >= 0; i--) {
const sup = i === 0 ? 1 : h[i - 1];
mx[i] = (rhsX[i] - sup * mx[i + 1]) / diag[i];
my[i] = (rhsY[i] - sup * my[i + 1]) / diag[i];
}
// Normalised tangent directions; handle length scales sub-linearly with chord.
const mlen = new Float64Array(n + 1);
for (let i = 0; i <= n; i++) {
mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i]));
}
// At interior knots, blend the C2 tangent direction toward the
// perpendicular-to-bisector (the perfectly symmetric tangent) by a factor
// proportional to turn sharpness × CP_ANGLE_CORRECTION.
// Both cp2 (incoming) and cp1 (outgoing) at the knot share the same adjusted
// direction, so collinear (aligned) handles are preserved.
for (let k = 1; k < n; k++) {
const d1x = (points[k][0] - points[k - 1][0]) / h[k - 1];
const d1y = (points[k][1] - points[k - 1][1]) / h[k - 1];
const d2x = (points[k + 1][0] - points[k][0]) / h[k];
const d2y = (points[k + 1][1] - points[k][1]) / h[k];
const dot = d1x * d2x + d1y * d2y;
// t: 0 = straight, 1 = hairpin
const t = ((1 - dot) / 2) * CP_ANGLE_CORRECTION;
if (t < 1e-6) {
continue;
}
// Bisector of the two chord directions as the "normal" at the knot.
// Its perpendicular is the ideal symmetric tangent direction.
const bx = d1x + d2x;
const by = d1y + d2y;
const blen = Math.hypot(bx, by);
if (blen < 1e-10) {
continue; // 180° hairpin bisector undefined, skip
}
// Blend target: bisector direction (pick sign aligning with current tangent)
let px = bx / blen;
let py = by / blen;
const tx = mx[k] / mlen[k];
const ty = my[k] / mlen[k];
if (tx * px + ty * py < 0) {
px = -px;
py = -py;
}
// Linear blend of unit directions, then renormalize to preserve magnitude.
const blendX = tx + t * (px - tx);
const blendY = ty + t * (py - ty);
const blendLen = Math.max(1e-10, Math.hypot(blendX, blendY));
mx[k] = (blendX / blendLen) * mlen[k];
my[k] = (blendY / blendLen) * mlen[k];
}
const path: string[] = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < n; i++) {
const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3;
const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist;
const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist;
const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist;
const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist;
path.push(
`C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1][0]} ${
points[i + 1][1]
}`,
);
}
return path.join(" ");
};
const generateElbowArrowShape = (
points: readonly LocalPoint[],
radius: number,
) => {
): string => {
const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
-6
View File
@@ -17,7 +17,6 @@ import {
} from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding";
import {
@@ -249,7 +248,6 @@ const bindLinearElementToElement = (
end: ValidLinearElement["end"],
elementStore: ElementStore,
scene: Scene,
zoom: Zoom,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
@@ -337,7 +335,6 @@ const bindLinearElementToElement = (
"orbit",
"start",
scene,
zoom,
);
}
}
@@ -414,7 +411,6 @@ const bindLinearElementToElement = (
"orbit",
"end",
scene,
zoom,
);
}
}
@@ -700,7 +696,6 @@ export const convertToExcalidrawElements = (
originalEnd,
elementStore,
scene,
{ value: 1 } as Zoom,
);
container = linearElement;
elementStore.add(linearElement);
@@ -726,7 +721,6 @@ export const convertToExcalidrawElements = (
end,
elementStore,
scene,
{ value: 1 } as Zoom,
);
elementStore.add(linearElement);
+53 -178
View File
@@ -8,7 +8,6 @@ import {
import {
bezierEquation,
clamp,
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
@@ -27,7 +26,7 @@ import {
type GlobalPoint,
} from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type {
AppState,
@@ -42,7 +41,7 @@ import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { getBindingGap, maxBindingDistance_simple } from "./binding";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
@@ -588,193 +587,67 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo];
};
const getSnappedMidpointIndexForElbowArrow = (
element: ExcalidrawBindableElement,
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
center: GlobalPoint,
horizontalThreshold: number,
verticalThreshold: number,
) => {
const { x, y, width, height, angle } = element;
const nonRotated = pointRotateRads(point, center, -angle as Radians);
const bindingGap = getBindingGap(element);
if (pointDistance(center, nonRotated) < bindingGap) {
return -1;
}
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return 2;
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return 3;
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return 0;
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return 1;
} else if (element.type === "diamond") {
const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 1;
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 0;
}
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 2;
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 3;
}
}
return -1;
};
const getSnappedMidpointIndexForSimpleArrow = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap,
horizontalThreshold: number,
verticalThreshold: number,
zoom: AppState["zoom"],
) => {
const baseMidpoints = getAllMidpoints(element, elementsMap);
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
for (let i = 0; i < baseMidpoints.length; i++) {
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold;
if (
pointDistance(baseMidpoints[i], point) <= threshold &&
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
})
) {
return i;
}
}
return -1;
};
export const getAllMidpoints = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
return getDiamondBaseCorners(element).map((curve) =>
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
);
}
return [
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
pointFrom(element.width / 2, 0),
].map(([x, y]) =>
pointRotateRads(
pointFrom<GlobalPoint>(element.x + x, element.y + y),
center,
element.angle,
),
}),
);
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
arrow: { elbowed: boolean },
): GlobalPoint | undefined => {
const center = elementCenterPoint(element, elementsMap);
const baseMidpoints = getAllMidpoints(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? baseMidpoints.map((midpoint) => {
return pointFrom<GlobalPoint>(
midpoint[0] + (midpoint[0] - center[0]) * 0.1,
midpoint[1] + (midpoint[1] - center[1]) * 0.1,
);
})
: baseMidpoints;
const TOLERANCE = 0.05;
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
const verticalThreshold = clamp(TOLERANCE * element.height, 5, maxDistance);
const horizontalThreshold = clamp(TOLERANCE * element.width, 5, maxDistance);
const idx = arrow.elbowed
? getSnappedMidpointIndexForElbowArrow(
element,
point,
center,
horizontalThreshold,
verticalThreshold,
)
: getSnappedMidpointIndexForSimpleArrow(
element,
point,
elementsMap,
horizontalThreshold,
verticalThreshold,
);
if (idx === -1) {
return undefined;
}
return sideMidpoints[idx];
return candidate;
};
export const projectFixedPointOntoDiagonal = (
@@ -787,6 +660,9 @@ export const projectFixedPointOntoDiagonal = (
isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint(
@@ -794,7 +670,6 @@ export const projectFixedPointOntoDiagonal = (
element,
elementsMap,
zoom,
arrow,
);
if (sideMidPoint) {
return sideMidPoint;
+6 -1
View File
@@ -8,6 +8,7 @@ import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene";
import type {
@@ -155,8 +156,12 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hoveredElement: NonDeletedExcalidrawElement,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) {
return elements;
}
+4 -4
View File
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
} as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(360.9291017525165);
expect(y1).toEqual(185.24770129343722);
expect(x2).toEqual(481.4815539037601);
expect(y2).toEqual(319.8162855827246);
expect(x1).toEqual(366.0476290709661);
expect(y1).toEqual(186.59818534770224);
expect(x2).toEqual(494.6034220048372);
expect(y2).toEqual(324.16489799221546);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ describe("check rotated elements can be hit:", () => {
] as LocalPoint[],
});
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
point: pointFrom<GlobalPoint>(90, -70),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
+2 -7
View File
@@ -16,7 +16,6 @@ import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { Scene } from "../src/Scene";
@@ -188,12 +187,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement;
API.setElements([rectangle1, rectangle2, arrow]);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene, {
value: 1,
} as Zoom);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene, {
value: 1,
} as Zoom);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
@@ -434,12 +434,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[
[
"54.27552",
"46.16120",
"51.36383",
"54.86323",
],
[
"76.95494",
"44.56052",
"81.64884",
"43.04575",
],
]
`);
@@ -499,12 +499,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"104.27552",
"66.16120",
"101.36383",
"74.86323",
],
[
"126.95494",
"64.56052",
"131.64884",
"63.04575",
],
]
`);
@@ -707,14 +707,8 @@ describe("Test Linear Elements", () => {
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
const firstSegmentMidpoint = pointFrom<GlobalPoint>(
55.9697848965255,
47.442326230998205,
);
const lastSegmentMidpoint = pointFrom<GlobalPoint>(
76.08587175006699,
43.294165939653226,
);
const firstSegmentMidpoint = pointFrom<GlobalPoint>(47.30521, 57.2734);
const lastSegmentMidpoint = pointFrom<GlobalPoint>(83.70877, 40.46424);
let line: ExcalidrawLinearElement;
beforeEach(() => {
@@ -759,16 +753,16 @@ describe("Test Linear Elements", () => {
0,
],
[
"85.96978",
"77.44233",
"77.30521",
"87.27340",
],
[
70,
50,
],
[
"106.08587",
"73.29417",
"113.70877",
"70.46424",
],
[
40,
@@ -815,12 +809,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"29.28349",
"20.91105",
"22.32088",
"37.43003",
],
[
"78.86048",
"46.12277",
"81.55727",
"43.21091",
],
]
`);
@@ -904,12 +898,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"54.27552",
"46.16120",
"51.36383",
"54.86323",
],
[
"76.95494",
"44.56052",
"81.64884",
"43.04575",
],
]
`);
@@ -1071,8 +1065,8 @@ describe("Test Linear Elements", () => {
);
expect(position).toMatchInlineSnapshot(`
{
"x": "86.17305",
"y": "76.11251",
"x": "86.53100",
"y": "72.83556",
}
`);
});
@@ -1191,8 +1185,8 @@ describe("Test Linear Elements", () => {
20,
105,
80,
"55.45894",
45,
"56.68277",
"47.27188",
]
`);
@@ -1202,7 +1196,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(`
{
"height": 130,
"width": "366.11716",
"width": "368.53316",
}
`);
@@ -1214,7 +1208,7 @@ describe("Test Linear Elements", () => {
),
).toMatchInlineSnapshot(`
{
"x": "271.11716",
"x": "273.53316",
"y": 45,
}
`);
@@ -1231,10 +1225,10 @@ describe("Test Linear Elements", () => {
[
20,
35,
"501.11716",
95,
"205.45894",
"52.50000",
"503.53316",
"119.02540",
"204.47758",
"77.01270",
]
`);
});
@@ -1897,7 +1897,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement,
"start",
elementsMap,
appState.zoom,
appState.isBindingEnabled,
),
}
@@ -1912,7 +1911,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement,
"end",
elementsMap,
appState.zoom,
appState.isBindingEnabled,
),
}
@@ -1945,7 +1943,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
appState.zoom,
);
}
}
@@ -1960,7 +1957,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
appState.zoom,
);
}
}
+24 -41
View File
@@ -248,6 +248,7 @@ import {
getElementBounds,
doBoundsIntersect,
isPointInElement,
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
@@ -933,20 +934,14 @@ class App extends React.Component<AppProps, AppState> {
"Missing last pointer move coords when changing bind skip mode for arrow start",
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = elementsMap.get(
this.state.selectedLinearElement.elementId,
) as ExcalidrawArrowElement | undefined;
const hoveredElement =
arrow &&
getHoveredElementForBinding(
arrow,
pointFrom<GlobalPoint>(
this.lastPointerMoveCoords.x,
this.lastPointerMoveCoords.y,
),
this.scene.getNonDeletedElements(),
elementsMap,
);
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(
this.lastPointerMoveCoords.x,
this.lastPointerMoveCoords.y,
),
this.scene.getNonDeletedElements(),
elementsMap,
);
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
elementsMap,
@@ -1076,7 +1071,6 @@ class App extends React.Component<AppProps, AppState> {
const { x, y } = this.lastPointerMoveCoords;
const hoveredElement = getHoveredElementForBinding(
arrow,
pointFrom<GlobalPoint>(x, y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
@@ -5383,6 +5377,12 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
if (this.state.selectedLinearElement) {
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
@@ -5390,13 +5390,6 @@ class App extends React.Component<AppProps, AppState> {
);
if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding(
element,
pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
this.handleDelayedBindModeChange(element, hoveredElement);
}
}
@@ -7083,11 +7076,10 @@ class App extends React.Component<AppProps, AppState> {
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding(
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
globalPoint,
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.setState({
@@ -7098,9 +7090,6 @@ class App extends React.Component<AppProps, AppState> {
hoveredElement,
elementsMap,
this.state.zoom,
{
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
},
),
},
});
@@ -7126,11 +7115,10 @@ class App extends React.Component<AppProps, AppState> {
isArrowElement(this.state.newElement) &&
isBindingEnabled(this.state) &&
getHoveredElementForBinding(
this.state.newElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
@@ -7245,7 +7233,6 @@ class App extends React.Component<AppProps, AppState> {
if (isSimpleArrow(multiElement)) {
const hoveredElement = getHoveredElementForBinding(
multiElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
elementsMap,
@@ -7278,11 +7265,10 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.activeTool.type === "arrow") {
const hit = getHoveredElementForBinding(
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
maxBindingDistance_simple(this.state.zoom),
);
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap();
@@ -7295,7 +7281,6 @@ class App extends React.Component<AppProps, AppState> {
hit,
elementsMap,
this.state.zoom,
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
),
},
});
@@ -9282,7 +9267,6 @@ class App extends React.Component<AppProps, AppState> {
const hoveredElementForBinding =
isBindingEnabled(this.state) &&
getHoveredElementForBinding(
{ elbowed: isElbowArrow(multiElement) },
pointFrom<GlobalPoint>(
this.lastPointerMoveCoords?.x ??
rx + multiElement.points[multiElement.points.length - 1][0],
@@ -9405,7 +9389,6 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap();
const boundElement = isBindingEnabled(this.state)
? getHoveredElementForBinding(
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
point,
this.scene.getNonDeletedElements(),
elementsMap,
@@ -9481,6 +9464,7 @@ class App extends React.Component<AppProps, AppState> {
...prevState,
bindMode: "orbit",
newElement: element,
startBoundElement: boundElement,
suggestedBinding:
boundElement && isBindingElement(element)
? {
@@ -9490,7 +9474,6 @@ class App extends React.Component<AppProps, AppState> {
boundElement,
elementsMap,
this.state.zoom,
element,
),
}
: null,
@@ -9887,16 +9870,16 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding(
element,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
);
this.handleDelayedBindModeChange(element, hoveredElement);
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleDelayedBindModeChange(element, hoveredElement);
}
}
if (
+135 -29
View File
@@ -7,6 +7,7 @@ import {
type Radians,
bezierEquation,
pointRotateRads,
pointDistance,
} from "@excalidraw/math";
import {
@@ -23,12 +24,13 @@ import {
deconstructDiamondElement,
deconstructRectanguloidElement,
elementCenterPoint,
getAllMidpoints,
getDiamondBaseCorners,
FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface,
getTransformHandles,
getTransformHandlesFromCoords,
hasBoundingBox,
hitElementItself,
isArrowElement,
isBindableElement,
isElbowArrow,
@@ -36,6 +38,7 @@ import {
isImageElement,
isLinearElement,
isLineElement,
maxBindingDistance_simple,
isTextElement,
LinearElementEditor,
getActiveTextElement,
@@ -410,42 +413,145 @@ const renderBindingHighlightForBindableElement_simple = (
break;
}
// Draw midpoint indicators
if (
appState.isMidpointSnappingEnabled &&
(isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element))
) {
context.save();
// Draw midpoint indicators
const linearElement = appState.selectedLinearElement;
const arrow =
linearElement?.elementId &&
LinearElementEditor.getElement(linearElement?.elementId, elementsMap);
const cursorIsInsideBindable =
pointerCoords &&
hitElementItself({
point: pointerCoords,
element: suggestedBinding.element,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
const midpointRadius = 4 / appState.zoom.value;
const isElbow =
(arrow && isElbowArrow(arrow)) ||
(appState.activeTool.type === "arrow" &&
appState.currentItemArrowType === "elbow");
// Render base midpoints
const midpoints = getAllMidpoints(suggestedBinding.element, elementsMap);
for (const midpoint of midpoints) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)`
: `rgba(65, 65, 65, 0.5)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
if (!cursorIsInsideBindable || isElbow) {
context.save();
const center = elementCenterPoint(suggestedBinding.element, elementsMap);
let midpoints: GlobalPoint[];
if (suggestedBinding.element.type === "diamond") {
const center = elementCenterPoint(
suggestedBinding.element,
elementsMap,
);
midpoints = getDiamondBaseCorners(suggestedBinding.element).map(
(curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(
point,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
},
);
} else {
const basePoints = [
{
x: suggestedBinding.element.width,
y: suggestedBinding.element.height / 2,
}, // RIGHT
{
x: suggestedBinding.element.width / 2,
y: suggestedBinding.element.height,
}, // BOTTOM
{ x: 0, y: suggestedBinding.element.height / 2 }, // LEFT
{ x: suggestedBinding.element.width / 2, y: 0 }, // TOP
];
midpoints = basePoints.map((point) => {
const globalPoint = pointFrom<GlobalPoint>(
point.x + suggestedBinding.element.x,
point.y + suggestedBinding.element.y,
);
const rotatedPoint = pointRotateRads(
globalPoint,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
});
}
const hoveredMidpoint =
pointerCoords &&
midpoints.reduce(
(
closestIdx: {
idx: number;
distance: number;
},
point,
idx,
) => {
const distance = pointDistance(point, pointerCoords);
if (idx === -1 || distance < closestIdx.distance) {
return { idx, distance };
}
return closestIdx;
},
{
idx: -1,
distance: Infinity,
},
);
const midpointRadius = 4 / appState.zoom.value;
const highlightThreshold =
maxBindingDistance_simple(appState.zoom) +
suggestedBinding.element.strokeWidth / 2;
midpoints.forEach((midpoint, idx) => {
const isHighlighted =
(!cursorIsInsideBindable || isElbow) &&
hoveredMidpoint?.idx === idx &&
hoveredMidpoint.distance <= highlightThreshold;
// also render midpoint if cursor close but not highlighted
// (for elbows, always show all points)
const isShown =
!isHighlighted &&
(isElbow ||
(idx === hoveredMidpoint?.idx &&
hoveredMidpoint.distance <= highlightThreshold * 2));
if (isHighlighted) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
} else if (isShown) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)`
: `rgba(65, 65, 65, 0.5)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
});
context.restore();
}
// Render the highlighted midpoint if any
const midpoint = appState.suggestedBinding?.midPoint;
if (midpoint) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
context.restore();
}
};
@@ -98,6 +98,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -731,6 +732,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2275,6 +2277,40 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id4",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 493213705,
"width": 100,
"x": -100,
"y": -50,
},
"stats": {
"open": false,
"panels": 3,
@@ -2374,7 +2410,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -2382,7 +2418,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "399.26547",
"height": "439.20000",
"id": "id4",
"index": "a2",
"isDeleted": false,
@@ -2396,8 +2432,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"488.00000",
"-399.26547",
488,
"-439.20000",
],
],
"roughness": 1,
@@ -2419,9 +2455,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "488.00000",
"width": 488,
"x": 6,
"y": "-4.89900",
"y": "-5.39000",
}
`;
@@ -2542,7 +2578,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -2550,7 +2586,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "399.26547",
"height": "439.20000",
"index": "a2",
"isDeleted": false,
"link": null,
@@ -2562,8 +2598,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"488.00000",
"-399.26547",
488,
"-439.20000",
],
],
"roughness": 1,
@@ -2584,9 +2620,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"version": 11,
"width": "488.00000",
"width": 488,
"x": 6,
"y": "-4.89900",
"y": "-5.39000",
},
"inserted": {
"isDeleted": true,
@@ -7352,6 +7388,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10323,6 +10360,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16553,6 +16591,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -17301,6 +17340,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -17947,6 +17987,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -18591,6 +18632,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -19343,6 +19385,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -21560,6 +21603,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6243,6 +6243,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8691,6 +8692,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8923,6 +8925,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9345,6 +9348,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9757,6 +9761,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14514,6 +14519,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
+1 -1
View File
@@ -5132,7 +5132,7 @@ describe("history", () => {
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([0, 0.5001]),
}),
isDeleted: true,
}),
+4 -14
View File
@@ -16,8 +16,6 @@ import * as StaticScene from "../renderer/staticScene";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { render, fireEvent, act, unmountComponent } from "./test-utils";
import type { Zoom } from "../types";
unmountComponent();
const renderInteractiveScene = vi.spyOn(
@@ -90,7 +88,6 @@ describe("move element", () => {
"orbit",
"start",
h.app.scene,
{ value: 1 } as Zoom,
);
bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
@@ -98,7 +95,6 @@ describe("move element", () => {
"orbit",
"end",
h.app.scene,
{ value: 1 } as Zoom,
);
});
@@ -115,13 +111,10 @@ describe("move element", () => {
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 56.011199999998695]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[88, 88.01760000000121]],
[[106.00000000000001, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
@@ -140,13 +133,10 @@ describe("move element", () => {
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 56.011199999998695]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[89, 90.01760000000121]],
[[106, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
+12 -9
View File
@@ -196,7 +196,7 @@ export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
export const getCurvePathOps = (shape: Drawable): Op[] => {
// NOTE (mtolmacs): Temporary fix for extremely large elements
if (!shape) {
if (!shape || shape.sets.length === 0) {
return [];
}
@@ -316,26 +316,29 @@ export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
};
}
const ops = getCurvePathOps(roughShape);
// Prefer the fillPath set
const fillPathSet = roughShape.sets.find((s) => s.type === "fillPath");
const ops = fillPathSet ? fillPathSet.ops : getCurvePathOps(roughShape);
const points: Point[] = [];
let odd = false;
for (const operation of ops) {
if (operation.op === "move") {
odd = !odd;
if (odd) {
if (fillPathSet) {
// fillPath is always a single run, no odd/even skipping needed
points.push(pointFrom(operation.data[0], operation.data[1]));
} else {
odd = !odd;
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
}
} else if (operation.op === "bcurveTo") {
if (odd) {
if (fillPathSet || odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
points.push(pointFrom(operation.data[2], operation.data[3]));
points.push(pointFrom(operation.data[4], operation.data[5]));
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
}
}