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:
+119
-25
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user