snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,653 @@
import { reseed } from "@excalidraw/common";
import {
isElbowArrow,
projectFixedPointOntoDiagonal,
} from "@excalidraw/element";
import { pointFrom } from "@excalidraw/math";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
} from "@excalidraw/element/types";
import { actionToggleArrowBinding } from "../actions/actionToggleArrowBinding";
import { Excalidraw, sceneCoordsToViewportCoords } from "../index";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
render,
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
waitFor,
unmountComponent,
} from "./test-utils";
unmountComponent();
const { h } = window;
const mouse = new Pointer("mouse");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Fire Ctrl (or Meta on Mac) keydown on document. */
const ctrlKeyDown = (extra: Partial<KeyboardEventInit> = {}) =>
fireEvent.keyDown(document, {
key: "Control",
code: "ControlLeft",
ctrlKey: true,
repeat: false,
...extra,
});
/** Fire Ctrl (or Meta on Mac) keyup on document (ctrlKey is false on keyup). */
const ctrlKeyUp = () =>
fireEvent.keyUp(document, {
key: "Control",
code: "ControlLeft",
ctrlKey: false,
});
// ---------------------------------------------------------------------------
describe("Arrow binding – non-default case (bindingPreference: disabled)", () => {
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.state.width = 1920;
h.state.height = 1080;
});
afterEach(() => {
mouse.reset();
});
// -------------------------------------------------------------------------
// actionToggleArrowBinding
// -------------------------------------------------------------------------
describe("actionToggleArrowBinding", () => {
it("isBindingEnabled defaults to true", () => {
expect(h.state.isBindingEnabled).toBe(true);
});
it("checked() reflects the current bindingPreference value", () => {
expect(actionToggleArrowBinding.checked!(h.state)).toBe(true);
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
expect(actionToggleArrowBinding.checked!(h.state)).toBe(false);
});
it("executing the action toggles binding from enabled → disabled", () => {
expect(h.state.isBindingEnabled).toBe(true);
API.executeAction(actionToggleArrowBinding);
expect(h.state.isBindingEnabled).toBe(false);
expect(h.state.bindingPreference).toBe("disabled");
});
it("executing the action toggles binding from disabled → enabled", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
API.executeAction(actionToggleArrowBinding);
expect(h.state.isBindingEnabled).toBe(true);
expect(h.state.bindingPreference).toBe("enabled");
});
it("checked() returns false after action disables binding", () => {
API.executeAction(actionToggleArrowBinding); // true → false
expect(actionToggleArrowBinding.checked!(h.state)).toBe(false);
});
});
// -------------------------------------------------------------------------
// Arrow does not bind when isBindingEnabled is false
// -------------------------------------------------------------------------
describe("Arrow drawing with binding disabled", () => {
/**
* Baseline: verify binding IS created with binding enabled so we know the
* spatial setup is correct.
*/
it("arrow startBinding is set when binding is enabled", async () => {
API.setElements([
API.createElement({
type: "rectangle",
id: "baselineRect",
x: 100,
y: 100,
width: 400,
height: 200,
}),
]);
expect(h.state.isBindingEnabled).toBe(true);
UI.clickTool("arrow");
// Start inside the rectangle so startBinding can be created
mouse.down(200, 200);
mouse.up(700, 200);
await waitFor(() => {
const arrow = h.elements.find(
(el): el is ExcalidrawArrowElement => el.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.startBinding).not.toBeNull();
expect(arrow!.startBinding!.elementId).toBe("baselineRect");
});
});
it("arrow has no startBinding when binding is disabled", async () => {
API.setElements([
API.createElement({
type: "rectangle",
id: "rect1",
x: 100,
y: 100,
width: 400,
height: 200,
}),
]);
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
UI.clickTool("arrow");
// Start inside the rectangle – binding is off, so no startBinding
mouse.down(200, 200);
mouse.up(700, 200);
await waitFor(() => {
const arrow = h.elements.find(
(el): el is ExcalidrawArrowElement => el.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.startBinding).toBeNull();
});
});
it("arrow has no endBinding when binding is disabled", async () => {
API.setElements([
API.createElement({
type: "rectangle",
id: "rect2",
x: 500,
y: 100,
width: 300,
height: 200,
}),
]);
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
UI.clickTool("arrow");
// End inside the target rectangle – binding off -> no endBinding
mouse.down(100, 200);
mouse.up(600, 200);
await waitFor(() => {
const arrow = h.elements.find(
(el): el is ExcalidrawArrowElement => el.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.endBinding).toBeNull();
});
});
it("re-enabling binding via action causes new arrows to bind again", async () => {
API.setElements([
API.createElement({
type: "rectangle",
id: "rect3",
x: 100,
y: 100,
width: 400,
height: 200,
}),
]);
// Disable then re-enable binding
API.executeAction(actionToggleArrowBinding); // true -> false
API.executeAction(actionToggleArrowBinding); // false -> true
expect(h.state.isBindingEnabled).toBe(true);
UI.clickTool("arrow");
mouse.down(200, 200);
mouse.up(700, 200);
await waitFor(() => {
const arrow = h.elements.find(
(el): el is ExcalidrawArrowElement => el.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.startBinding).not.toBeNull();
});
});
it("elbow arrow does not snap to rectangle when binding is disabled", async () => {
const rect = API.createElement({
type: "rectangle",
id: "rect-elbow-no-snap",
x: 900,
y: 500,
width: 200,
height: 120,
}) as ExcalidrawBindableElement;
API.setElements([rect]);
// Turn off arrow binding
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
isMidpointSnappingEnabled: false,
});
expect(h.state.isBindingEnabled).toBe(false);
// Create the elbow arrow
UI.clickTool("arrow");
API.setAppState({ currentItemArrowType: "elbow" });
mouse.downAt(700, 400);
mouse.upAt(760, 460);
let arrow: ExcalidrawArrowElement;
await waitFor(() => {
const maybeArrow = h.elements.find(isElbowArrow);
expect(maybeArrow).toBeDefined();
arrow = maybeArrow!;
});
// Move the elbow arrow
UI.clickTool("selection");
const insideRectanglePoint = pointFrom<GlobalPoint>(1010, 570);
const originalArrowEndGlobal = pointFrom<GlobalPoint>(
arrow!.x + arrow!.points[arrow!.points.length - 1][0],
arrow!.y + arrow!.points[arrow!.points.length - 1][1],
);
const originalArrowEndViewport = sceneCoordsToViewportCoords(
{
sceneX: originalArrowEndGlobal[0],
sceneY: originalArrowEndGlobal[1],
},
h.state,
);
const insideRectangleViewport = sceneCoordsToViewportCoords(
{
sceneX: insideRectanglePoint[0],
sceneY: insideRectanglePoint[1],
},
h.state,
);
// End point dragged inside the rectangle; with snapping enabled this
// would snap to the rectangle outline.
mouse.moveTo(originalArrowEndViewport.x, originalArrowEndViewport.y);
mouse.down();
mouse.moveTo(insideRectangleViewport.x, insideRectangleViewport.y);
mouse.up();
const updatedEnd = arrow!.points[arrow!.points.length - 1];
const updatedEndGlobal = pointFrom<GlobalPoint>(
arrow!.x + updatedEnd[0],
arrow!.y + updatedEnd[1],
);
expect(arrow!.startBinding).toBeNull();
expect(arrow!.endBinding).toBeNull();
expect(updatedEndGlobal).not.toEqual(originalArrowEndGlobal);
expect(updatedEndGlobal).toEqual(insideRectanglePoint);
});
});
// -------------------------------------------------------------------------
// Arrow does snap to midpoint when isMidpointSnappingEnabled is true
// -------------------------------------------------------------------------
describe("Midpoint snapping", () => {
it("does not snap to midpoint when midpoint snapping is turned off", () => {
const rect = API.createElement({
type: "rectangle",
id: "rectNoMidSnap",
x: 100,
y: 100,
width: 400,
height: 200,
}) as ExcalidrawBindableElement;
const arrow = API.createElement({
type: "arrow",
x: 0,
y: 250,
width: 502,
height: -48,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(502, -48)],
}) as ExcalidrawArrowElement;
const elementsMap = new Map<string, ExcalidrawElement>([
[rect.id, rect],
[arrow.id, arrow],
]);
const point = pointFrom<GlobalPoint>(502, 202);
const snappedWithMidpoint = projectFixedPointOntoDiagonal(
arrow,
point,
rect,
"end",
elementsMap,
h.state.zoom,
true,
);
const snappedWithoutMidpoint = projectFixedPointOntoDiagonal(
arrow,
point,
rect,
"end",
elementsMap,
h.state.zoom,
false,
);
expect(snappedWithMidpoint).toEqual([500, 200]);
expect(snappedWithoutMidpoint).not.toEqual([500, 200]);
});
it("does not snap an angle-locked elbow binding to midpoint on finalize", async () => {
const rect = API.createElement({
type: "rectangle",
id: "rect-elbow-angle-lock",
x: 600,
y: 300,
width: 200,
height: 200,
}) as ExcalidrawBindableElement;
API.setElements([rect]);
API.setAppState({
currentItemArrowType: "elbow",
gridModeEnabled: false,
isMidpointSnappingEnabled: true,
});
const start = sceneCoordsToViewportCoords(
{ sceneX: 480, sceneY: 392 },
h.state,
);
const target = sceneCoordsToViewportCoords(
{ sceneX: 596, sceneY: 397 },
h.state,
);
UI.clickTool("arrow");
mouse.downAt(start.x, start.y);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(target.x, target.y);
mouse.upAt();
});
await waitFor(() => {
const arrow = h.elements.find(isElbowArrow);
expect(arrow).toBeDefined();
expect(arrow!.endBinding?.elementId).toBe(rect.id);
const endY = arrow!.y + arrow!.points.at(-1)![1];
expect(endY).toBeCloseTo(397, 1);
});
});
});
describe("Grid binding interactions", () => {
it("does not snap angle-locked binding to grid when grid mode is disabled", async () => {
const rect = API.createElement({
type: "rectangle",
id: "rect-angle-lock-no-grid",
x: 600,
y: 300,
width: 200,
height: 200,
}) as ExcalidrawBindableElement;
API.setElements([rect]);
API.setAppState({ gridModeEnabled: false, gridSize: 20 });
const start = sceneCoordsToViewportCoords(
{ sceneX: 300, sceneY: 333 },
h.state,
);
const target = sceneCoordsToViewportCoords(
{ sceneX: 596, sceneY: 347 },
h.state,
);
UI.clickTool("arrow");
mouse.downAt(start.x, start.y);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(target.x, target.y);
mouse.upAt();
});
await waitFor(() => {
const arrow = h.elements.find(
(element): element is ExcalidrawArrowElement =>
element.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.endBinding?.elementId).toBe(rect.id);
const startY = arrow!.y + arrow!.points[0][1];
const endY = arrow!.y + arrow!.points.at(-1)![1];
expect(endY).toBeCloseTo(startY);
});
});
it("uses the incoming direction to choose the grid axis for rotated bindables", async () => {
const ellipse = API.createElement({
type: "ellipse",
id: "ellipse-rotated-grid-binding",
x: 610,
y: 300,
width: 240,
height: 120,
angle: Math.PI / 3,
}) as ExcalidrawBindableElement;
API.setElements([ellipse]);
API.setAppState({ gridModeEnabled: true, gridSize: 20 });
const start = sceneCoordsToViewportCoords(
{ sceneX: 400, sceneY: 360 },
h.state,
);
const target = sceneCoordsToViewportCoords(
{ sceneX: 658, sceneY: 360 },
h.state,
);
UI.clickTool("arrow");
mouse.downAt(start.x, start.y);
mouse.moveTo(target.x, target.y);
mouse.upAt();
await waitFor(() => {
const arrow = h.elements.find(
(element): element is ExcalidrawArrowElement =>
element.type === "arrow",
);
expect(arrow).toBeDefined();
expect(arrow!.endBinding?.elementId).toBe(ellipse.id);
const startY = arrow!.y + arrow!.points[0][1];
const endY = arrow!.y + arrow!.points.at(-1)![1];
expect(endY).toBeCloseTo(startY, 1);
});
});
});
// -------------------------------------------------------------------------
// Ctrl / Cmd key toggle
// -------------------------------------------------------------------------
describe("Ctrl key toggle when binding preference is disabled", () => {
it("Ctrl keydown temporarily enables binding when preference is disabled", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(true);
});
it("Ctrl keyup restores isBindingEnabled to false after the temporary toggle", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(false);
});
it("full round-trip: off → Ctrl down → on → Ctrl up → off", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(false);
});
it("full round-trip can be repeated after Ctrl release resets state", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
// First cycle
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(false);
// Second cycle
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(false);
});
});
describe("Ctrl key toggle when binding starts ON", () => {
it("Ctrl keydown temporarily disables binding when it was on", () => {
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(false);
});
it("Ctrl keyup restores isBindingEnabled to true after the temporary toggle", () => {
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(false);
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(true);
});
});
// -------------------------------------------------------------------------
// event.repeat guard
// -------------------------------------------------------------------------
describe("event.repeat guard", () => {
it("Ctrl keydown with repeat=true does not toggle binding (preference: disabled)", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
});
ctrlKeyDown({ repeat: true });
// Must remain off – repeat events are ignored
expect(h.state.isBindingEnabled).toBe(false);
});
it("Ctrl keydown with repeat=true does not toggle binding (default: on)", () => {
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyDown({ repeat: true });
expect(h.state.isBindingEnabled).toBe(true);
});
it("pressing another key while Ctrl held does not change binding state", () => {
// Start ON, first Ctrl keydown flips to false
expect(h.state.isBindingEnabled).toBe(true);
ctrlKeyDown();
expect(h.state.isBindingEnabled).toBe(false);
// A second keydown with Ctrl (e.g. pressing another key while Ctrl held)
// should leave state unchanged
fireEvent.keyDown(document, {
key: "z",
ctrlKey: true,
repeat: false,
});
expect(h.state.isBindingEnabled).toBe(false);
// Ctrl keyup restores to preference value
ctrlKeyUp();
expect(h.state.isBindingEnabled).toBe(true);
});
});
// -------------------------------------------------------------------------
// View-mode guard
// -------------------------------------------------------------------------
describe("View-mode guard", () => {
it("Ctrl keydown in viewMode does not toggle isBindingEnabled", () => {
API.setAppState({
bindingPreference: "disabled",
isBindingEnabled: false,
viewModeEnabled: true,
});
ctrlKeyDown();
// Handler returns early in viewMode — state must stay false
expect(h.state.isBindingEnabled).toBe(false);
});
});
});