feat(editor): visualize binding midpoints + support for simple arrows (#10611)

* feat: Force exact center focus point

When the projected point is close to center snap it to the exact center.

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

* fix: Tests

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

* fix: Snap to center around side mid point.

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

* Trigger CI

* fix: Midpoint outline focus point

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

* fix: Tests

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

* fix: Dragging existing arrow reset focus point on outline

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

* fix: Tests

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

* feat: Midpoint indicator

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

* fix: Rotated mid points

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

* fix: No hole

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Increase outline midpoint sticky distance

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

* fix: Don't show midpoint indicator when no snapping is possible

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

* feat: Indicate lock-in

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: Simple arrow mid point selection inconsistency

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

* fix: cache override

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

* fix: Precise know dragging with midpoint refactor

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

* fear: Frame support

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

* fix: Crossing arrow won't trigger mid point

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

* fix: Arrow creation point highlight

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

* fix: Restore types & tests

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

* chore: Restore restore.ts

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

* fix: restore.ts

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

* fix: Elbow arrows reliably highlight center point

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

* fix: Highlight point ordering

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

* feat: Bind with focus point across shape

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

* fix: Lint

* fix: Midpoint and binding alignment

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

* chore: Indicator color

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

* chore: More knob tuning

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

* fix: Radius

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

* fix: Tests

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

* simplify point indicators

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-02-11 11:06:27 +01:00
committed by GitHub
parent 1c8e8bb0f3
commit a70417f23f
15 changed files with 699 additions and 268 deletions
+119 -25
View File
@@ -7,6 +7,7 @@ import {
} from "@excalidraw/common";
import {
bezierEquation,
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
@@ -27,19 +28,25 @@ import {
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import type {
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import { isPointInElement } from "./collision";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
@@ -329,24 +336,10 @@ export function deconstructRectanguloidElement(
return shape;
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
export function getDiamondBaseCorners(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
): Curve<GlobalPoint>[] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
@@ -363,7 +356,7 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY),
];
const baseCorners = [
return [
curve(
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
@@ -413,6 +406,27 @@ export function deconstructDiamondElement(
),
), // TOP
];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map(
(corner) =>
@@ -570,18 +584,94 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo];
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
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);
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 candidate;
};
export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement,
point: GlobalPoint,
element: ExcalidrawElement,
element: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element,
elementsMap,
@@ -603,18 +693,22 @@ export const projectFixedPointOntoDiagonal = (
),
a,
);
const intersector = lineSegment<GlobalPoint>(point, b);
const intersector = lineSegment<GlobalPoint>(b, a);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2);
let p = null;
let projection = null;
if (d1 != null && d2 != null) {
p = d1 < d2 ? p1 : p2;
projection = d1 < d2 ? p1 : p2;
} else {
p = p1 || p2 || null;
projection = p1 || p2 || null;
}
return p && isPointInElement(p, element, elementsMap) ? p : null;
if (projection && isPointInElement(projection, element, elementsMap)) {
return projection;
}
return null;
};