fix: Elbow arrow z-index binding (#9067)

This commit is contained in:
Márk Tolmács 2025-02-01 19:21:03 +01:00 committed by GitHub
parent 86c67bd37f
commit 302664e500
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 165 additions and 31 deletions

View file

@ -1605,6 +1605,8 @@ export const actionChangeArrowType = register({
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
false,
true,
); );
const endHoveredElement = const endHoveredElement =
!newElement.endBinding && !newElement.endBinding &&
@ -1613,6 +1615,8 @@ export const actionChangeArrowType = register({
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
false,
true,
); );
const startElement = startHoveredElement const startElement = startHoveredElement
? startHoveredElement ? startHoveredElement

View file

@ -5770,7 +5770,10 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
if (editingLinearElement?.lastUncommittedPoint != null) { if (editingLinearElement?.lastUncommittedPoint != null) {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
);
} else { } else {
// causes stack overflow if not sync // causes stack overflow if not sync
flushSync(() => { flushSync(() => {
@ -5790,7 +5793,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.startBoundElement, this.state.startBoundElement,
); );
} else { } else {
this.maybeSuggestBindingAtCursor(scenePointer); this.maybeSuggestBindingAtCursor(scenePointer, false);
} }
} }
@ -7728,6 +7731,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isElbowArrow(element), isElbowArrow(element),
isElbowArrow(element),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@ -10005,15 +10009,20 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private maybeSuggestBindingAtCursor = (pointerCoords: { private maybeSuggestBindingAtCursor = (
x: number; pointerCoords: {
y: number; x: number;
}): void => { y: number;
},
considerAll: boolean,
): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
false,
considerAll,
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@ -10043,7 +10052,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state.zoom, this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&

View file

@ -49,7 +49,11 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
} from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
@ -75,6 +79,7 @@ import {
clamp, clamp,
} from "../../math"; } from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
import { getElementsAtPosition } from "../scene/comparisons";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@ -425,7 +430,8 @@ export const maybeBindLinearElement = (
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
if (hoveredElement !== null) { if (hoveredElement !== null) {
@ -558,7 +564,64 @@ export const getHoveredElementForBinding = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
if (considerAllElements) {
let cullRest = false;
const candidateElements = getElementsAtPosition(
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
pointerCoords,
elementsMap,
zoom,
(fullShape ||
!isBindingFallthroughEnabled(
element as ExcalidrawBindableElement,
)) &&
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element),
),
).filter((element) => {
if (cullRest) {
return false;
}
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
cullRest = true;
}
return true;
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
}
// Prefer the shape with the border being tested (if any)
const borderTestElements = candidateElements.filter((element) =>
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
);
if (borderTestElements.length === 1) {
return borderTestElements[0];
}
// 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 hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
elements, elements,
(element) => (element) =>
@ -570,7 +633,8 @@ export const getHoveredElementForBinding = (
zoom, zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape && !isFrameLikeElement(element), (fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element),
), ),
); );
@ -1220,6 +1284,8 @@ const getElligibleElementForBindingElement = (
elements, elements,
elementsMap, elementsMap,
zoom, zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
); );
}; };

View file

@ -20,11 +20,11 @@ import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
distanceToBindableElement, distanceToBindableElement,
avoidRectangularCorner, avoidRectangularCorner,
getHoveredElementForBinding,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid, snapToMid,
getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
@ -872,6 +872,8 @@ export const updateElbowArrowPoints = (
updates: { updates: {
points?: readonly LocalPoint[]; points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null; fixedSegments?: FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
}, },
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
@ -953,17 +955,48 @@ export const updateElbowArrowPoints = (
hoveredStartElement, hoveredStartElement,
hoveredEndElement, hoveredEndElement,
...rest ...rest
} = getElbowArrowData(arrow, elementsMap, updatedPoints, options); } = getElbowArrowData(
{
x: arrow.x,
y: arrow.y,
startBinding:
typeof updates.startBinding !== "undefined"
? updates.startBinding
: arrow.startBinding,
endBinding:
typeof updates.endBinding !== "undefined"
? updates.endBinding
: arrow.endBinding,
startArrowhead: arrow.startArrowhead,
endArrowhead: arrow.endArrowhead,
},
elementsMap,
updatedPoints,
options,
);
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
//// ////
// 1. Renormalize the arrow // 1. Renormalize the arrow
//// ////
if (!updates.points && !updates.fixedSegments) { if (
!updates.points &&
!updates.fixedSegments &&
!updates.startBinding &&
!updates.endBinding
) {
return handleSegmentRenormalization(arrow, elementsMap); return handleSegmentRenormalization(arrow, elementsMap);
} }
// Short circuit on no-op to avoid huge performance hit
if (
updates.startBinding === arrow.startBinding &&
updates.endBinding === arrow.endBinding
) {
return {};
}
//// ////
// 2. Just normal elbow arrow things // 2. Just normal elbow arrow things
//// ////
@ -1010,6 +1043,7 @@ export const updateElbowArrowPoints = (
//// ////
// 5. Handle resize // 5. Handle resize
////
if (updates.points && updates.fixedSegments) { if (updates.points && updates.fixedSegments) {
return updates; return updates;
} }
@ -2120,6 +2154,7 @@ const getHoveredElements = (
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom, zoom,
true, true,
true,
), ),
getHoveredElementForBinding( getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint), tupleToCoors(origEndGlobalPoint),
@ -2127,6 +2162,7 @@ const getHoveredElements = (
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom, zoom,
true, true,
true,
), ),
]; ];
}; };

View file

@ -444,6 +444,8 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
isElbowArrow(element),
isElbowArrow(element),
) )
: null; : null;
@ -796,6 +798,7 @@ export class LinearElementEditor {
elements, elements,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
linearElementEditor.elbowed,
), ),
}; };

View file

@ -33,13 +33,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId } = updates as any; const { points, fixedSegments, fileId, startBinding, endBinding } =
updates as any;
if ( if (
isElbowArrow(element) && isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case (Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) { ) {
const elementsMap = toBrandedType<SceneElementsMap>( const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(), Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
@ -58,6 +61,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
{ {
fixedSegments, fixedSegments,
points, points,
startBinding,
endBinding,
}, },
{ {
isDragging: options?.isDragging, isDragging: options?.isDragging,

View file

@ -75,20 +75,23 @@ export const getElementsAtPosition = (
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => { ) => {
const iframeLikes: ExcalidrawIframeElement[] = []; const iframeLikes: ExcalidrawIframeElement[] = [];
// The parameter elements comes ordered from lower z-index to higher. const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
// We want to preserve that order on the returned array. // We need to to hit testing from front (end of the array) to back (beginning of the array)
// Exception being embeddables which should be on top of everything else in // because array is ordered from lower z-index to highest and we want element z-index
// terms of hit testing. // with higher z-index
const elsAtPos = elements.filter((element) => { for (let index = elements.length - 1; index >= 0; --index) {
const hit = !element.isDeleted && isAtPositionFn(element); const element = elements[index];
if (hit) { if (element.isDeleted) {
if (isIframeElement(element)) { continue;
iframeLikes.push(element);
return false;
}
return true;
} }
return false; if (isIframeElement(element)) {
}); iframeLikes.push(element);
return elsAtPos.concat(iframeLikes); continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition.concat(iframeLikes);
}; };

View file

@ -9,7 +9,11 @@ import {
isDarwin, isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import type { FontFamilyValues, FontString } from "./element/types"; import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "./element/types";
import type { import type {
ActiveTool, ActiveTool,
AppState, AppState,
@ -543,6 +547,9 @@ export const isTransparent = (color: string) => {
); );
}; };
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void