Files
upstream-excalidraw/packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
2026-09-16 05:31:04 +05:00

861 lines
29 KiB
TypeScript

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,
);
});
});
});