Half done

This commit is contained in:
Mark Tolmacs 2025-03-28 20:41:26 +01:00
parent ff57dd60d8
commit 5af4500bbc
5 changed files with 222 additions and 83 deletions

View File

@ -7,7 +7,6 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
@ -559,9 +558,6 @@ 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

View File

@ -1,7 +1,6 @@
import { import {
KEYS, KEYS,
arrayToMap, arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors, tupleToCoors,
invariant, invariant,
isDevEnv, isDevEnv,
@ -427,7 +426,7 @@ export const getSuggestedBindingsForArrows = (
export const maybeBindLinearElement = ( export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
pointerCoords: { x: number; y: number }, startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
): void => { ): void => {
@ -441,7 +440,20 @@ export const maybeBindLinearElement = (
} }
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
pointerCoords, tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd ? 0 : -1,
elementsMap,
),
),
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd ? -1 : 0,
elementsMap,
),
),
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,
@ -568,6 +580,10 @@ export const getHoveredElementForBinding = (
x: number; x: number;
y: number; y: number;
}, },
otherPointerCoords: {
x: number;
y: number;
},
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
@ -575,59 +591,89 @@ export const getHoveredElementForBinding = (
considerAllElements?: boolean, considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
if (considerAllElements) { if (considerAllElements) {
let cullRest = false; const otherCandidateElement =
const candidateElements = getAllElementsAtPositionForBinding( getAllElementsAtPositionForBinding(
elements, elements,
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest( bindingBorderTest(
element, element,
pointerCoords, otherPointerCoords,
elementsMap, elementsMap,
zoom, zoom,
(fullShape || fullShape ||
!isBindingFallthroughEnabled( // disable fullshape snapping for frame elements so we
element as ExcalidrawBindableElement, // can bind to frame children
)) && !isFrameLikeElement(element),
// disable fullshape snapping for frame elements so we ),
// can bind to frame children )
!isFrameLikeElement(element), .filter(
), (element): element is NonDeleted<ExcalidrawBindableElement> =>
).filter((element) => { element != null,
if (cullRest) { )
return false; // Prefer the shape with the border being tested (if any)
} .filter(
(element, _, arr) =>
arr.length <= 1 ||
bindingBorderTest(
element as NonDeleted<ExcalidrawBindableElement>,
otherPointerCoords,
elementsMap,
zoom,
false,
),
)
// Prefer smaller bindables to be consisent with the check for the other
// point
.sort(
(a, b) =>
b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() ?? null;
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) { const candidateElement =
cullRest = true; getAllElementsAtPositionForBinding(
} elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
pointerCoords,
elementsMap,
zoom,
fullShape ||
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element),
),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
element != null,
) // Prefer the shape with the border being tested (if any)
.filter(
(element, _, arr) =>
arr.length <= 1 ||
bindingBorderTest(
element as NonDeleted<ExcalidrawBindableElement>,
pointerCoords,
elementsMap,
zoom,
false,
),
)
// Prefer smaller bindables
.sort(
(a, b) =>
b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() ?? null;
return true; if (otherCandidateElement === candidateElement) {
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
return null; return null;
} }
if (candidateElements.length === 1) { return candidateElement;
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 = getElementAtPositionForBinding( const hoveredElement = getElementAtPositionForBinding(
@ -641,8 +687,7 @@ 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 || !isBindingFallthroughEnabled(element)) && fullShape || !isFrameLikeElement(element),
!isFrameLikeElement(element),
), ),
); );
@ -1179,34 +1224,39 @@ export const snapToMid = (
export const getOutlineAvoidingPoint = ( export const getOutlineAvoidingPoint = (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
coords: GlobalPoint, startOrEnd: "start" | "end",
pointIndex: number,
scene: Scene, scene: Scene,
zoom: AppState["zoom"], zoom: AppState["zoom"],
fallback?: GlobalPoint, fallback?: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const coords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd ? 0 : -1,
elementsMap,
);
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
{ x: coords[0], y: coords[1] }, tupleToCoors(coords),
scene.getNonDeletedElements(), tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd ? -1 : 0,
elementsMap,
),
),
elements,
elementsMap, elementsMap,
zoom, zoom,
true, true,
isElbowArrow(element), isElbowArrow(element),
); );
if (hoveredElement) { const pointIndex = startOrEnd === "start" ? 0 : element.points.length - 1;
const newPoints = Array.from(element.points);
newPoints[pointIndex] = pointFrom<LocalPoint>(
coords[0] - element.x,
coords[1] - element.y,
);
if (hoveredElement) {
return bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
{ element,
...element,
points: newPoints,
},
hoveredElement, hoveredElement,
pointIndex === 0 ? "start" : "end", pointIndex === 0 ? "start" : "end",
elementsMap, elementsMap,

View File

@ -503,4 +503,82 @@ describe("element binding", () => {
}); });
}); });
}); });
// UX RATIONALE: The arrow might be outside of the shape at high zoom and you
// won't see what's going on.
it(
"allow non-binding simple (complex) arrow creation while start and end" +
" points are in the same shape",
() => {
UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: 5,
y: 5,
height: 95,
width: 95,
});
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[95, 95],
]);
const rect2 = API.createElement({
type: "rectangle",
x: 300,
y: 300,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect2]);
const arrow2 = UI.createElement("arrow", {
x: 305,
y: 305,
height: 95,
width: 95,
});
expect(arrow2.startBinding).toBe(null);
expect(arrow2.endBinding).toBe(null);
expect(arrow2.points).toCloselyEqualPoints([
[0, 0],
[95, 95],
]);
UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow3 = UI.createElement("arrow", {
x: 5,
y: 5,
height: 95,
width: 95,
elbowed: true,
});
expect(arrow3.startBinding).toBe(null);
expect(arrow3.endBinding).toBe(null);
expect(arrow3.points).toCloselyEqualPoints([
[0, 0],
[45, 45],
[95, 95],
]);
},
);
}); });

View File

@ -2919,14 +2919,8 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement( maybeBindLinearElement(
multiElement, multiElement,
this.state, this.state,
tupleToCoors( "end",
LinearElementEditor.getPointAtIndexGlobalCoordinates( nonDeletedElementsMap,
multiElement,
-1,
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
); );
} }
@ -5976,8 +5970,7 @@ class App extends React.Component<AppProps, AppState> {
toLocalPoint( toLocalPoint(
getOutlineAvoidingPoint( getOutlineAvoidingPoint(
multiElement, multiElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), "end",
multiElement.points.length - 1,
this.scene, this.scene,
this.state.zoom, this.state.zoom,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
@ -8667,7 +8660,16 @@ class App extends React.Component<AppProps, AppState> {
...points.slice(0, -1), ...points.slice(0, -1),
toLocalPoint( toLocalPoint(
getOutlineAvoidingPoint( getOutlineAvoidingPoint(
newElement, {
...newElement,
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
},
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y), pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
newElement.points.length - 1, newElement.points.length - 1,
this.scene, this.scene,
@ -9091,7 +9093,7 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement( maybeBindLinearElement(
newElement, newElement,
this.state, this.state,
pointerCoords, "end",
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
); );

View File

@ -464,6 +464,7 @@ export class UI {
height: initialHeight = initialWidth, height: initialHeight = initialWidth,
angle = 0, angle = 0,
points: initialPoints, points: initialPoints,
elbowed = false,
}: { }: {
position?: number; position?: number;
x?: number; x?: number;
@ -473,6 +474,7 @@ export class UI {
height?: number; height?: number;
angle?: number; angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never; points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
elbowed?: boolean;
} = {}, } = {},
): Element<T> & { ): Element<T> & {
/** Returns the actual, current element from the elements array, instead /** Returns the actual, current element from the elements array, instead
@ -491,6 +493,17 @@ export class UI {
if (type === "text") { if (type === "text") {
mouse.reset(); mouse.reset();
mouse.click(x, y); mouse.click(x, y);
} else if (type === "arrow" && points.length === 2 && elbowed) {
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(x + points[0][0], y + points[0][1]);
mouse.click();
mouse.moveTo(
x + points[points.length - 1][0],
y + points[points.length - 1][1],
);
mouse.click();
Keyboard.keyPress(KEYS.ESCAPE);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();