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";
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
FontFamilyValues,
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> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void

View file

@ -1,7 +1,6 @@
import {
KEYS,
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
invariant,
isDevEnv,
@ -427,7 +426,7 @@ export const getSuggestedBindingsForArrows = (
export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
): void => {
@ -441,7 +440,20 @@ export const maybeBindLinearElement = (
}
const hoveredElement = getHoveredElementForBinding(
pointerCoords,
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd ? 0 : -1,
elementsMap,
),
),
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd ? -1 : 0,
elementsMap,
),
),
elements,
elementsMap,
appState.zoom,
@ -568,6 +580,10 @@ export const getHoveredElementForBinding = (
x: number;
y: number;
},
otherPointerCoords: {
x: number;
y: number;
},
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
@ -575,59 +591,89 @@ export const getHoveredElementForBinding = (
considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
if (considerAllElements) {
let cullRest = false;
const candidateElements = getAllElementsAtPositionForBinding(
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;
}
const otherCandidateElement =
getAllElementsAtPositionForBinding(
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
otherPointerCoords,
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>,
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)) {
cullRest = true;
}
const candidateElement =
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;
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
if (otherCandidateElement === candidateElement) {
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>;
return candidateElement;
}
const hoveredElement = getElementAtPositionForBinding(
@ -641,8 +687,7 @@ export const getHoveredElementForBinding = (
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element),
fullShape || !isFrameLikeElement(element),
),
);
@ -1179,34 +1224,39 @@ export const snapToMid = (
export const getOutlineAvoidingPoint = (
element: NonDeleted<ExcalidrawLinearElement>,
coords: GlobalPoint,
pointIndex: number,
startOrEnd: "start" | "end",
scene: Scene,
zoom: AppState["zoom"],
fallback?: GlobalPoint,
): GlobalPoint => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const coords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd ? 0 : -1,
elementsMap,
);
const hoveredElement = getHoveredElementForBinding(
{ x: coords[0], y: coords[1] },
scene.getNonDeletedElements(),
tupleToCoors(coords),
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd ? -1 : 0,
elementsMap,
),
),
elements,
elementsMap,
zoom,
true,
isElbowArrow(element),
);
if (hoveredElement) {
const newPoints = Array.from(element.points);
newPoints[pointIndex] = pointFrom<LocalPoint>(
coords[0] - element.x,
coords[1] - element.y,
);
const pointIndex = startOrEnd === "start" ? 0 : element.points.length - 1;
if (hoveredElement) {
return bindPointToSnapToElementOutline(
{
...element,
points: newPoints,
},
element,
hoveredElement,
pointIndex === 0 ? "start" : "end",
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(
multiElement,
this.state,
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
-1,
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(),
"end",
nonDeletedElementsMap,
this.scene.getNonDeletedElements(),
);
}
@ -5976,8 +5970,7 @@ class App extends React.Component<AppProps, AppState> {
toLocalPoint(
getOutlineAvoidingPoint(
multiElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
multiElement.points.length - 1,
"end",
this.scene,
this.state.zoom,
pointFrom<GlobalPoint>(
@ -8667,7 +8660,16 @@ class App extends React.Component<AppProps, AppState> {
...points.slice(0, -1),
toLocalPoint(
getOutlineAvoidingPoint(
newElement,
{
...newElement,
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
},
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
newElement.points.length - 1,
this.scene,
@ -9091,7 +9093,7 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement(
newElement,
this.state,
pointerCoords,
"end",
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
);

View file

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