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
+26 -18
View File
@@ -800,6 +800,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit,
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
) || globalPoint,
}
: { mode: null };
@@ -833,6 +834,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
) || otherEndpoint,
}
: { mode: undefined }
@@ -1389,14 +1391,16 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point),
);
const snapPoint = snapToMid(
arrowElement,
bindableElement,
elementsMap,
edgePoint,
0.05,
arrowElement,
);
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : snapPoint[0],
!isHorizontal ? bindableCenter[1] : snapPoint[1],
isHorizontal ? bindableCenter[0] : resolved[0],
!isHorizontal ? bindableCenter[1] : resolved[1],
);
const intersector =
customIntersector ??
@@ -1404,7 +1408,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
vectorNormalize(vectorFromPoint(resolved, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
@@ -1419,14 +1423,14 @@ export const bindPointToSnapToElementOutline = (
if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? bindableCenter[0] : snapPoint[0],
isHorizontal ? bindableCenter[1] : snapPoint[1],
!isHorizontal ? bindableCenter[0] : resolved[0],
isHorizontal ? bindableCenter[1] : resolved[1],
);
const anotherIntersector = lineSegment(
anotherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
vectorNormalize(vectorFromPoint(resolved, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
anotherPoint,
@@ -1573,18 +1577,18 @@ export const avoidRectangularCorner = (
return p;
};
const snapToMid = (
arrowElement: ExcalidrawArrowElement,
export const snapToMid = (
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
): GlobalPoint => {
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 = getBindingGap(bindTarget, arrowElement);
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
@@ -1593,7 +1597,7 @@ const snapToMid = (
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) {
return p;
return undefined;
}
if (
@@ -1602,8 +1606,8 @@ const snapToMid = (
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads<GlobalPoint>(
pointFrom(x - bindingGap, center[1]),
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
@@ -1613,7 +1617,11 @@ const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
@@ -1621,7 +1629,7 @@ const snapToMid = (
) {
// RIGHT
return pointRotateRads(
pointFrom(x + width + bindingGap, center[1]),
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
@@ -1632,7 +1640,7 @@ const snapToMid = (
) {
// DOWN
return pointRotateRads(
pointFrom(center[0], y + height + bindingGap),
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
@@ -1681,7 +1689,7 @@ const snapToMid = (
}
}
return p;
return undefined;
};
const compareElementArea = (
+56 -9
View File
@@ -26,6 +26,7 @@ import {
import {
deconstructLinearOrFreeDrawElement,
getSnapOutlineMidPoint,
isPathALoop,
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
@@ -48,6 +49,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
snapToMid,
updateBoundPoint,
} from "./binding";
import {
@@ -355,6 +357,7 @@ export class LinearElementEditor {
app,
shouldRotateWithDiscreteAngle(event),
event.altKey,
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -408,13 +411,14 @@ export class LinearElementEditor {
altFocusPoint:
!linearElementEditor.initialState.altFocusPoint &&
startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id
updates?.suggestedBinding?.element.id !== startBindingElement.id
? projectFixedPointOntoDiagonal(
element,
pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement,
"start",
elementsMap,
app.state.zoom,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -532,6 +536,7 @@ export class LinearElementEditor {
app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey,
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -607,11 +612,11 @@ export class LinearElementEditor {
const altFocusPointBindableElement =
endIsSelected && // The "other" end (i.e. "end") is dragged
startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
? startBindingElement
: startIsSelected && // The "other" end (i.e. "start") is dragged
endBindingElement &&
updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
? endBindingElement
: null;
@@ -631,6 +636,7 @@ export class LinearElementEditor {
altFocusPointBindableElement,
"start",
elementsMap,
app.state.zoom,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -2080,6 +2086,7 @@ const pointDraggingUpdates = (
app: AppClassProperties,
angleLocked: boolean,
altKey: boolean,
linearElementEditor: LinearElementEditor,
): {
positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates;
@@ -2127,13 +2134,27 @@ const pointDraggingUpdates = (
);
if (isElbowArrow(element)) {
const suggestedBindingElement = startIsDragged
? start.element
: endIsDragged
? end.element
: null;
return {
positions: naiveDraggingPoints,
updates: {
suggestedBinding: startIsDragged
? start.element
: endIsDragged
? end.element
suggestedBinding: suggestedBindingElement
? {
element: suggestedBindingElement,
midPoint: snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
}
: null,
},
};
@@ -2226,7 +2247,20 @@ const pointDraggingUpdates = (
(updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = start.element;
updates.suggestedBinding = start.element
? {
element: start.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
start.element,
elementsMap,
app.state.zoom,
),
}
: null;
}
} else if (startIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding;
@@ -2252,7 +2286,20 @@ const pointDraggingUpdates = (
(updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = end.element;
updates.suggestedBinding = end.element
? {
element: end.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
end.element,
elementsMap,
app.state.zoom,
),
}
: null;
}
} else if (endIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding;
+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;
};
+2 -2
View File
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(63.4035);
expect(boundArrow.points[1][1]).toBeCloseTo(-84.538);
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561);
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2,
@@ -108,7 +108,6 @@ export const actionFinalize = register<FormData>({
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
element,
draggedPoints,
+41 -13
View File
@@ -249,6 +249,7 @@ import {
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
@@ -6400,15 +6401,28 @@ class App extends React.Component<AppProps, AppState> {
// and point
const { newElement } = this.state;
if (!newElement && isBindingEnabled(this.state)) {
const globalPoint = pointFrom<GlobalPoint>(
scenePointerX,
scenePointerY,
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
globalPoint,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
elementsMap,
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.setState({
suggestedBinding: hoveredElement,
suggestedBinding: {
element: hoveredElement,
midPoint: getSnapOutlineMidPoint(
globalPoint,
hoveredElement,
elementsMap,
this.state.zoom,
),
},
});
} else if (this.state.suggestedBinding) {
this.setState({
@@ -6591,16 +6605,19 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom),
);
if (
hit &&
!isPointInElement(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
this.scene.getNonDeletedElementsMap(),
)
) {
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap();
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
this.setState({
suggestedBinding: hit,
suggestedBinding: {
element: hit,
midPoint: getSnapOutlineMidPoint(
scenePointer,
hit,
elementsMap,
this.state.zoom,
),
},
});
}
}
@@ -8762,7 +8779,18 @@ class App extends React.Component<AppProps, AppState> {
bindMode: "orbit",
newElement: element,
startBoundElement: boundElement,
suggestedBinding: boundElement || null,
suggestedBinding:
boundElement && isBindingElement(element)
? {
element: boundElement,
midPoint: getSnapOutlineMidPoint(
point,
boundElement,
elementsMap,
this.state.zoom,
),
}
: null,
selectedElementIds: nextSelectedElementIds,
selectedLinearElement: linearElementEditor,
};
@@ -135,7 +135,6 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("184"));
expect(linear.startBinding).not.toBe(null);
});
+7 -1
View File
@@ -82,7 +82,12 @@ import {
getNormalizedZoom,
} from "../scene";
import type { AppState, BinaryFiles, LibraryItem } from "../types";
import type {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { ImportedDataState, LegacyAppState } from "./types";
type RestoredAppState = Omit<
@@ -212,6 +217,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
boundElement,
startOrEnd,
elementsMap,
{ value: 1 as NormalizedZoomValue },
) || p;
const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding(
element,
+286 -45
View File
@@ -5,6 +5,8 @@ import {
type GlobalPoint,
type LocalPoint,
type Radians,
bezierEquation,
pointRotateRads,
} from "@excalidraw/math";
import {
@@ -21,11 +23,13 @@ import {
deconstructDiamondElement,
deconstructRectanguloidElement,
elementCenterPoint,
getDiamondBaseCorners,
FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface,
getTransformHandles,
getTransformHandlesFromCoords,
hasBoundingBox,
hitElementItself,
isArrowElement,
isBindableElement,
isElbowArrow,
@@ -35,6 +39,12 @@ import {
isLineElement,
isTextElement,
LinearElementEditor,
headingForPoint,
compareHeading,
HEADING_RIGHT,
HEADING_DOWN,
HEADING_LEFT,
HEADING_UP,
} from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element";
@@ -88,8 +98,11 @@ import {
strokeRectWithRotation_simple,
} from "./helpers";
import type { AppClassProperties, InteractiveCanvasAppState } from "../types";
import type {
AppState,
AppClassProperties,
InteractiveCanvasAppState,
} from "../types";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig,
@@ -209,11 +222,14 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
const renderBindingHighlightForBindableElement_simple = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
suggestedBinding: NonNullable<AppState["suggestedBinding"]>,
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
pointerCoords: GlobalPoint | null,
) => {
const enclosingFrame = element.frameId && elementsMap.get(element.frameId);
const enclosingFrame =
suggestedBinding.element.frameId &&
elementsMap.get(suggestedBinding.element.frameId);
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
context.translate(enclosingFrame.x, enclosingFrame.y);
@@ -236,12 +252,12 @@ const renderBindingHighlightForBindableElement_simple = (
context.translate(-enclosingFrame.x, -enclosingFrame.y);
}
switch (element.type) {
switch (suggestedBinding.element.type) {
case "magicframe":
case "frame":
context.save();
context.translate(element.x, element.y);
context.translate(suggestedBinding.element.x, suggestedBinding.element.y);
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
@@ -254,14 +270,19 @@ const renderBindingHighlightForBindableElement_simple = (
context.roundRect(
0,
0,
element.width,
element.height,
suggestedBinding.element.width,
suggestedBinding.element.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.stroke();
context.closePath();
} else {
context.strokeRect(0, 0, element.width, element.height);
context.strokeRect(
0,
0,
suggestedBinding.element.width,
suggestedBinding.element.height,
);
}
context.restore();
@@ -269,30 +290,30 @@ const renderBindingHighlightForBindableElement_simple = (
default:
context.save();
const center = elementCenterPoint(element, elementsMap);
const center = elementCenterPoint(suggestedBinding.element, elementsMap);
context.translate(center[0], center[1]);
context.rotate(element.angle as Radians);
context.rotate(suggestedBinding.element.angle as Radians);
context.translate(-center[0], -center[1]);
context.translate(element.x, element.y);
context.translate(suggestedBinding.element.x, suggestedBinding.element.y);
context.lineWidth =
clamp(1.75, element.strokeWidth, 4) /
clamp(1.75, suggestedBinding.element.strokeWidth, 4) /
Math.max(0.25, appState.zoom.value);
context.strokeStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
switch (element.type) {
switch (suggestedBinding.element.type) {
case "ellipse":
context.beginPath();
context.ellipse(
element.width / 2,
element.height / 2,
element.width / 2,
element.height / 2,
suggestedBinding.element.width / 2,
suggestedBinding.element.height / 2,
suggestedBinding.element.width / 2,
suggestedBinding.element.height / 2,
0,
0,
2 * Math.PI,
@@ -302,18 +323,20 @@ const renderBindingHighlightForBindableElement_simple = (
break;
case "diamond":
{
const [segments, curves] = deconstructDiamondElement(element);
const [segments, curves] = deconstructDiamondElement(
suggestedBinding.element,
);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x,
segment[0][1] - element.y,
segment[0][0] - suggestedBinding.element.x,
segment[0][1] - suggestedBinding.element.y,
);
context.lineTo(
segment[1][0] - element.x,
segment[1][1] - element.y,
segment[1][0] - suggestedBinding.element.x,
segment[1][1] - suggestedBinding.element.y,
);
context.stroke();
});
@@ -322,14 +345,17 @@ const renderBindingHighlightForBindableElement_simple = (
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(start[0] - element.x, start[1] - element.y);
context.moveTo(
start[0] - suggestedBinding.element.x,
start[1] - suggestedBinding.element.y,
);
context.bezierCurveTo(
control1[0] - element.x,
control1[1] - element.y,
control2[0] - element.x,
control2[1] - element.y,
end[0] - element.x,
end[1] - element.y,
control1[0] - suggestedBinding.element.x,
control1[1] - suggestedBinding.element.y,
control2[0] - suggestedBinding.element.x,
control2[1] - suggestedBinding.element.y,
end[0] - suggestedBinding.element.x,
end[1] - suggestedBinding.element.y,
);
context.stroke();
});
@@ -338,18 +364,20 @@ const renderBindingHighlightForBindableElement_simple = (
break;
default:
{
const [segments, curves] = deconstructRectanguloidElement(element);
const [segments, curves] = deconstructRectanguloidElement(
suggestedBinding.element,
);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x,
segment[0][1] - element.y,
segment[0][0] - suggestedBinding.element.x,
segment[0][1] - suggestedBinding.element.y,
);
context.lineTo(
segment[1][0] - element.x,
segment[1][1] - element.y,
segment[1][0] - suggestedBinding.element.x,
segment[1][1] - suggestedBinding.element.y,
);
context.stroke();
});
@@ -358,14 +386,17 @@ const renderBindingHighlightForBindableElement_simple = (
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(start[0] - element.x, start[1] - element.y);
context.moveTo(
start[0] - suggestedBinding.element.x,
start[1] - suggestedBinding.element.y,
);
context.bezierCurveTo(
control1[0] - element.x,
control1[1] - element.y,
control2[0] - element.x,
control2[1] - element.y,
end[0] - element.x,
end[1] - element.y,
control1[0] - suggestedBinding.element.x,
control1[1] - suggestedBinding.element.y,
control2[0] - suggestedBinding.element.x,
control2[1] - suggestedBinding.element.y,
end[0] - suggestedBinding.element.x,
end[1] - suggestedBinding.element.y,
);
context.stroke();
});
@@ -378,6 +409,133 @@ const renderBindingHighlightForBindableElement_simple = (
break;
}
if (
isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element)
) {
// Draw midpoint indicators
const linearElement = appState.selectedLinearElement;
const arrow =
linearElement?.elementId &&
LinearElementEditor.getElement(linearElement?.elementId, elementsMap);
const insideBindable =
pointerCoords &&
arrow &&
hitElementItself({
point: pointerCoords,
element: suggestedBinding.element,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
if (!insideBindable || isElbowArrow(arrow)) {
context.save();
context.translate(suggestedBinding.element.x, suggestedBinding.element.y);
const midpointRadius = 5 / appState.zoom.value;
const center = elementCenterPoint(suggestedBinding.element, elementsMap);
let midpoints: LocalPoint[];
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<LocalPoint>(
rotatedPoint[0] - suggestedBinding.element.x,
rotatedPoint[1] - suggestedBinding.element.y,
);
},
);
} 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<LocalPoint>(
rotatedPoint[0] - suggestedBinding.element.x,
rotatedPoint[1] - suggestedBinding.element.y,
);
});
}
const highlightedPoint =
suggestedBinding.midPoint &&
pointFrom<LocalPoint>(
suggestedBinding.midPoint[0] - suggestedBinding.element.x,
suggestedBinding.midPoint[1] - suggestedBinding.element.y,
);
const target = [HEADING_RIGHT, HEADING_DOWN, HEADING_LEFT, HEADING_UP];
midpoints.forEach((midpoint, idx) => {
const isHighlighted =
highlightedPoint &&
compareHeading(
headingForPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
highlightedPoint[0] + suggestedBinding.element.x,
highlightedPoint[1] + suggestedBinding.element.y,
),
center,
suggestedBinding.element.angle as Radians,
),
center,
),
target[idx],
);
if (!isHighlighted) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.5)`
: `rgba(65, 65, 65, 0.4)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
} else {
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();
}
}
};
const renderBindingHighlightForBindableElement_complex = (
@@ -631,6 +789,78 @@ const renderBindingHighlightForBindableElement_complex = (
context.fill();
context.restore();
// Draw midpoint indicators
context.save();
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
const midpointRadius = 5 / appState.zoom.value;
const cutoutPadding = 5 / appState.zoom.value;
const cutoutRadius = midpointRadius + cutoutPadding;
let midpoints;
if (element.type === "diamond") {
const [, curves] = deconstructDiamondElement(element);
const center = elementCenterPoint(element, allElementsMap);
midpoints = curves.map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return {
x: rotatedPoint[0] - element.x,
y: rotatedPoint[1] - element.y,
};
});
} else {
const center = elementCenterPoint(element, allElementsMap);
const basePoints = [
{ x: element.width / 2, y: 0 }, // TOP
{ x: element.width, y: element.height / 2 }, // RIGHT
{ x: element.width / 2, y: element.height }, // BOTTOM
{ x: 0, y: element.height / 2 }, // LEFT
];
midpoints = basePoints.map((point) => {
const globalPoint = pointFrom<GlobalPoint>(
point.x + element.x,
point.y + element.y,
);
const rotatedPoint = pointRotateRads(
globalPoint,
center,
element.angle,
);
return {
x: rotatedPoint[0] - element.x,
y: rotatedPoint[1] - element.y,
};
});
}
// Clear cutouts around midpoints
midpoints.forEach((midpoint) => {
context.clearRect(
midpoint.x - cutoutRadius,
midpoint.y - cutoutRadius,
cutoutRadius * 2,
cutoutRadius * 2,
);
});
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, ${opacity})`
: `rgba(106, 189, 252, ${opacity})`;
midpoints.forEach((midpoint) => {
context.beginPath();
context.arc(midpoint.x, midpoint.y, midpointRadius, 0, 2 * Math.PI);
context.fill();
});
context.restore();
}
return {
@@ -641,17 +871,21 @@ const renderBindingHighlightForBindableElement_complex = (
const renderBindingHighlightForBindableElement = (
app: AppClassProperties,
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
suggestedBinding: AppState["suggestedBinding"],
allElementsMap: NonDeletedSceneElementsMap,
appState: InteractiveCanvasAppState,
deltaTime: number,
state?: { runtime: number },
) => {
if (suggestedBinding === null) {
return;
}
if (getFeatureFlag("COMPLEX_BINDINGS")) {
return renderBindingHighlightForBindableElement_complex(
app,
context,
element,
suggestedBinding.element,
allElementsMap,
appState,
deltaTime,
@@ -661,11 +895,18 @@ const renderBindingHighlightForBindableElement = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
const pointerCoords = app.lastPointerMoveCoords
? pointFrom<GlobalPoint>(
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
)
: null;
renderBindingHighlightForBindableElement_simple(
context,
element,
suggestedBinding,
allElementsMap,
appState,
pointerCoords,
);
context.restore();
};
@@ -227,7 +227,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 34,
"version": 33,
"width": "94.00000",
"x": 0,
"y": 0,
@@ -334,7 +334,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
"mode": "orbit",
},
"height": "74.35962",
"height": "105.58873",
"points": [
[
0,
@@ -342,50 +342,50 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
[
88,
"74.35962",
"105.58873",
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 33,
"version": 32,
"width": 88,
},
"inserted": {
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.39746",
"0.60254",
0,
"0.50010",
],
"mode": "orbit",
},
"height": "1.66245",
"height": "0.00000",
"points": [
[
0,
0,
],
[
88,
"-1.66245",
"88.00000",
"0.00000",
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 30,
"width": 88,
"version": 29,
"width": "88.00000",
},
},
},
@@ -440,13 +440,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
],
"startBinding": null,
"version": 34,
"version": 33,
"width": "94.00000",
"x": 0,
"y": 0,
},
"inserted": {
"height": "74.35962",
"height": "105.58873",
"points": [
[
0,
@@ -454,21 +454,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
[
88,
"74.35962",
"105.58873",
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 33,
"version": 32,
"width": 88,
"x": 6,
"y": "46.67801",
"y": "7.09000",
},
},
},
@@ -854,7 +854,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 26,
"version": 30,
"width": 100,
"x": 150,
"y": 0,
@@ -904,60 +904,60 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": {
"deleted": {
"endBinding": null,
"height": "5.28000",
"height": "0.00293",
"points": [
[
0,
0,
],
[
-44,
"-5.28000",
"-44.00000",
"-0.00293",
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 25,
"width": 44,
"y": "5.28000",
"version": 29,
"width": "44.00000",
"y": "0.00293",
},
"inserted": {
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.41067",
"0.58933",
0,
"0.50010",
],
"mode": "orbit",
},
"height": "9.80848",
"height": 0,
"points": [
[
0,
0,
],
[
"47.06697",
"9.80848",
"6.00000",
0,
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 24,
"width": "47.06697",
"y": "-0.87545",
"version": 27,
"width": "6.00000",
"y": "0.01000",
},
},
},
@@ -1004,35 +1004,35 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
],
"startBinding": null,
"version": 26,
"version": 30,
"width": 100,
"x": 150,
"y": 0,
},
"inserted": {
"height": "5.28000",
"height": "0.00293",
"points": [
[
0,
0,
],
[
-44,
"-5.28000",
"-44.00000",
"-0.00293",
],
],
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.63636",
"0.63636",
1,
"0.50010",
],
"mode": "orbit",
},
"version": 25,
"width": 44,
"version": 29,
"width": "44.00000",
"x": 144,
"y": "5.28000",
"y": "0.00293",
},
},
},
@@ -2390,7 +2390,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "398.76619",
"height": "439.13521",
"id": "id4",
"index": "a2",
"isDeleted": false,
@@ -2413,7 +2413,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
[
488,
"-398.76619",
"-439.13521",
],
],
"roughness": 1,
@@ -2434,16 +2434,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 12,
"version": 13,
"width": 488,
"x": 6,
"y": "-4.89286",
"y": "-5.38920",
}
`;
exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of elements 1`] = `3`;
exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `11`;
exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `12`;
exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] redo stack 1`] = `[]`;
@@ -2558,7 +2558,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": {
"elementId": "id1",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "398.76619",
"height": "439.13521",
"index": "a2",
"isDeleted": false,
"link": null,
@@ -2579,7 +2579,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
],
[
488,
"-398.76619",
"-439.13521",
],
],
"roughness": 1,
@@ -2599,14 +2599,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 12,
"version": 13,
"width": 488,
"x": 6,
"y": "-4.89286",
"y": "-5.38920",
},
"inserted": {
"isDeleted": true,
"version": 9,
"version": 10,
},
},
},
@@ -16375,7 +16375,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -16383,7 +16383,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"id": "id13",
"index": "a3",
"isDeleted": false,
@@ -16398,7 +16398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -16409,7 +16409,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -16422,7 +16422,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
}
`;
@@ -16458,7 +16458,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -16467,7 +16467,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -16478,7 +16478,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -16487,7 +16487,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -16793,7 +16793,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -16801,7 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00611",
"height": "0.00936",
"index": "a3",
"isDeleted": false,
"link": null,
@@ -16814,7 +16814,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
88,
"0.00611",
"0.00936",
],
],
"roughness": 1,
@@ -16825,7 +16825,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -17126,7 +17126,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -17134,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"id": "id13",
"index": "a3",
"isDeleted": false,
@@ -17149,7 +17149,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -17160,7 +17160,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -17173,7 +17173,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
}
`;
@@ -17434,7 +17434,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -17442,7 +17442,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"index": "a3",
"isDeleted": false,
"link": null,
@@ -17455,7 +17455,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -17466,7 +17466,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -17478,7 +17478,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
},
"inserted": {
"isDeleted": true,
@@ -17775,7 +17775,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -17783,7 +17783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"id": "id13",
"index": "a3",
"isDeleted": false,
@@ -17798,7 +17798,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -17809,7 +17809,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -17822,7 +17822,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
}
`;
@@ -18083,7 +18083,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -18091,7 +18091,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"index": "a3",
"isDeleted": false,
"link": null,
@@ -18104,7 +18104,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -18115,7 +18115,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -18127,7 +18127,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
},
"inserted": {
"isDeleted": true,
@@ -18422,7 +18422,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -18430,7 +18430,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"id": "id13",
"index": "a3",
"isDeleted": false,
@@ -18445,7 +18445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -18456,7 +18456,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -18469,7 +18469,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 12,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
}
`;
@@ -18522,7 +18522,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -18530,7 +18530,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -18541,7 +18541,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -18816,7 +18816,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -18824,7 +18824,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00611",
"height": "0.00936",
"index": "a3",
"isDeleted": false,
"link": null,
@@ -18837,7 +18837,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
88,
"0.00611",
"0.00936",
],
],
"roughness": 1,
@@ -18848,7 +18848,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -19177,7 +19177,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -19185,7 +19185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00092",
"height": "0.00004",
"id": "id13",
"index": "a3",
"isDeleted": false,
@@ -19200,7 +19200,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
"88.00000",
"0.00092",
"0.00004",
],
],
"roughness": 1,
@@ -19211,7 +19211,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -19224,7 +19224,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 13,
"width": "88.00000",
"x": 6,
"y": "0.00849",
"y": "0.00996",
}
`;
@@ -19288,7 +19288,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -19296,7 +19296,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -19567,7 +19567,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id2",
"fixedPoint": [
"0.50010",
0,
"0.50010",
],
"mode": "orbit",
@@ -19575,7 +19575,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "0.00611",
"height": "0.00936",
"index": "a3",
"isDeleted": false,
"link": null,
@@ -19588,7 +19588,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
],
[
88,
"0.00611",
"0.00936",
],
],
"roughness": 1,
@@ -19599,7 +19599,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -182,14 +182,14 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elementId": "id3",
"fixedPoint": [
"-0.02000",
"0.44667",
"0.47904",
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "90.00000",
"height": "90.02554",
"id": "id6",
"index": "a2",
"isDeleted": false,
@@ -204,7 +204,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
],
[
89,
"90.00000",
"90.02554",
],
],
"roughness": 1,
@@ -217,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elementId": "id0",
"fixedPoint": [
"1.06000",
"0.46000",
"0.55687",
],
"mode": "orbit",
},
@@ -230,6 +230,6 @@ exports[`move element > rectangles with binding arrow 7`] = `
"versionNonce": 271613161,
"width": 89,
"x": 106,
"y": "46.00000",
"y": "55.68677",
}
`;
+22 -21
View File
@@ -1590,7 +1590,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
@@ -1613,7 +1613,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
@@ -1636,7 +1636,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
@@ -1667,7 +1667,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
@@ -1690,7 +1690,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
@@ -4510,20 +4510,20 @@ describe("history", () => {
// create start binding
mouse.downAt(0, 0);
mouse.moveTo(0, 10);
mouse.moveTo(0, 10);
mouse.moveTo(0, 3);
mouse.moveTo(0, 3);
mouse.up();
// create end binding
mouse.downAt(100, 0);
mouse.moveTo(100, 10);
mouse.moveTo(100, 10);
mouse.moveTo(100, 3);
mouse.moveTo(100, 3);
mouse.up();
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding
?.fixedPoint,
).not.toEqual([1, 0.5001]);
).toEqual([1, 0.5001]);
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode,
).toBe("orbit");
@@ -4623,12 +4623,12 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [0.6363636363636364, 0.6363636363636364],
fixedPoint: [1, 0.5001],
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [0.4106696643494564, 0.5893303356505437],
fixedPoint: [0, 0.5001],
mode: "orbit",
}),
}),
@@ -4666,13 +4666,13 @@ describe("history", () => {
// create start binding
mouse.downAt(0, 0);
mouse.moveTo(0, 10);
mouse.upAt(0, 10);
mouse.moveTo(0, 3);
mouse.upAt(0, 3);
// create end binding
mouse.downAt(100, 0);
mouse.moveTo(100, 10);
mouse.upAt(100, 10);
mouse.moveTo(100, 3);
mouse.upAt(100, 3);
expect(h.elements).toEqual(
expect.arrayContaining([
@@ -4766,13 +4766,13 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [0.6363636363636364, 0.6363636363636364],
fixedPoint: [1, 0.5001],
mode: "orbit",
}),
// rebound with previous rectangle
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [0.39746300211416496, 0.6025369978858351],
fixedPoint: [0, 0.5001],
mode: "orbit",
}),
}),
@@ -5031,8 +5031,9 @@ describe("history", () => {
// bind arrow to rect1 and rect2
UI.clickTool("arrow");
mouse.down(0, 0);
mouse.moveTo(50, 0);
mouse.up(50, 0);
mouse.moveTo(25, 0);
mouse.moveTo(47, 0);
mouse.up(47, 0);
const arrowId = h.elements[2].id;
@@ -5055,7 +5056,7 @@ describe("history", () => {
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([0, 0.5001]),
}),
isDeleted: true,
}),
+9 -6
View File
@@ -78,7 +78,7 @@ describe("move element", () => {
// create elements
const rectA = UI.createElement("rectangle", { size: 100 });
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
const arrow = UI.createElement("arrow", { x: 105, y: 50, size: 88 });
act(() => {
// bind line to two rectangles
@@ -110,7 +110,10 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106.00000000000001, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
renderInteractiveScene.mockClear();
@@ -129,11 +132,11 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[79, 124.1678]],
2,
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
+2 -2
View File
@@ -80,6 +80,6 @@ test("unselected bound arrows update when rotating their target elements", async
expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-98.87, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-123.65, 0);
expect(textArrow.points[1][0]).toBeCloseTo(-95.4635969899922, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-126.8785027399889, 0);
});
+6 -1
View File
@@ -47,6 +47,7 @@ import type {
DurableIncrement,
EphemeralIncrement,
} from "@excalidraw/element";
import type { GlobalPoint } from "@excalidraw/math";
import type { Action } from "./actions/types";
import type { Spreadsheet } from "./charts";
@@ -301,7 +302,10 @@ export interface AppState {
selectionElement: NonDeletedExcalidrawElement | null;
isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint;
} | null;
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
frameRendering: {
enabled: boolean;
@@ -759,6 +763,7 @@ export type AppClassProperties = {
updateEditorAtom: App["updateEditorAtom"];
onPointerDownEmitter: App["onPointerDownEmitter"];
lastPointerMoveCoords: App["lastPointerMoveCoords"];
bindModeHandler: App["bindModeHandler"];
};