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

840 lines
25 KiB
TypeScript

import {
BUCKET_FILL_BACKGROUND_PICKS,
COLOR_PALETTE,
KEYS,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { pointFrom } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type { ExcalidrawLineElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { activeEyeDropperAtom } from "../components/EyeDropper";
import { editorJotaiStore } from "../editor-jotai";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import {
act,
fireEvent,
GlobalTestState,
render,
togglePopover,
waitFor,
} from "./test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
const selectBucketFill = () => {
act(() => {
h.app.setActiveTool({ type: "bucketfill" });
});
};
describe("bucket fill tool", () => {
beforeAll(() => {
// radix popovers (font family, color picker) need a ResizeObserver;
// jsdom has none (same stub as test-utils' togglePopover)
(global as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
});
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
const seedRectangle = () => {
const rect = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 120,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
// commit the owner as its own history increment so the bucket fill is a
// separate, independently-undoable step
act(() => {
h.app.updateScene({
elements: [rect],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
return rect;
};
it("uses the paint droplet as the bucket fill cursor hotspot", () => {
selectBucketFill();
expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch(
/^url\(data:image\/svg\+xml/,
);
expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch(
/\) 5 18, auto$/,
);
});
it("colorizes the cursor with the effective fill color and keeps it updated", () => {
act(() => {
API.setAppState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
});
});
selectBucketFill();
const fallbackColor = h.app.bucketFill.getBucketFillBackgroundColor(
COLOR_PALETTE.transparent,
);
const fallbackCursor = GlobalTestState.interactiveCanvas.style.cursor;
expect(decodeURIComponent(fallbackCursor)).toContain(
`fill="${fallbackColor}"`,
);
const nextColor = "#ffec99";
act(() => {
API.setAppState({ currentItemBackgroundColor: nextColor });
});
const updatedCursor = GlobalTestState.interactiveCanvas.style.cursor;
expect(updatedCursor).not.toBe(fallbackCursor);
expect(decodeURIComponent(updatedCursor)).toContain(`fill="${nextColor}"`);
});
it("does not rebuild or reapply an unchanged bucket fill cursor", () => {
selectBucketFill();
const encodeURIComponentSpy = vi.spyOn(globalThis, "encodeURIComponent");
const cursorSetterSpy = vi.spyOn(
GlobalTestState.interactiveCanvas.style,
"cursor",
"set",
);
h.app.cursor.applyForTool();
expect(encodeURIComponentSpy).not.toHaveBeenCalled();
expect(cursorSetterSpy).not.toHaveBeenCalled();
encodeURIComponentSpy.mockRestore();
cursorSetterSpy.mockRestore();
});
it("cycles the non-white top picks on repeated B presses", () => {
const colorPicks = BUCKET_FILL_BACKGROUND_PICKS.filter(
(color) => color !== COLOR_PALETTE.white,
);
act(() => {
API.setAppState({ currentItemBackgroundColor: COLOR_PALETTE.white });
});
Keyboard.keyPress(KEYS.B);
expect(h.state.activeTool.type).toBe("bucketfill");
expect(h.state.currentItemBackgroundColor).toBe(COLOR_PALETTE.white);
for (const color of colorPicks) {
Keyboard.keyPress(KEYS.B);
expect(h.state.currentItemBackgroundColor).toBe(color);
expect(
decodeURIComponent(GlobalTestState.interactiveCanvas.style.cursor),
).toContain(`fill="${color}"`);
}
Keyboard.keyPress(KEYS.B);
expect(h.state.currentItemBackgroundColor).toBe(colorPicks[0]);
});
it("temporarily activates the background eye dropper while Alt is held", async () => {
selectBucketFill();
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyDown(KEYS.ALT);
});
const eyeDropper = editorJotaiStore.get(activeEyeDropperAtom);
expect(eyeDropper).not.toBeNull();
expect(eyeDropper!.colorPickerType).toBe("elementBackground");
expect(eyeDropper!.keepOpenOnAlt).toBe(true);
expect(eyeDropper!.swapPreviewOnAlt).toBeUndefined();
await waitFor(() => {
expect(
GlobalTestState.renderResult.container.querySelector(
".excalidraw-eye-dropper-preview",
),
).not.toBeNull();
});
const color = "#ff8787";
act(() => {
eyeDropper!.onSelect(color, { altKey: true } as PointerEvent);
});
expect(h.state.currentItemBackgroundColor).toBe(color);
Keyboard.keyUp(KEYS.ALT);
expect(editorJotaiStore.get(activeEyeDropperAtom)).toBeNull();
await waitFor(() => {
expect(
GlobalTestState.renderResult.container.querySelector(
".excalidraw-eye-dropper-preview",
),
).toBeNull();
});
});
it("clears the bucket fill cursor immediately when Escape exits the tool", () => {
selectBucketFill();
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url(");
Keyboard.keyPress(KEYS.ESCAPE);
expect(h.state.activeTool.type).toBe("selection");
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe("");
});
it("creates a unselected line polygon with the current background color", () => {
const rect = seedRectangle();
act(() => {
// the bucket fill color is the shared shape background color
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.polygon).toBe(true);
expect(fill!.backgroundColor).toBe("#ffec99");
expect(fill!.strokeColor).toBe("transparent");
expect(fill!.fillStyle).toBe("solid");
// no metadata marker: fills are recognized by shape, not provenance
expect(fill!.customData).toBeUndefined();
// linear elements must be normalized: points[0] === [0, 0], and the
// polygon is explicitly closed
expect(fill!.points[0]).toEqual([0, 0]);
expect(fill!.points[fill!.points.length - 1]).toEqual([0, 0]);
// inserted immediately below (before) the owner in z-order
expect(h.elements[0].id).toBe(fill!.id);
expect(h.elements[1].id).toBe(rect.id);
// the fill is NOT selected and the tool stays active so the user can keep
// filling regions back-to-back
expect(h.state.selectedElementIds[fill!.id]).not.toBe(true);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("fires the public onPointerDown/onPointerUp callbacks", async () => {
// regression: the bucket branch used to early-return before the shared
// pointer lifecycle, silently skipping the public callback contract
const onPointerDown = vi.fn();
const onPointerUp = vi.fn();
await render(
<Excalidraw
handleKeyboardGlobally={true}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
/>,
);
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
expect(onPointerDown).toHaveBeenCalledTimes(1);
expect(onPointerUp).toHaveBeenCalledTimes(1);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("recovers from a lost pointer-up via the shared cleanup", () => {
// regression: the old bespoke once-listener only reset pointer state on
// a real pointer-up; the shared missing-pointer-up cleanup now covers
// lost ones (e.g. window blurred mid-click)
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.downAt(80, 70);
expect(h.state.cursorButton).toBe("down");
act(() => {
(h.app as any).maybeCleanupAfterMissingPointerUp(null);
});
expect(h.state.cursorButton).toBe("up");
// the click never completed, so the armed fill is discarded — an
// interrupted interaction must not leave a permanent edit
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
mouse.upAt(80, 70);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
});
it("dragging with the bucket tool neither selects nor creates extras", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.downAt(80, 70);
mouse.moveTo(120, 100); // drag well past any threshold
mouse.upAt(120, 100);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
expect(h.state.selectionElement).toBeNull();
expect(Object.keys(h.state.selectedElementIds)).toHaveLength(0);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("re-clicking a filled region does not stack a duplicate fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
mouse.clickAt(80, 70);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("re-clicking with a changed color restyles the existing fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
});
mouse.clickAt(80, 70);
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(1);
expect(fills[0].backgroundColor).toBe("#ffc9c9");
// the restyle is its own undoable step
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
const afterUndo = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(afterUndo).toHaveLength(1);
expect(afterUndo[0].backgroundColor).toBe("#ffec99");
});
it("restyles an orphaned fill even when no region can be derived", () => {
const rect = seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
// delete the rectangle: the fill's boundaries are gone, so no region
// can be derived around the click anymore
act(() => {
h.app.updateScene({
elements: h.elements.filter((el) => el.id !== rect.id),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
});
mouse.clickAt(80, 70); // on the orphaned paint
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(1);
expect(fills[0].backgroundColor).toBe("#ffc9c9");
});
it("clicking a since-subdivided part of a filled region creates a new fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70); // fill the whole rectangle
// split the region with a line drawn afterwards
const splitter = API.createElement({
type: "line",
x: 10,
y: 70,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(140, 0)],
});
act(() => {
h.app.updateScene({
elements: [...h.elements, splitter],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
mouse.clickAt(80, 45); // top half: a different (smaller) region now
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted && el.id !== splitter.id,
);
expect(fills).toHaveLength(2);
});
it("can fill multiple regions in a row without re-selecting the tool", () => {
const first = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 100,
height: 80,
roundness: null,
backgroundColor: "transparent",
});
const second = API.createElement({
type: "rectangle",
x: 200,
y: 20,
width: 100,
height: 80,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [first, second],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(70, 60); // inside first
expect(h.state.activeTool.type).toBe("bucketfill");
mouse.clickAt(250, 60); // inside second, tool still active
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(2);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("shows fill color, fill style and opacity in the panel when the tool is active", () => {
seedRectangle();
selectBucketFill();
const panel = document.querySelector(".selected-shape-actions");
expect(panel).not.toBeNull();
// the fill (background) color picker is shown...
expect(panel!.querySelector('[aria-label="Background"]')).not.toBeNull();
// ...along with the fill style + opacity controls for the created fills...
expect(panel!.querySelector('[data-testid="fill-hachure"]')).not.toBeNull();
expect(panel!.querySelector('[data-testid="opacity"]')).not.toBeNull();
// ...and nothing else from the generic shape panel (e.g. stroke)
expect(panel!.querySelector('[aria-label="Stroke"]')).toBeNull();
});
it("uses the current fill style and opacity for created fills", () => {
seedRectangle();
act(() => {
API.setAppState({
currentItemBackgroundColor: "#ffec99",
currentItemFillStyle: "hachure",
currentItemOpacity: 60,
});
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.fillStyle).toBe("hachure");
expect(fill!.opacity).toBe(60);
});
it("selects the tool with the b shortcut", () => {
expect(h.state.activeTool.type).toBe("selection");
Keyboard.keyPress(KEYS.B);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("opens the background color popup with the g shortcut", () => {
selectBucketFill();
Keyboard.keyPress(KEYS.G);
expect(h.state.openPopup).toBe("elementBackground");
});
it("swallows the hidden transparent entry's hotkey (q) inside the picker", () => {
// regression: the hidden transparent entry's `q` returned "unhandled",
// so the picker didn't stop propagation and App.onKeyDown toggled the
// global tool lock while the color dialog was open
seedRectangle();
selectBucketFill();
togglePopover("Background");
const picker = document.querySelector(".color-picker-content")!;
expect(picker).not.toBeNull();
expect(h.state.activeTool.locked).toBe(false);
const before = h.state.currentItemBackgroundColor;
// `q` maps to the hidden transparent swatch AND to the global tool-lock
// shortcut — inside the modal it must be dead, not leaked
fireEvent.keyDown(picker, { key: "q" });
expect(h.state.activeTool.locked).toBe(false);
expect(h.state.currentItemBackgroundColor).toBe(before);
// the other hotkeys keep working on their usual keys (w = white)
fireEvent.keyDown(picker, { key: "w" });
expect(h.state.currentItemBackgroundColor).not.toBe(before);
expect(h.state.currentItemBackgroundColor).not.toBe("transparent");
});
it("shift+f opens the font popup instead of switching to bucket fill", () => {
// regression: bucket fill was originally bound to shift+f, shadowing the
// long-standing "show fonts" shortcut for text elements
const text = API.createElement({ type: "text", x: 20, y: 20 });
act(() => {
h.app.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
API.setAppState({ selectedElementIds: { [text.id]: true } });
});
Keyboard.withModifierKeys({ shift: true }, () => {
Keyboard.keyPress("F");
});
expect(h.state.activeTool.type).not.toBe("bucketfill");
expect(h.state.openPopup).toBe("fontFamily");
});
it("falls back to green when the shared background color is transparent", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "transparent" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.backgroundColor).toBe("#b2f2bb");
// the shared appState value is left untouched
expect(h.state.currentItemBackgroundColor).toBe("transparent");
});
it("inserts the overlap fill below the lowest participating shape", () => {
const below = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
const owner = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [below, owner],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
// click the overlap lens (inside both rectangles)
mouse.clickAt(75, 75);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
// both rectangles bound the lens, so the fill goes below the lower one
expect(h.elements[0].id).toBe(fill.id);
expect(h.elements[1].id).toBe(below.id);
expect(h.elements[2].id).toBe(owner.id);
});
it("inserts the fill above a participant whose opaque background covers it", () => {
// lower rectangle is filled red: its background would hide a fill placed
// beneath it, so the bucket fill must go above it (but below the
// transparent owner, whose outline should stay visible)
const below = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
roundness: null,
backgroundColor: "#ff0000",
});
const owner = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [below, owner],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(75, 75);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
// order: below (red, covered) -> fill -> owner (transparent outline on top)
expect(h.elements[0].id).toBe(below.id);
expect(h.elements[1].id).toBe(fill.id);
expect(h.elements[2].id).toBe(owner.id);
});
it("does nothing when clicking empty canvas", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(400, 400);
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("does not fill on right-click or middle-click", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
for (const button of [1, 2]) {
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button,
pointerId: 1,
pointerType: "mouse",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button,
pointerId: 1,
pointerType: "mouse",
});
}
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("a normal touch tap fills once, on release", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// the fill is only armed while the finger is down — nothing committed yet
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
pointerId: 1,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("a second touch pointer aborts the fill (pinch/pan intent)", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// a second finger lands while the first is still down
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 100,
clientY: 90,
button: 0,
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
// the user meant to pinch/pan, not to fill — no permanent edit
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
});
it("pointercancel aborts the fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// the browser takes the pointer over (scroll, palm rejection) — no
// pointerup will follow
fireEvent.pointerCancel(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
// and the next tap fills normally
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
pointerId: 2,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("does not fill in view mode", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
act(() => {
API.setAppState({ viewModeEnabled: true });
});
mouse.clickAt(80, 70);
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("undo removes the generated fill in one step", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(
h.elements.find((el) => el.id === fill.id && !el.isDeleted),
).toBeUndefined();
// the owner rectangle survives
expect(
h.elements.filter((el) => el.type === "rectangle" && !el.isDeleted),
).toHaveLength(1);
});
});