snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
860
packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
Normal file
860
packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import { render, unmountComponent } from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
/**
|
||||
* An arrow whose endpoint sits at (endX, endY), approached from (startX, startY).
|
||||
*/
|
||||
const createArrow = (
|
||||
id: string,
|
||||
[startX, startY]: [number, number],
|
||||
[endX, endY]: [number, number],
|
||||
overrides: Partial<ExcalidrawArrowElement> = {},
|
||||
) =>
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id,
|
||||
x: startX,
|
||||
y: startY,
|
||||
width: Math.abs(endX - startX),
|
||||
height: Math.abs(endY - startY),
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(endX - startX, endY - startY),
|
||||
],
|
||||
...overrides,
|
||||
}) as ExcalidrawArrowElement;
|
||||
|
||||
const getText = () =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawTextElement => element.type === "text",
|
||||
)!;
|
||||
|
||||
const getArrow = (id: string) =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawArrowElement => element.id === id,
|
||||
)!;
|
||||
|
||||
/** global coordinates of an arrow's start/end point */
|
||||
const endpointOf = (arrow: ExcalidrawArrowElement, which: "start" | "end") => {
|
||||
const point = arrow.points[which === "start" ? 0 : arrow.points.length - 1];
|
||||
return [arrow.x + point[0], arrow.y + point[1]] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* `normalizeFixedPoint` nudges exact side midpoints off 0.5 by 1e-4 to keep
|
||||
* headings unambiguous, which shows up as a sub-pixel offset scaled by the
|
||||
* text size.
|
||||
*/
|
||||
const expectPointsClose = (
|
||||
actual: readonly [number, number],
|
||||
expected: readonly [number, number],
|
||||
) => {
|
||||
expect(actual[0]).toBeCloseTo(expected[0], 1);
|
||||
expect(actual[1]).toBeCloseTo(expected[1], 1);
|
||||
};
|
||||
|
||||
/** creates a text bound to the arrow endpoint at (x, y) via the text tool */
|
||||
const bindTextAt = async (x: number, y: number, text: string) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.clickAt(x, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
describe("binding text to an arrow endpoint", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally />);
|
||||
});
|
||||
|
||||
describe("hover affordance", () => {
|
||||
it("highlights a free arrow endpoint while the text tool is active", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
// away from the arrow entirely
|
||||
mouse.moveTo(400, 400);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the label midpoint when hovering the arrow itself", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
// mid-arrow, clear of both endpoints
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the endpoint over the label midpoint", () => {
|
||||
// short enough that the endpoint and the midpoint are close together
|
||||
API.setElements([createArrow("arrow", [100, 160], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor?.anchor).toBe("end");
|
||||
});
|
||||
|
||||
it("does not offer a label midpoint on an arrow that already has one", () => {
|
||||
const arrow = createArrow("arrow", [100, 300], [100, 100]);
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
containerId: arrow.id,
|
||||
text: "existing",
|
||||
});
|
||||
API.setElements([
|
||||
{ ...arrow, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
// a click only becomes a label when it snaps to the arrow's center, so
|
||||
// merely being inside the arrow's bounding box must not raise the anchor
|
||||
it("does not offer a label midpoint away from the arrow's center", () => {
|
||||
// bounding box spans (100,400)-(400,580); label centers on (250,580)
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
height: 180,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(150, 180),
|
||||
pointFrom<LocalPoint>(300, 0),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
// inside the bounding box but nowhere near the stroke or the midpoint
|
||||
mouse.moveTo(150, 560);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// on the stroke, but far from the midpoint
|
||||
mouse.moveTo(175, 490);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
mouse.moveTo(250, 580);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not highlight endpoints for other tools", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the highlight on escape", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
// escape reverts the tool via the action's appState rather than
|
||||
// `setActiveTool`, so the highlight has to be cleared there too
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding toggle (ctrl/cmd)", () => {
|
||||
it("does not offer an endpoint while binding is disabled", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("still offers the label anchor — a label is not an arrow binding", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
// the highlight is maintained at write time — the renderer draws whatever
|
||||
// `hoveredArrowTextAnchor` says — so the toggle itself has to refresh the
|
||||
// anchor: there is no pointermove to do it
|
||||
it("clears the highlight the moment ctrl is pressed, restores on release", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyDown("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
Keyboard.keyUp("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops plain text instead of binding the endpoint", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
await bindTextAt(100, 100, "plain");
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
expect(getText().containerId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not highlight an endpoint that is already bound", () => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
endBinding: {
|
||||
elementId: "other",
|
||||
fixedPoint: [0.5001, 0.5001],
|
||||
mode: "orbit",
|
||||
},
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "other",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// ...but the arrow can still take a label at its midpoint
|
||||
mouse.moveTo(100, 200);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the highlight when switching away from the text tool", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
UI.clickTool("selection");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placement strategy", () => {
|
||||
// the side of the text the arrow attaches to is the one it already points
|
||||
// at, so the text lands beyond the tip and the arrow needn't move
|
||||
it.each([
|
||||
{
|
||||
name: "arrow pointing up binds the text's bottom side midpoint",
|
||||
from: [100, 300] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [0.5001, 1],
|
||||
textAlign: "center",
|
||||
verticalAlign: "bottom",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing right binds the text's left side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [300, 100] as [number, number],
|
||||
fixedPoint: [0, 0.5001],
|
||||
textAlign: "left",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing down binds the text's top side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [100, 300] as [number, number],
|
||||
fixedPoint: [0.5001, 0],
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing left binds the text's right side midpoint",
|
||||
from: [300, 100] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [1, 0.5001],
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
])("$name", async ({ from, to, fixedPoint, textAlign, verticalAlign }) => {
|
||||
API.setElements([createArrow("arrow", from, to)]);
|
||||
|
||||
await bindTextAt(to[0], to[1], "label");
|
||||
|
||||
const text = getText();
|
||||
expect(getArrow("arrow").endBinding).toEqual({
|
||||
elementId: text.id,
|
||||
fixedPoint,
|
||||
mode: "orbit",
|
||||
});
|
||||
expect(text.textAlign).toBe(textAlign);
|
||||
expect(text.verticalAlign).toBe(verticalAlign);
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(text.boundElements).toEqual([{ id: "arrow", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("binds the start point when that is the endpoint under the cursor", async () => {
|
||||
// arrow drawn away from (100, 100) to the right
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
await bindTextAt(100, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
const arrow = getArrow("arrow");
|
||||
expect(arrow.startBinding?.elementId).toBe(text.id);
|
||||
expect(arrow.endBinding).toBeNull();
|
||||
// the arrow leaves the tip heading right, so the text sits to its left
|
||||
expect(arrow.startBinding?.fixedPoint).toEqual([1, 0.5001]);
|
||||
expect(text.textAlign).toBe("right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the arrow stays put", () => {
|
||||
it("does not move the arrow when the text is created and typed into", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "a label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
// growing the text (wider + a second line) must not drag the tip
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
it("keeps the bound side midpoint pinned as the text grows", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "short");
|
||||
const bottomMidOf = (text: ExcalidrawTextElement) =>
|
||||
[text.x + text.width / 2, text.y + text.height] as const;
|
||||
const anchorBefore = bottomMidOf(getText());
|
||||
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
|
||||
const text = getText();
|
||||
expect(text.width).toBeGreaterThan(0);
|
||||
expectPointsClose(bottomMidOf(text), anchorBefore);
|
||||
});
|
||||
|
||||
// An orbit binding resolves the endpoint by intersecting the arrow's own
|
||||
// line with the gap-grown text outline. Anchoring along the bound side's
|
||||
// normal instead used to leave the tip `gap * tan(angle)` off to the side
|
||||
// — invisible on orthogonal arrows, several px on diagonal ones, and only
|
||||
// once the first keystroke triggered `updateBoundElements`.
|
||||
it.each([
|
||||
{ name: "45°", from: [100, 100] as [number, number] },
|
||||
{ name: "steep", from: [160, 100] as [number, number] },
|
||||
{ name: "shallow", from: [100, 160] as [number, number] },
|
||||
])("does not shift a diagonal arrow's tip ($name)", async ({ from }) => {
|
||||
API.setElements([createArrow("arrow", from, [300, 300])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(300, 300, "x");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the binding gap is derived from the *bind target*, so anchoring with the
|
||||
// arrow's stroke width offsets the tip by half the difference between the
|
||||
// two the moment `updateBoundElements` first runs
|
||||
it.each([
|
||||
{ arrowStroke: 2, textStroke: "bold" as const },
|
||||
{ arrowStroke: 4, textStroke: "thin" as const },
|
||||
{ arrowStroke: 1, textStroke: "medium" as const },
|
||||
])(
|
||||
"does not shift when the text's stroke width differs (arrow $arrowStroke vs $textStroke)",
|
||||
async ({ arrowStroke, textStroke }) => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
strokeWidth: arrowStroke,
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ currentItemStrokeWidthKey: textStroke });
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "x");
|
||||
expect(getText().strokeWidth).not.toBe(arrowStroke);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("drag-sizing", () => {
|
||||
/** press at the endpoint, drag to `toX`, release */
|
||||
const dragTextAt = async (
|
||||
[x, y]: [number, number],
|
||||
toX: number,
|
||||
text: string,
|
||||
) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.downAt(x, y);
|
||||
// several moves so the drag registers
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
mouse.moveTo(x + ((toX - x) * i) / 4, y);
|
||||
}
|
||||
mouse.upAt(toX, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
it("sets a fixed width without moving the anchor (left-bound)", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// arrow points right, so the text hangs off its left edge at x = tip+gap
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("left");
|
||||
// left edge is the anchor and must be untouched by the drag
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
expect(text.width).toBeCloseTo(214, 0);
|
||||
});
|
||||
|
||||
it("grows leftwards for a right-bound text, pinning its right edge", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("right");
|
||||
// the right edge is the anchor
|
||||
expect(text.x + text.width).toBeCloseTo(294, 0);
|
||||
});
|
||||
|
||||
it("keeps a centred text centred on the anchor", async () => {
|
||||
API.setElements([createArrow("arrow", [300, 100], [300, 300])]);
|
||||
|
||||
await dragTextAt([300, 300], 430, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("center");
|
||||
expect(text.x + text.width / 2).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it("does not let the drag run back over the arrow", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// drag towards the arrow, i.e. the wrong side of the anchor
|
||||
await dragTextAt([300, 100], 150, "label");
|
||||
|
||||
const text = getText();
|
||||
// treated as no drag at all — the text keeps autogrowing
|
||||
expect(text.autoResize).toBe(true);
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
});
|
||||
|
||||
// the autoResize handle unwraps the text back to one line; anchoring that
|
||||
// resize by alignment is what keeps the arrow still
|
||||
it("does not move the arrow when the text is un-wrapped", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
// grows leftwards off the text's right edge
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
Keyboard.exitTextEditor(await getTextEditor());
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
const rightEdge = text.x + text.width;
|
||||
|
||||
API.setAppState({ selectedElementIds: { [text.id]: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const resized = getText();
|
||||
expect(resized.autoResize).toBe(true);
|
||||
expect(resized.x + resized.width).toBeCloseTo(rightEdge, 4);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the anchor can only pin one point, so every other arrow bound to the text
|
||||
// has to be re-routed to the resized box
|
||||
it("re-routes the other arrows bound to the text", () => {
|
||||
const wrapped = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "text",
|
||||
x: 200,
|
||||
y: 280,
|
||||
width: 300,
|
||||
height: 50,
|
||||
text: "this is it my friends\nald aksdl askdlasdk",
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
boundElements: [{ id: "fromTop", type: "arrow" }],
|
||||
}),
|
||||
// a wrapped, fixed-width text: `originalText` is what the resize
|
||||
// measures against once it unwraps
|
||||
originalText: "this is it my friends ald aksdl askdlasdk",
|
||||
autoResize: false,
|
||||
};
|
||||
// bound to the text's top side, i.e. not the side the alignment pins
|
||||
const fromTop = createArrow("fromTop", [350, 120], [350, 260], {
|
||||
endBinding: {
|
||||
elementId: "text",
|
||||
fixedPoint: [0.5001, 0],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
API.setElements([wrapped, fromTop]);
|
||||
const tipBefore = endpointOf(getArrow("fromTop"), "end");
|
||||
|
||||
API.setAppState({ selectedElementIds: { text: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const text = getText();
|
||||
// the box really did move out from under the arrow
|
||||
expect(text.y).not.toBeCloseTo(280, 0);
|
||||
|
||||
const tipAfter = endpointOf(getArrow("fromTop"), "end");
|
||||
expect(tipAfter).not.toEqual(tipBefore);
|
||||
// still just clear of the top edge it is bound to...
|
||||
expect(tipAfter[1]).toBeLessThan(text.y);
|
||||
// ...and it chased the box's new centre. Not asserting the centre
|
||||
// exactly: the arrow is diagonal now, so the outline intersection sits
|
||||
// slightly to one side of the fixed point.
|
||||
const centre = text.x + text.width / 2;
|
||||
expect(Math.abs(tipAfter[0] - centre)).toBeLessThan(
|
||||
Math.abs(tipBefore[0] - centre),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the arrow endpoint where it was", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occlusion", () => {
|
||||
const coveringRect = (overrides: Record<string, unknown> = {}) =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 120,
|
||||
backgroundColor: "#ffc9c9",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("does not reach through an element stacked above the endpoint", async () => {
|
||||
// rect is drawn after (above) the arrow and covers its tip at (100, 100)
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect(),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click belongs to the rect, as it would without the arrow there
|
||||
await bindTextAt(100, 100, "label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getText().containerId).toBe("rect");
|
||||
});
|
||||
|
||||
it("still offers the endpoint when the arrow is stacked above", () => {
|
||||
API.setElements([
|
||||
coveringRect(),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("is not occluded by a transparent element above it", () => {
|
||||
// transparent shapes are only hit on their stroke, so they don't cover
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect({ backgroundColor: "transparent" }),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
// generic text-creation history behaviour lives in textWysiwyg.test.tsx;
|
||||
// these cover what the endpoint binding adds on top
|
||||
it("removes the text and its binding in a single undo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
expect(textId).toBeDefined();
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(h.elements.find((el) => el.id === textId)?.isDeleted).toBe(true);
|
||||
expect(arrow().isDeleted).toBe(false);
|
||||
expect(arrow().endBinding).toBeNull();
|
||||
|
||||
// and the endpoint is immediately reusable
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: arrowId,
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the text and its binding on redo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
|
||||
Keyboard.undo();
|
||||
Keyboard.redo();
|
||||
|
||||
const text = h.elements.find((el) => el.id === textId)!;
|
||||
expect(text.isDeleted).toBe(false);
|
||||
expect(isTextElement(text) && text.text).toBe("bound");
|
||||
expect(arrow().endBinding?.elementId).toBe(textId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction with existing text-tool behavior", () => {
|
||||
it("adds a label to the arrow when clicking away from the endpoints", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [500, 100])]);
|
||||
|
||||
await bindTextAt(300, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
// mid-arrow click is still the "arrow label" gesture
|
||||
expect(text.containerId).toBe("arrow");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
});
|
||||
|
||||
// the text tool edits before it creates: a text that is the top-most hit
|
||||
// under the cursor takes the click, so the endpoint must not be offered
|
||||
// over it. Occlusion alone can't provide this — its hit test skips bound
|
||||
// texts.
|
||||
it("yields to a container-bound label overlapping the endpoint", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 60,
|
||||
y: 60,
|
||||
width: 80,
|
||||
height: 80,
|
||||
// transparent, so the rect itself doesn't occlude the endpoint
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
containerId: rect.id,
|
||||
text: "under",
|
||||
x: 75,
|
||||
y: 87.5,
|
||||
width: 50,
|
||||
height: 25,
|
||||
});
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
{ ...rect, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click edits the label rather than binding the endpoint under it
|
||||
mouse.clickAt(100, 100);
|
||||
await getTextEditor();
|
||||
expect(h.state.editingTextElement?.id).toBe("label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting the selected one", async () => {
|
||||
// with a text selected, a text-tool click normally edits that text no
|
||||
// matter where the click lands — but a click on an endpoint promises a
|
||||
// new label there, not a binding to whatever happened to be selected
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "faraway",
|
||||
x: 400,
|
||||
y: 400,
|
||||
text: "faraway",
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ selectedElementIds: { faraway: true } });
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("faraway");
|
||||
|
||||
const faraway = h.elements.find((el) => el.id === "faraway")!;
|
||||
expect(isTextElement(faraway) && faraway.text).toBe("faraway");
|
||||
expect(faraway.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting one lying beyond the tip", async () => {
|
||||
// the text covers the anchor region past the tip, but the arrow is the
|
||||
// top-most hit at the endpoint itself — the endpoint stays available,
|
||||
// and the new label must not be conflated with the text underneath
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "under",
|
||||
x: 75,
|
||||
y: 55,
|
||||
width: 50,
|
||||
height: 50,
|
||||
text: "under",
|
||||
}),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("under");
|
||||
|
||||
const under = h.elements.find((el) => el.id === "under")!;
|
||||
expect(isTextElement(under) && under.text).toBe("under");
|
||||
expect(under.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes the binding when the text is submitted empty", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "temporary");
|
||||
expect(getArrow("arrow").endBinding).not.toBeNull();
|
||||
|
||||
updateTextEditor(editor, "");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user