import React from "react";
import rough from "roughjs/bin/rough";
import * as Element from "@excalidraw/element";
import {
BOUND_TEXT_PADDING,
DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY,
} from "@excalidraw/common";
import { pointFrom, type LocalPoint } from "@excalidraw/math";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { getDefaultAppState } from "../appState";
import * as StaticScene from "../renderer/staticScene";
import { Renderer } from "../scene/Renderer";
import { exportToSvg } from "../scene/export";
import { getNormalizedZoom } from "../scene";
import { getElementRenderOffsets } from "../renderOverrides";
import { API } from "./helpers/api";
import {
act,
GlobalTestState,
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
waitFor,
} from "./test-utils";
import type {
AppState,
ElementRenderOffsets,
ElementRenderOverride,
ElementRenderOverrides,
} from "../types";
import type {
RenderableElementsMap,
StaticCanvasRenderConfig,
} from "../scene/types";
const { h } = window;
afterEach(() => {
vi.restoreAllMocks();
restoreOriginalGetBoundingClientRect();
});
describe("setElementRenderOverrides", () => {
const setup = async () => {
const element = API.createElement({ type: "rectangle", opacity: 70 });
const onChange = vi.fn();
const mounted = await render(
,
);
const rendering = vi.spyOn(StaticScene, "renderStaticScene");
const submit = (snapshot: ElementRenderOverrides | null) => {
act(() => h.app.api.setElementRenderOverrides(snapshot));
// Equivalent snapshots need not render, but the published state remains
// available for checking snapshot ownership and identity.
// eslint-disable-next-line dot-notation -- inspect private state without triggering a render
return h.app["elementRenderOverrides"];
};
onChange.mockClear();
return { element: h.elements[0], onChange, rendering, submit, ...mounted };
};
it("copies and replaces the complete snapshot, including omitted fields", async () => {
const { element, submit } = await setup();
const value = { opacity: 25, offset: { x: 100, y: 20 } };
const input = new Map([[element.id, value]]);
const published = submit(input);
value.opacity = 99;
value.offset.x = 999;
input.clear();
expect(published.get(element.id)).toEqual({
opacity: 25,
offset: { x: 100, y: 20 },
});
const next = submit(new Map([[element.id, { offset: { x: 10, y: 0 } }]]));
expect(
Element.resolveElementRenderState(
element,
h.app.scene.getNonDeletedElementsMap(),
{
elementRenderOverrides: next,
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
},
).opacity,
).toBe(0.7);
expect(submit(new Map())).toHaveLength(0);
expect(submit(null)).toHaveLength(0);
});
it("rejects an invalid snapshot atomically and clamps finite opacity", async () => {
const { element, submit, rendering } = await setup();
const previous = submit(new Map([[element.id, { opacity: 200 }]]));
expect(previous.get(element.id)?.opacity).toBe(100);
rendering.mockClear();
expect(() =>
submit(
new Map([
[element.id, { opacity: 0 }],
["unknown", { offset: { x: Number.NaN, y: 0 } }],
]),
),
).toThrow(TypeError);
expect(rendering).not.toHaveBeenCalled();
act(() => h.app.api.updateScene({ appState: { scrollX: 1 } }));
expect(
rendering.mock.lastCall![0].renderConfig.elementRenderOverrides,
).toBe(previous);
});
it("drops empty entries and rejects null offsets without replacing the snapshot", async () => {
const { element, submit, rendering } = await setup();
const snapshot = submit(new Map([[element.id, {}]]));
expect(snapshot.size).toBe(0);
rendering.mockClear();
expect(() =>
submit(
new Map([
[element.id, { offset: null } as unknown as ElementRenderOverride],
]),
),
).toThrow(TypeError);
expect(rendering).not.toHaveBeenCalled();
});
it("skips empty submissions and repeated clears, but repaints the first clear", async () => {
const { element, submit, rendering, onChange } = await setup();
const renders = vi.spyOn(h.app, "render");
const empty = submit(null);
expect(submit(new Map())).toBe(empty);
expect(submit(new Map([[element.id, {}]]))).toBe(empty);
expect(renders).not.toHaveBeenCalled();
expect(rendering).not.toHaveBeenCalled();
submit(new Map([[element.id, { opacity: 50 }]]));
renders.mockClear();
rendering.mockClear();
const cleared = submit(null);
expect(cleared.size).toBe(0);
expect(renders).toHaveBeenCalledTimes(1);
expect(rendering).toHaveBeenCalledTimes(1);
expect(submit(null)).toBe(cleared);
expect(submit(new Map([[element.id, {}]]))).toBe(cleared);
expect(renders).toHaveBeenCalledTimes(1);
expect(rendering).toHaveBeenCalledTimes(1);
expect(onChange).not.toHaveBeenCalled();
});
it("detects caller mutations and complete replacements in a reused map", async () => {
const { element, submit, rendering } = await setup();
const value = { opacity: 25, offset: { x: 10, y: 20 } };
const input: Map = new Map([
[element.id, value],
]);
const first = submit(input);
rendering.mockClear();
value.opacity = 60;
value.offset.x = 30;
const changed = submit(input);
expect(changed).not.toBe(first);
expect(changed.get(element.id)).toEqual({
opacity: 60,
offset: { x: 30, y: 20 },
});
expect(first.get(element.id)).toEqual({
opacity: 25,
offset: { x: 10, y: 20 },
});
input.set(element.id, { opacity: 60 });
expect(submit(input).get(element.id)).toEqual({ opacity: 60 });
input.delete(element.id);
input.set("future", { opacity: 60 });
expect([...submit(input)]).toEqual([["future", { opacity: 60 }]]);
input.clear();
expect(submit(input).size).toBe(0);
expect(rendering).toHaveBeenCalledTimes(4);
});
it("preserves a pending visual update when followed by an equivalent snapshot", async () => {
const { element, rendering, onChange } = await setup();
const input = new Map([[element.id, { opacity: 50 }]]);
act(() => {
h.app.api.setElementRenderOverrides(input);
h.app.api.setElementRenderOverrides(new Map(input));
});
expect(rendering).toHaveBeenCalledTimes(1);
expect(
rendering.mock.lastCall![0].renderConfig.elementRenderOverrides?.get(
element.id,
),
).toEqual({ opacity: 50 });
expect(onChange).not.toHaveBeenCalled();
});
it.each(["opacity", "translation", "unknown", "empty"])(
"does not repaint for unrelated updates with a %s snapshot",
async (kind) => {
const { element, submit, rendering } = await setup();
submit(
new Map([
[
kind === "unknown" ? "unknown" : element.id,
kind === "translation"
? { offset: { x: 10, y: 0 } }
: kind === "empty"
? {}
: { opacity: 0 },
],
]),
);
rendering.mockClear();
act(() => h.app.setState({ cursorButton: "down" }));
act(() => h.app.setState({ cursorButton: "up" }));
expect(rendering).not.toHaveBeenCalled();
act(() => h.app.api.updateScene({ appState: { scrollX: 10 } }));
expect(rendering).toHaveBeenCalledTimes(1);
},
);
it("repaints without changing elements, history, store or onChange", async () => {
const { element, submit, onChange, rendering } = await setup();
const elements = h.elements;
const before = JSON.stringify(elements);
const state = h.state;
const cached = Element.elementWithCanvasCache.get(element);
const commit = vi.spyOn(h.store, "commit");
const subscriber = vi.fn();
const unsubscribe = h.app.api.onChange(subscriber);
for (let opacity = 0; opacity <= 100; opacity += 20) {
submit(
new Map([[element.id, { opacity, offset: { x: opacity, y: 0 } }]]),
);
}
expect(rendering).toHaveBeenCalledTimes(6);
expect(h.elements).toBe(elements);
expect(JSON.stringify(h.elements)).toBe(before);
expect(h.state).toBe(state);
expect(cached).toBeDefined();
expect(Element.elementWithCanvasCache.get(element)).toBe(cached);
expect(commit).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
expect(subscriber).not.toHaveBeenCalled();
unsubscribe();
});
it("still commits document changes batched with visual updates", async () => {
const { element, onChange } = await setup();
act(() => {
h.app.api.setElementRenderOverrides(
new Map([[element.id, { opacity: 0 }]]),
);
h.app.api.updateScene({ appState: { viewBackgroundColor: "#ff0000" } });
});
expect(onChange).toHaveBeenCalled();
expect(h.state.viewBackgroundColor).toBe("#ff0000");
});
it("does not skip ordinary forceUpdate calls after a visual-only commit", async () => {
const { element, submit, onChange } = await setup();
submit(new Map([[element.id, { opacity: 0 }]]));
submit(new Map([[element.id, { opacity: 0 }]]));
expect(onChange).not.toHaveBeenCalled();
act(() => h.app.forceUpdate());
expect(onChange).toHaveBeenCalledTimes(1);
});
it("leaves exported geometry and opacity unchanged", async () => {
const { element, submit } = await setup();
const exportScene = async () =>
(await exportToSvg(h.app.api.getSceneElements(), h.state, null))
.outerHTML;
const before = await exportScene();
submit(
new Map([[element.id, { opacity: 0, offset: { x: 1000, y: 1000 } }]]),
);
expect(await exportScene()).toBe(before);
});
it("clears on reset and ignores submissions after unmount", async () => {
const { element, submit, rendering, unmount } = await setup();
submit(new Map([[element.id, { opacity: 0 }]]));
act(() => h.app.api.resetScene());
expect(
rendering.mock.lastCall![0].renderConfig.elementRenderOverrides,
).toHaveLength(0);
const api = h.app.api;
unmount();
rendering.mockClear();
expect(() =>
api.setElementRenderOverrides(new Map([[element.id, { opacity: 0 }]])),
).not.toThrow();
expect(rendering).not.toHaveBeenCalled();
});
it("keeps frame-name editing on document geometry while the frame is translated", async () => {
mockBoundingClientRect();
const { onChange, submit } = await setup();
await waitFor(() => expect(h.state.width).toBe(200));
const frame = API.createElement({
type: "frame",
id: "frame",
x: 20,
y: 30,
width: 80,
height: 50,
});
API.setElements([frame]);
API.updateElement(frame, { name: " Draft " });
act(() => h.setState({ editingFrame: frame.id }));
const query = (selector: string) =>
GlobalTestState.renderResult.container.querySelector(
selector,
);
const nameElement = await waitFor(() => {
const element = query(".frame-name");
expect(element?.querySelector("input")).not.toBe(null);
return element!;
});
const documentLeft = nameElement.style.left;
onChange.mockClear();
// A render-only offset that puts the frame far off-screen must neither
// end the edit (which trims and commits the name) nor move the editor.
submit(new Map([[frame.id, { offset: { x: 10000, y: 0 } }]]));
expect(h.state.editingFrame).toBe(frame.id);
expect(API.getElement(frame).name).toBe(" Draft ");
expect(onChange).not.toHaveBeenCalled();
expect(query(".frame-name input")).not.toBe(null);
expect(query(".frame-name")!.style.left).toBe(documentLeft);
// Back to being a decoration, the name follows the offset and is culled.
act(() => h.setState({ editingFrame: null }));
expect(query(".frame-name")).toBe(null);
});
it.each([
{ type: "frame", zoom: 1, editing: false },
{ type: "frame", zoom: 2, editing: false },
{ type: "frame", zoom: 2, editing: true },
{ type: "magicframe", zoom: 2, editing: false },
] as const)(
"keeps $type title hit bounds in document coordinates at zoom $zoom (editing: $editing)",
async ({ type, zoom, editing }) => {
mockBoundingClientRect({
width: 1200,
height: 1000,
x: 40,
left: 40,
y: 30,
top: 30,
right: 1240,
bottom: 1030,
});
const { onChange, submit } = await setup();
await waitFor(() => expect(h.state.width).toBe(1200));
const frame = API.createElement({
type,
id: "frame",
x: 200,
y: 200,
width: 200,
height: 80,
});
API.setElements([frame]);
API.updateElement(frame, { name: "Frame title" });
act(() =>
h.setState({
zoom: { value: getNormalizedZoom(zoom) },
scrollX: 25,
scrollY: -30,
editingFrame: editing ? frame.id : null,
}),
);
const title = await waitFor(() => {
const element =
GlobalTestState.renderResult.container.querySelector(
".frame-name",
);
expect(element).not.toBe(null);
return element!;
});
// JSDOM has no layout: measure the rendered CSS position, including
// visual offsets, within the editor's viewport rectangle.
const measure = vi
.spyOn(title, "getBoundingClientRect")
.mockImplementation(() => {
const width = 60;
const height = editing ? 32 : 20;
const left = h.state.offsetLeft + parseFloat(title.style.left);
const bottom =
h.state.offsetTop + h.state.height - parseFloat(title.style.bottom);
return {
x: left,
y: bottom - height,
left,
top: bottom - height,
right: left + width,
bottom,
width,
height,
toJSON: () => ({}),
};
});
const cache = h.app.frameNameBoundsCache;
cache._cache.clear();
const documentBounds = cache.get(frame)!;
const point = {
x: documentBounds.x + documentBounds.width / 2,
y: documentBounds.y + documentBounds.height / 2,
};
expect(h.app.hitElement(point.x, point.y, frame, false)).toBe(true);
cache._cache.clear();
onChange.mockClear();
const documentElements = JSON.stringify(h.elements);
// First measurement happens with a translated title (unless editing).
submit(new Map([[frame.id, { offset: { x: 120, y: 150 } }]]));
const bounds = cache.get(frame);
expect(bounds).toEqual(documentBounds);
measure.mockClear();
submit(new Map([[frame.id, { offset: { x: 140, y: 160 } }]]));
expect(cache.get(frame)).toBe(bounds);
submit(null);
expect(cache.get(frame)).toBe(bounds);
expect(h.app.hitElement(point.x, point.y, frame, false)).toBe(true);
expect(h.app.hitElement(point.x + 120, point.y + 150, frame, false)).toBe(
false,
);
expect(measure).not.toHaveBeenCalled();
expect(JSON.stringify(h.elements)).toBe(documentElements);
expect(onChange).not.toHaveBeenCalled();
},
);
it("keeps the offsets map identity while only opacities change", () => {
const first = getElementRenderOffsets(
new Map([
["a", { opacity: 10, offset: { x: 1, y: 2 } }],
["b", { opacity: 20 }],
]),
new Map(),
);
expect([...first]).toEqual([["a", { x: 1, y: 2 }]]);
expect(
getElementRenderOffsets(
new Map([
["a", { opacity: 99, offset: { x: 1, y: 2 } }],
["b", { opacity: 0 }],
["c", { opacity: 0 }],
]),
first,
),
).toBe(first);
expect(
getElementRenderOffsets(
new Map([["a", { offset: { x: 1, y: 3 } }]]),
first,
),
).not.toBe(first);
expect(getElementRenderOffsets(new Map(), first).size).toBe(0);
expect(
getElementRenderOffsets(
new Map([["b", { offset: { x: 1, y: 2 } }]]),
first,
),
).not.toBe(first);
});
it("does not populate a replacement offsets map during a fade", () => {
const snapshot = (opacity: number): ElementRenderOverrides =>
new Map(
Array.from({ length: 1000 }, (_, index) => [
`r${index}`,
{ opacity, offset: { x: index, y: -index } },
]),
);
const first = getElementRenderOffsets(snapshot(20), new Map());
// Snapshot iteration order does not affect offsets or their identity.
const fade = new Map([...snapshot(80)].reverse());
const set = vi.spyOn(Map.prototype, "set");
const next = getElementRenderOffsets(fade, first);
const writes = set.mock.calls.length;
set.mockRestore();
expect(next).toBe(first);
expect(writes).toBe(0);
});
it.each([
[
"a later offset changes",
[
["a", { x: 1, y: 2 }],
["b", { x: 3, y: 5 }],
],
],
[
"an offset is appended after unchanged entries",
[
["a", { x: 1, y: 2 }],
["b", { x: 3, y: 4 }],
["c", { x: 5, y: 6 }],
],
],
["an offset is removed", [["a", { x: 1, y: 2 }]]],
[
"an ID changes without changing the number of offsets",
[
["a", { x: 1, y: 2 }],
["c", { x: 3, y: 4 }],
],
],
] as const)("replaces the offsets map when %s", (_, entries) => {
const previous: ElementRenderOffsets = new Map([
["a", { x: 1, y: 2 }],
["b", { x: 3, y: 4 }],
]);
const next = getElementRenderOffsets(
new Map(entries.map(([id, offset]) => [id, { offset }])),
previous,
);
expect(next).not.toBe(previous);
expect([...next]).toEqual(entries);
expect([...previous]).toEqual([
["a", { x: 1, y: 2 }],
["b", { x: 3, y: 4 }],
]);
});
});
describe("render override geometry", () => {
const setup = (elements: NonDeletedExcalidrawElement[]) => {
const scene = new Element.Scene(elements, { skipValidation: true });
const renderer = new Renderer(scene);
const appState: AppState = {
...getDefaultAppState(),
width: 500,
height: 500,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
};
const { elementsMap } = renderer.getRenderableElements({
...appState,
selectedElements: [],
});
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 500;
const context = canvas.getContext("2d")!;
const renderConfig: StaticCanvasRenderConfig = {
imageCache: new Map(),
renderGrid: false,
isExporting: false,
canvasBackgroundColor: "#fff",
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
theme: "light",
};
// What App hands the static canvas: document culling, adjusted for the
// snapshot's offsets, whose identity is kept while they don't change.
let offsets: ElementRenderOffsets = new Map();
const visibleWith = (
overrides: ElementRenderOverrides,
state: AppState = appState,
map: RenderableElementsMap = elementsMap,
) => {
const { visibleElements } = renderer.getRenderableElements({
...state,
selectedElements: scene.getSelectedElements(state),
});
offsets = getElementRenderOffsets(overrides, offsets);
return offsets.size
? renderer.getVisibleElementsWithRenderOffsets(
visibleElements,
map,
state,
offsets,
)
: visibleElements;
};
const draw = (overrides: ElementRenderOverrides) =>
StaticScene.renderStaticScene({
canvas,
rc: rough.canvas(canvas),
scale: 1,
elementsMap,
allElementsMap: scene.getNonDeletedElementsMap(),
visibleElements: visibleWith(overrides),
appState,
renderConfig: {
...renderConfig,
elementRenderOverrides: overrides,
},
});
return {
scene,
renderer,
appState,
elementsMap,
context,
draw,
visibleWith,
renderConfig,
};
};
it("culls at visual coordinates while preserving interaction geometry", () => {
const rect = API.createElement({
type: "rectangle",
x: 600,
y: 100,
width: 50,
height: 50,
});
const { renderer, appState, visibleWith } = setup([rect]);
const overrides = new Map([[rect.id, { offset: { x: -300, y: 0 } }]]);
expect(visibleWith(overrides)).toContain(rect);
expect(
renderer.getRenderableElements({ ...appState, selectedElements: [] })
.visibleElements,
).not.toContain(rect);
expect(rect.x).toBe(600);
});
it("updates cached visibility for new overrides, viewport bounds and scene elements", () => {
const rect = API.createElement({
type: "rectangle",
id: "a",
x: 600,
y: 100,
width: 50,
height: 50,
});
const { renderer, appState, scene, visibleWith } = setup([rect]);
const overrides = new Map([[rect.id, { offset: { x: -300, y: 0 } }]]);
const visible = visibleWith(overrides);
expect(visible).toContain(rect);
expect(visibleWith(overrides, { ...appState, cursorButton: "down" })).toBe(
visible,
);
// a new snapshot with the same offsets keeps the result too
expect(
visibleWith(
new Map([[rect.id, { opacity: 50, offset: { x: -300, y: 0 } }]]),
),
).toBe(visible);
expect(visibleWith(overrides, { ...appState, width: 100 })).not.toContain(
rect,
);
expect(
visibleWith(overrides, { ...appState, scrollX: -600 }),
).not.toContain(rect);
expect(visibleWith(new Map())).not.toContain(rect);
const moved = { ...rect, x: 1200 };
scene.replaceAllElements([moved], { skipValidation: true });
const changed = renderer.getRenderableElements({
...appState,
selectedElements: [],
});
expect(visibleWith(overrides, appState, changed.elementsMap)).not.toContain(
moved,
);
});
it("reuses the document-visible set for opacity-only snapshots without viewport geometry", () => {
const rects = Array.from({ length: 20 }, (_, index) =>
API.createElement({
type: "rectangle",
id: `r${index}`,
x: index * 30,
y: 10,
width: 20,
height: 20,
}),
);
const { renderer, appState, visibleWith } = setup(rects);
const documentVisible = renderer.getRenderableElements({
...appState,
selectedElements: [],
}).visibleElements;
const inViewport = vi.spyOn(Element, "isElementInViewport");
expect(visibleWith(new Map([[rects[3].id, { opacity: 50 }]]))).toBe(
documentVisible,
);
expect(visibleWith(new Map([[rects[3].id, { opacity: 51 }]]))).toBe(
documentVisible,
);
expect(inViewport).not.toHaveBeenCalled();
// translating one element checks that element only, not the scene
const moving = visibleWith(
new Map([[rects[3].id, { opacity: 50, offset: { x: 5, y: 0 } }]]),
);
expect(moving).toBe(documentVisible);
expect(inViewport).toHaveBeenCalledTimes(1);
expect(inViewport.mock.calls[0][0]).toBe(rects[3]);
expect(
visibleWith(
new Map([[rects[3].id, { opacity: 50, offset: { x: 5000, y: 0 } }]]),
),
).toEqual(documentVisible.filter((element) => element !== rects[3]));
expect(inViewport).toHaveBeenCalledTimes(2);
});
it("keeps frame-drag ordering responsive to selection and drag-state changes", () => {
// `a` is off-screen in the document and only visible through its offset,
// so the visible set is rebuilt in scene order on every change here.
const a = API.createElement({ type: "rectangle", id: "a", x: 600, y: 10 });
const b = API.createElement({ type: "rectangle", id: "b", x: 10, y: 10 });
const frame = API.createElement({
type: "frame",
id: "frame",
width: 300,
height: 300,
});
const child = API.createElement({
type: "rectangle",
id: "child",
frameId: frame.id,
x: 20,
y: 20,
});
const { appState, visibleWith } = setup([a, b, frame, child]);
const overrides = new Map([[a.id, { offset: { x: -590, y: 0 } }]]);
const ids = (state: AppState) =>
visibleWith(overrides, state).map((element) => element.id);
const selected: AppState = {
...appState,
selectedElementIds: { [a.id]: true },
frameToHighlight: frame,
};
expect(ids(selected)).toEqual(["a", "b", "frame", "child"]);
expect(ids({ ...selected, selectedElementsAreBeingDragged: true })).toEqual(
["b", "frame", "child", "a"],
);
expect(
ids({
...selected,
selectedElementsAreBeingDragged: true,
selectedElementIds: { [b.id]: true },
}),
).toEqual(["a", "frame", "child", "b"]);
expect(ids(selected)).toEqual(["a", "b", "frame", "child"]);
});
it("drops the frame-drag reordering when the anchoring frame is translated away", () => {
const a = API.createElement({ type: "rectangle", id: "a", x: 10, y: 10 });
const b = API.createElement({ type: "rectangle", id: "b", x: 10, y: 10 });
const frame = API.createElement({
type: "frame",
id: "frame",
x: 200,
y: 200,
width: 100,
height: 100,
});
const { appState, visibleWith } = setup([a, b, frame]);
const dragging: AppState = {
...appState,
selectedElementIds: { [a.id]: true },
frameToHighlight: frame,
selectedElementsAreBeingDragged: true,
};
const ids = (overrides: ElementRenderOverrides) =>
visibleWith(overrides, dragging).map((element) => element.id);
expect(ids(new Map())).toEqual(["b", "a", "frame"]);
// the frame leaves the view: back to plain scene order, like a full
// recalculation would produce
expect(ids(new Map([[frame.id, { offset: { x: 10000, y: 0 } }]]))).toEqual([
"a",
"b",
]);
expect(ids(new Map([[frame.id, { offset: { x: 0, y: 0 } }]]))).toEqual([
"b",
"a",
"frame",
]);
});
it.each(["a", "b", "c"])(
"preserves scene order as %s is translated into and out of view",
(id) => {
const sceneIds = ["a", "b", "c"];
const { visibleWith } = setup(
sceneIds.map((elementId) =>
API.createElement({
type: "rectangle",
id: elementId,
x: elementId === id ? 600 : 10,
y: 10,
}),
),
);
const ids = (offsetX: number) =>
visibleWith(new Map([[id, { offset: { x: offsetX, y: 0 } }]])).map(
(element) => element.id,
);
expect(ids(-590)).toEqual(sceneIds);
expect(ids(-580)).toEqual(sceneIds);
expect(ids(-570)).toEqual(sceneIds);
expect(ids(0)).toEqual(sceneIds.filter((elementId) => elementId !== id));
},
);
it("preserves scene order when elements enter and leave in the same update", () => {
const a = API.createElement({ type: "rectangle", id: "a", x: 600, y: 10 });
const b = API.createElement({ type: "rectangle", id: "b", x: 10, y: 10 });
const c = API.createElement({ type: "rectangle", id: "c", x: 600, y: 60 });
const d = API.createElement({ type: "rectangle", id: "d", x: 10, y: 60 });
const { visibleWith } = setup([a, b, c, d]);
expect(
visibleWith(
new Map([
[c.id, { offset: { x: -590, y: 0 } }],
[b.id, { offset: { x: 1000, y: 0 } }],
[a.id, { offset: { x: -590, y: 0 } }],
]),
),
).toEqual([a, c, d]);
expect(visibleWith(new Map())).toEqual([b, d]);
});
it("does not rebuild the visible set for a translated arrow whose label is off-screen", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow",
x: -1000,
y: 100,
width: 1100,
points: [pointFrom(0, 0), pointFrom(1100, 0)],
boundElements: [{ id: "label", type: "text" }],
});
const label = API.createElement({
type: "text",
id: "label",
x: -500,
y: 90,
width: 50,
text: "label",
containerId: arrow.id,
});
const { renderer, appState, elementsMap, visibleWith } = setup([
arrow,
label,
]);
const documentVisible = renderer.getRenderableElements({
...appState,
selectedElements: [],
}).visibleElements;
expect(documentVisible).toEqual([arrow]);
const walk = vi.spyOn(elementsMap, "values");
for (const x of [10, 20, 30]) {
expect(visibleWith(new Map([[arrow.id, { offset: { x, y: 0 } }]]))).toBe(
documentVisible,
);
}
expect(walk).not.toHaveBeenCalled();
});
it.each(["rectangle", "arrow"] as const)(
"culls a %s by its container offset; its label needs no entry of its own",
(type) => {
const container = API.createElement({
type,
id: "a",
x: 900,
y: 100,
width: 100,
points:
type === "arrow"
? [pointFrom(0, 0), pointFrom(100, 0)]
: undefined,
boundElements: [{ id: "label", type: "text" }],
});
const label = API.createElement({
type: "text",
id: "label",
x: 750,
y: 110,
width: 400,
text: "label",
containerId: container.id,
});
const { renderer, appState, visibleWith } = setup([container, label]);
const visible = visibleWith;
expect(visible(new Map())).toEqual([]);
// The container comes into view through its offset; the label is drawn
// with it, so its own (document, off-screen) entry stays out and any
// offset targeting it directly is ignored.
expect(
visible(new Map([[container.id, { offset: { x: -500, y: 0 } }]])),
).toEqual([container]);
expect(
visible(
new Map([
[container.id, { offset: { x: -500, y: 0 } }],
[label.id, { offset: { x: 2000, y: 0 } }],
]),
),
).toEqual([container]);
expect(
visible(new Map([[label.id, { offset: { x: -800, y: 0 } }]])),
).toEqual([]);
expect(
visible(
new Map([
[container.id, { offset: { x: -2000, y: 0 } }],
[label.id, { offset: { x: -800, y: 0 } }],
]),
),
).toEqual([]);
expect(
renderer.getRenderableElements({ ...appState, selectedElements: [] })
.visibleElements,
).toEqual([]);
},
);
it.each(["arrow", "rectangle", "ellipse", "diamond", "stickynote"] as const)(
"moves a %s label with its container while keeping label opacity independent",
(type) => {
const frame = API.createElement({
type: "frame",
id: "frame",
width: 500,
height: 500,
opacity: 50,
});
const container = API.createElement({
type,
id: "container",
x: 50,
y: 100,
width: 200,
height: 100,
frameId: frame.id,
points:
type === "arrow"
? [pointFrom(0, 0), pointFrom(200, 0)]
: undefined,
boundElements: [{ id: "label", type: "text" }],
});
const label = API.createElement({
type: "text",
id: "label",
text: "Label",
x: 100,
y: 140,
width: 50,
height: 25,
opacity: 70,
containerId: container.id,
frameId: frame.id,
});
const { context, draw } = setup([container, label, frame]);
draw(new Map());
const bitmap = Element.elementWithCanvasCache.get(label)!;
expect(bitmap).toBeDefined();
const original = JSON.stringify([container, label, frame]);
const paints: { x: number; y: number; alpha: number }[] = [];
const drawImage = vi.mocked(context.drawImage).getMockImplementation()!;
vi.spyOn(context, "drawImage").mockImplementation((...args) => {
const [source, x, y] = args;
if (source === bitmap.canvas) {
const t = context.getTransform();
paints.push({
x: t.a * x + t.c * y + t.e,
y: t.b * x + t.d * y + t.f,
alpha: context.globalAlpha,
});
}
drawImage.call(context, ...args);
});
const offset = { x: 20, y: 10 };
draw(new Map());
draw(new Map([[container.id, { offset }]]));
draw(
new Map([
[container.id, { offset }],
[label.id, { offset }],
]),
);
draw(
new Map([
[container.id, { offset, opacity: 10 }],
[label.id, { offset: { x: 1000, y: -1000 }, opacity: 40 }],
]),
);
draw(new Map([[label.id, { offset: { x: 1000, y: -1000 } }]]));
draw(new Map());
expect(paints).toHaveLength(6);
const [baseline] = paints;
expect(baseline.alpha).toBe(0.35);
const translated = {
x: baseline.x + offset.x,
y: baseline.y + offset.y,
alpha: baseline.alpha,
};
expect(paints.slice(1)).toEqual([
translated,
translated,
{ ...translated, alpha: 0.2 },
baseline,
baseline,
]);
expect(Element.elementWithCanvasCache.get(label)).toBe(bitmap);
expect(JSON.stringify([container, label, frame])).toBe(original);
},
);
it("keeps offsets on unbound text independent", () => {
const label = API.createElement({
type: "text",
x: 600,
y: 100,
text: "Label",
});
const { elementsMap, renderConfig, visibleWith } = setup([label]);
const overrides = new Map([[label.id, { offset: { x: -400, y: 10 } }]]);
expect(
Element.resolveElementRenderState(label, elementsMap, {
...renderConfig,
elementRenderOverrides: overrides,
}).offset,
).toEqual({ x: -400, y: 10 });
expect(visibleWith(overrides)).toContain(label);
});
it("clips a translated child against the translated frame boundary", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 100,
y: 100,
width: 200,
height: 200,
});
const rect = API.createElement({
type: "rectangle",
x: 120,
y: 120,
width: 50,
height: 50,
frameId: frame.id,
});
const { draw, context } = setup([rect, frame]);
const clip = vi.spyOn(context, "clip");
const translate = vi.spyOn(context, "translate");
draw(
new Map([
[rect.id, { offset: { x: -60, y: 0 } }],
[frame.id, { offset: { x: 10, y: 0 } }],
]),
);
expect(clip).toHaveBeenCalled();
expect(translate).toHaveBeenCalledWith(110, 100);
});
it("multiplies overridden frame and child opacities", () => {
const frame = API.createElement({ type: "frame", id: "frame" });
const rect = API.createElement({ type: "rectangle", frameId: frame.id });
expect(
Element.resolveElementRenderState(
rect,
new Map([
[frame.id, frame],
[rect.id, rect],
]),
{
elementRenderOverrides: new Map([
[frame.id, { opacity: 50 }],
[rect.id, { opacity: 50 }],
]),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
},
).opacity,
).toBe(0.25);
});
it("gives a synthetic embed label its owner's overrides and frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
width: 500,
height: 500,
});
const embed = API.createElement({
type: "embeddable",
id: "embed",
width: 200,
height: 100,
frameId: frame.id,
});
const { draw } = setup([embed, frame]);
const renderElement = vi.spyOn(Element, "renderElement");
draw(new Map([[embed.id, { opacity: 0, offset: { x: 20, y: 0 } }]]));
const label = renderElement.mock.calls.find(
([element]) => element.type === "text",
)?.[0];
expect(label).toMatchObject({ id: embed.id, frameId: frame.id });
});
it.each([
[0, 80],
[80, 0],
[30, 80],
[80, 80],
])(
"keeps the label gap with its arrow (arrow %s, ignored label %s)",
(arrowX, labelX) => {
const arrow = API.createElement({
type: "arrow",
id: "arrow",
x: 50,
y: 100,
points: [pointFrom(0, 0), pointFrom(300, 0)],
boundElements: [{ id: "label", type: "text" }],
});
const label = API.createElement({
type: "text",
id: "label",
containerId: arrow.id,
text: "Label",
width: 50,
height: 25,
});
const { context, draw } = setup([arrow, label]);
const holes: number[] = [];
const rect = vi.mocked(context.rect).getMockImplementation()!;
vi.spyOn(context, "rect").mockImplementation((x, y, width, height) => {
if (width === label.width + BOUND_TEXT_PADDING * 2) {
const transform = context.getTransform();
holes.push(transform.a * x + transform.c * y + transform.e);
}
rect.call(context, x, y, width, height);
});
draw(new Map());
draw(
new Map([
[arrow.id, { offset: { x: arrowX, y: 0 } }],
[label.id, { offset: { x: labelX, y: 0 } }],
]),
);
expect(holes).toHaveLength(2);
expect(holes[1] - holes[0]).toBeCloseTo(arrowX, 8);
},
);
it("reuses original arrow shapes and bitmaps when rendering translated links", () => {
const arrow = {
...API.createElement({
type: "arrow",
x: 50,
y: 100,
points: [pointFrom(0, 0), pointFrom(200, 0)],
}),
link: "https://example.com",
};
const { draw } = setup([arrow]);
draw(new Map());
const bitmap = Element.elementWithCanvasCache.get(arrow);
const shape = Element.ShapeCache.get(arrow, null);
const generateShape = vi.spyOn(Element.ShapeCache, "generateElementShape");
for (const x of [10, 20, 0]) {
draw(new Map([[arrow.id, { offset: { x, y: 0 }, opacity: 40 }]]));
}
expect(bitmap).toBeDefined();
expect(shape).toBeDefined();
expect(Element.elementWithCanvasCache.get(arrow)).toBe(bitmap);
expect(Element.ShapeCache.get(arrow, null)).toBe(shape);
expect(generateShape).toHaveBeenCalled();
expect(
generateShape.mock.calls.every(([element]) => element === arrow),
).toBe(true);
});
it("preserves frame, erasure and selection alpha without leaking it to siblings or links", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
width: 400,
height: 400,
});
const child = {
...API.createElement({
type: "rectangle",
x: 50,
y: 50,
frameId: frame.id,
}),
link: "https://example.com",
};
const sibling = API.createElement({ type: "rectangle", x: 200, y: 200 });
const { context, draw, appState, renderConfig } = setup([
child,
frame,
sibling,
]);
appState.openDialog = {
name: "elementLinkSelector",
sourceElementId: sibling.id,
};
appState.selectedElementIds = { [sibling.id]: true };
renderConfig.elementsPendingErasure.add(child.id);
const alphas: number[] = [];
const drawImage = vi.mocked(context.drawImage).getMockImplementation()!;
vi.spyOn(context, "drawImage").mockImplementation((...args) => {
alphas.push(context.globalAlpha);
drawImage.call(context, ...args);
});
draw(
new Map([
[child.id, { opacity: 50 }],
[frame.id, { opacity: 50 }],
]),
);
const erasedAlpha = (0.25 * ELEMENT_READY_TO_ERASE_OPACITY) / 100;
expect(alphas).toEqual([
erasedAlpha * DEFAULT_REDUCED_GLOBAL_ALPHA,
erasedAlpha,
1,
]);
expect(context.globalAlpha).toBe(1);
});
it("keeps the uncropped preview at document coordinates while translating the image", () => {
const image = {
...API.createElement({
type: "image",
x: 100,
y: 100,
width: 100,
height: 60,
}),
crop: {
x: 10,
y: 5,
width: 50,
height: 30,
naturalWidth: 100,
naturalHeight: 60,
},
};
const { context, draw, appState } = setup([image]);
appState.croppingElementId = image.id;
const paints: { x: number; y: number; alpha: number }[] = [];
const drawImage = vi.mocked(context.drawImage).getMockImplementation()!;
vi.spyOn(context, "drawImage").mockImplementation((...args) => {
const [, x, y] = args;
const t = context.getTransform();
paints.push({
x: t.a * x + t.c * y + t.e,
y: t.b * x + t.d * y + t.f,
alpha: context.globalAlpha,
});
drawImage.call(context, ...args);
});
draw(new Map());
draw(new Map([[image.id, { offset: { x: 80, y: 40 }, opacity: 25 }]]));
expect(paints).toHaveLength(4);
expect(paints[2]).toEqual(paints[0]);
expect(paints[2].alpha).toBe(0.1);
expect(paints[3]).toEqual({
x: paints[1].x + 80,
y: paints[1].y + 40,
alpha: 0.25,
});
});
});