import React from "react"; import { vi } from "vitest"; import { CODES, CURSOR_TYPE, POINTER_BUTTON } from "@excalidraw/common"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import { actionZoomIn } from "../actions/actionCanvas"; import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; import { DefaultSidebar, Excalidraw, Footer, MainMenu } from "../index"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { getTextEditor, updateTextEditor } from "./queries/dom"; import { act, fireEvent, GlobalTestState, mockBoundingClientRect, render, restoreOriginalGetBoundingClientRect, waitFor, } from "./test-utils"; import type { MockInstance } from "vitest"; import type { ExcalidrawProps } from "../types"; const { h } = window; const mouse = new Pointer("mouse"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(null), ms)); // the wheel listener is attached natively on the excalidraw container // (not via React), so we dispatch a bubbling wheel event from the canvas const wheelZoom = () => { fireEvent.wheel(GlobalTestState.interactiveCanvas, { ctrlKey: true, deltaY: -100, }); }; const wheelPan = () => { fireEvent.wheel(GlobalTestState.interactiveCanvas, { deltaX: 30, deltaY: 40, }); }; // zoom shortcuts' keyTest matches on `event.code` (CODES.EQUAL/MINUS/ZERO) const pressZoomShortcut = (code: string) => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.codePress(code); }); }; const rightClickCanvas = () => { const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, clientX: 5, clientY: 5, }); fireEvent(GlobalTestState.interactiveCanvas, event); return event; }; const dispatchPaste = async () => { const clipboardJSON = await serializeAsClipboardJSON({ elements: [API.createElement({ type: "rectangle", width: 10, height: 10 })], files: null, }); document.dispatchEvent( createPasteEvent({ types: { "text/plain": clipboardJSON } }), ); }; const queryContainer = (selector: string) => GlobalTestState.renderResult.container.querySelector(selector); beforeEach(() => { localStorage.clear(); mouse.reset(); }); describe("baseline (interactive & ui enabled by default)", () => { beforeEach(async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); Object.assign(document, { elementFromPoint: () => GlobalTestState.canvas, }); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("user input mechanisms used in this suite actually affect the editor", async () => { expect(h.state.viewModeEnabled).toBe(false); // ctrl+wheel zooms const initialZoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBeGreaterThan(initialZoom); // plain wheel pans const { scrollX, scrollY } = h.state; wheelPan(); expect([h.state.scrollX, h.state.scrollY]).not.toEqual([scrollX, scrollY]); // ctrl+"=" zooms in const zoomBeforeKeyboard = h.state.zoom.value; pressZoomShortcut(CODES.EQUAL); expect(h.state.zoom.value).toBeGreaterThan(zoomBeforeKeyboard); // paste inserts elements await dispatchPaste(); await waitFor(() => expect(h.elements.length).toBe(1)); // right-click opens the context menu rightClickCanvas(); expect(h.state.contextMenu).not.toBe(null); expect(UI.queryContextMenu()).not.toBe(null); }); it("renders UI chrome", () => { expect(queryContainer(".layer-ui__wrapper")).not.toBe(null); expect(queryContainer(".App-toolbar")).not.toBe(null); expect(queryContainer(".excalidraw--non-interactive")).toBe(null); expect(queryContainer(".excalidraw--ui-hidden")).toBe(null); }); }); describe("interaction={false}", () => { let rectangle: ExcalidrawElement; beforeEach(async () => { mockBoundingClientRect(); rectangle = API.createElement({ type: "rectangle", x: 10, y: 10, width: 50, height: 50, }); await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); Object.assign(document, { elementFromPoint: () => GlobalTestState.canvas, }); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("forces viewModeEnabled", () => { expect(h.state.viewModeEnabled).toBe(true); }); it("container gets the excalidraw--non-interactive class", () => { const container = queryContainer(".excalidraw-container")!; expect(container).not.toBe(null); expect(container.classList.contains("excalidraw--non-interactive")).toBe( true, ); }); it("wheel does not zoom or pan", () => { const { scrollX, scrollY } = h.state; const zoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBe(zoom); wheelPan(); expect(h.state.scrollX).toBe(scrollX); expect(h.state.scrollY).toBe(scrollY); }); it("keyboard zoom shortcuts are inert", () => { // zoom to != 100% first so ctrl+0 (reset zoom) would be observable act(() => { h.app.actionManager.executeAction(actionZoomIn, "api"); }); const zoom = h.state.zoom.value; expect(zoom).not.toBe(1); pressZoomShortcut(CODES.EQUAL); expect(h.state.zoom.value).toBe(zoom); pressZoomShortcut(CODES.MINUS); expect(h.state.zoom.value).toBe(zoom); pressZoomShortcut(CODES.ZERO); expect(h.state.zoom.value).toBe(zoom); // page-scroll keys are inert as well (navigation-only) const { scrollY } = h.state; fireEvent.keyDown(document, { key: "PageDown", code: "PageDown" }); expect(h.state.scrollY).toBe(scrollY); }); it("pointer down+drag neither selects nor pans", () => { const { scrollX, scrollY } = h.state; // drag starting on top of the rectangle mouse.downAt(30, 30); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect(h.state.selectedElementIds).toEqual({}); expect(h.state.scrollX).toBe(scrollX); expect(h.state.scrollY).toBe(scrollY); // plain click on the element doesn't select either mouse.reset(); mouse.clickAt(30, 30); expect(h.state.selectedElementIds).toEqual({}); }); it("prevents the native context menu without opening Excalidraw's", () => { const event = rightClickCanvas(); expect(event.defaultPrevented).toBe(true); expect(h.state.contextMenu).toBe(null); expect(UI.queryContextMenu()).toBe(null); }); it("paste is inert", async () => { expect(h.elements.length).toBe(1); await dispatchPaste(); // paste is async; give it time to (not) insert await act(async () => { await sleep(50); }); expect(h.elements.length).toBe(1); }); it("programmatic viewport API still works", () => { expect(h.state.zoom.value).toBe(1); act(() => { h.app.viewport.setViewport({ target: [0, 0, 1000, 1000], fit: "scale-down", animation: false, }); }); // 200x100 viewport fitted to a 1000x1000 box expect(h.state.zoom.value).toBeCloseTo(0.1); }); it("programmatic updateScene still works", () => { expect(h.elements.length).toBe(1); const other = API.createElement({ type: "ellipse", x: 100, y: 100, width: 30, height: 30, }); API.updateScene({ elements: [...h.elements, other] }); expect(h.elements.length).toBe(2); }); it('executeAction no-ops for user sources but works for "api"', () => { const zoom = h.state.zoom.value; act(() => { h.app.actionManager.executeAction(actionZoomIn, "ui"); }); expect(h.state.zoom.value).toBe(zoom); act(() => { h.app.actionManager.executeAction(actionZoomIn, "keyboard"); }); expect(h.state.zoom.value).toBe(zoom); act(() => { h.app.actionManager.executeAction(actionZoomIn, "api"); }); expect(h.state.zoom.value).toBeGreaterThan(zoom); }); it("viewModeEnabled cannot be unset while non-interactive", async () => { API.updateScene({ appState: { viewModeEnabled: false } }); await waitFor(() => { expect(h.state.viewModeEnabled).toBe(true); }); }); }); describe("browser zoom prevention (non-interactive)", () => { // fireEvent returns `false` when a handler called preventDefault() const firesCtrlWheel = () => fireEvent.wheel(GlobalTestState.interactiveCanvas, { ctrlKey: true, deltaY: -100, }); const firesPlainWheel = () => fireEvent.wheel(GlobalTestState.interactiveCanvas, { deltaY: 40 }); const firesCtrlZoomKey = () => fireEvent.keyDown(document, { ctrlKey: true, code: CODES.EQUAL }); it("prevents ctrl+wheel & keyboard zoom by default, keeps page scroll", async () => { await render( , ); // browser zoom vectors get preventDefault-ed... expect(firesCtrlWheel()).toBe(false); expect(firesCtrlZoomKey()).toBe(false); // ...while regular scroll passes through to the page expect(firesPlainWheel()).toBe(true); // and the editor itself didn't zoom either expect(h.state.zoom.value).toBe(1); }); it("enabled.browserZoom keeps the browser defaults", async () => { await render( , ); expect( queryContainer(".excalidraw-container")!.classList.contains( "excalidraw--allow-browser-zoom", ), ).toBe(true); expect(firesCtrlWheel()).toBe(true); expect(firesCtrlZoomKey()).toBe(true); expect(firesPlainWheel()).toBe(true); expect(h.state.zoom.value).toBe(1); }); it("toggling enabled.browserZoom at runtime re-attaches listeners", async () => { await render( , ); expect(firesCtrlWheel()).toBe(false); GlobalTestState.renderResult.rerender( , ); expect(firesCtrlWheel()).toBe(true); }); }); describe("toggling `interaction` at runtime", () => { afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("clears transient state when disabled and re-enables input when re-enabled", async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); // draw & select an element, open the context menu const rectangle = UI.createElement("rectangle", { x: 10, y: 10, size: 50 }); expect(h.state.selectedElementIds[rectangle.id]).toBe(true); rightClickCanvas(); expect(h.state.contextMenu).not.toBe(null); // flip to non-interactive GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(h.state.viewModeEnabled).toBe(true); }); expect(h.state.contextMenu).toBe(null); expect(h.state.selectedElementIds).toEqual({}); // input is dead now const zoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBe(zoom); mouse.reset(); mouse.clickAt(30, 30); expect(h.state.selectedElementIds).toEqual({}); // flip back to interactive GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(h.state.viewModeEnabled).toBe(false); }); // drawing works again act(() => { h.app.setActiveTool({ type: "rectangle" }); }); mouse.reset(); mouse.downAt(100, 10); mouse.moveTo(150, 60); mouse.upAt(150, 60); expect(h.elements.length).toBe(2); // and wheel zoom works again wheelZoom(); expect(h.state.zoom.value).toBeGreaterThan(zoom); }); it("deselects when disabled while already in view mode", async () => { mockBoundingClientRect(); await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); const rectangle = API.createElement({ type: "rectangle" }); API.setElements([rectangle]); API.setSelectedElements([rectangle]); expect(h.state.selectedElementIds[rectangle.id]).toBe(true); GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(h.state.selectedElementIds).toEqual({}); }); }); it("submits an active text editor when interaction is disabled", async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); const text = API.createElement({ type: "text", text: "before", x: 20, y: 20, }); API.setElements([text]); API.setSelectedElements([text]); Keyboard.keyPress("Enter"); const editor = await getTextEditor(); updateTextEditor(editor, "committed before disable"); GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(h.state.editingTextElement).toBe(null); expect(queryContainer(".excalidraw-wysiwyg")).toBe(null); }); expect(h.elements.find((element) => element.id === text.id)).toMatchObject({ originalText: "committed before disable", }); // The detached editor must no longer have a live input listener. editor.value = "changed after disable"; fireEvent.input(editor); expect(h.elements.find((element) => element.id === text.id)).toMatchObject({ originalText: "committed before disable", }); }); it("commits and closes frame-name editing when interaction is disabled", async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); const frame = API.createElement({ type: "frame", x: 20, y: 30, width: 80, height: 50, }); API.setElements([frame]); API.updateElement(frame, { name: "before" }); act(() => h.setState({ editingFrame: frame.id })); const frameNameInput = await waitFor(() => { const input = queryContainer(".frame-name input"); expect(input).not.toBe(null); return input as HTMLInputElement; }); fireEvent.change(frameNameInput, { target: { value: " committed " } }); GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(h.state.editingFrame).toBe(null); expect(queryContainer(".frame-name input")).toBe(null); }); expect(h.elements.find((element) => element.id === frame.id)).toMatchObject( { name: "committed" }, ); }); it("clears held-key state before interaction is re-enabled", async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); // If the keyup occurs while non-interactive, App's keyup listener is not // attached. The transition itself must therefore clear this state. Keyboard.keyDown(" "); GlobalTestState.renderResult.rerender( , ); await waitFor(() => expect(h.state.viewModeEnabled).toBe(true)); Keyboard.keyUp(" "); GlobalTestState.renderResult.rerender( , ); await waitFor(() => expect(h.state.viewModeEnabled).toBe(false)); act(() => h.app.setActiveTool({ type: "rectangle" })); mouse.downAt(20, 20); mouse.moveTo(80, 70); mouse.upAt(80, 70); expect(h.elements).toHaveLength(1); }); it("closes native-listener interaction surfaces when disabled", async () => { mockBoundingClientRect(); await render(); await waitFor(() => expect(h.state.width).toBe(200)); Keyboard.keyPress("i"); await waitFor(() => { expect(queryContainer(".excalidraw-eye-dropper-preview")).not.toBe(null); }); GlobalTestState.renderResult.rerender( , ); await waitFor(() => { expect(queryContainer(".excalidraw-eye-dropper-preview")).toBe(null); }); }); }); describe("ui={false}", () => { beforeEach(async () => { await render( {/* children may be functional (e.g. excalidraw.com's ), so they must mount even with the default UI disabled */}
, ); }); it("renders canvases but no default UI chrome", () => { // canvas layers keep rendering expect(queryContainer("canvas.static")).not.toBe(null); expect(queryContainer("canvas.interactive")).not.toBe(null); expect(queryContainer(".SVGLayer")).not.toBe(null); expect(queryContainer(".excalidraw-textEditorContainer")).not.toBe(null); // The placement scaffold remains for host UI, but default controls don't. expect(queryContainer(".App-toolbar")).toBe(null); expect(queryContainer(".dropdown-menu-button")).toBe(null); expect(queryContainer(".default-sidebar-trigger")).toBe(null); expect(queryContainer(".layer-ui__wrapper__footer-left")).toBe(null); expect(queryContainer(".help-icon")).toBe(null); }); it("host children still mount", () => { // functional children (e.g. collab) keep working expect(queryContainer("[data-testid='host-child']")).not.toBe(null); }); it("container gets the excalidraw--ui-hidden class", () => { const container = queryContainer(".excalidraw-container")!; expect(container).not.toBe(null); expect(container.classList.contains("excalidraw--ui-hidden")).toBe(true); // ui-only mode must not force view mode / non-interactivity expect(container.classList.contains("excalidraw--non-interactive")).toBe( false, ); expect(h.state.viewModeEnabled).toBe(false); }); it("interaction still works (drawing via pointer)", () => { expect(h.elements.length).toBe(1); act(() => { h.app.setActiveTool({ type: "rectangle" }); }); mouse.downAt(100, 10); mouse.moveTo(150, 60); mouse.upAt(150, 60); expect(h.elements.length).toBe(2); expect(h.elements[1].type).toBe("rectangle"); }); it("right-click sets contextMenu state but renders no menu", () => { rightClickCanvas(); expect(h.state.contextMenu).not.toBe(null); expect(UI.queryContextMenu()).toBe(null); }); }); describe("ui={{ enabled: ... }}", () => { beforeEach(() => { mockBoundingClientRect(); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); const rectangle = () => API.createElement({ type: "rectangle", x: 10, y: 10, width: 50, height: 50, }); const scrollAwayFromContent = async () => { act(() => { h.app.setState({ scrollX: -10_000, scrollY: -10_000 }); }); await waitFor(() => expect(h.state.scrolledOutside).toBe(true)); }; it("renders opted-in zoom controls while the rest of the default UI stays hidden", async () => { await render(); expect(queryContainer(".zoom-actions")).not.toBe(null); expect(queryContainer(".undo-redo-buttons")).toBe(null); expect(queryContainer(".App-toolbar")).toBe(null); expect(queryContainer(".dropdown-menu-button")).toBe(null); expect(queryContainer(".default-sidebar-trigger")).toBe(null); expect(queryContainer(".help-icon")).toBe(null); expect( queryContainer(".excalidraw-container")!.classList.contains( "excalidraw--ui-hidden", ), ).toBe(true); }); it("renders an opted-in scroll-back-to-content control", async () => { await render( , ); expect(queryContainer(".zoom-actions")).toBe(null); expect(queryContainer(".scroll-back-to-content")).toBe(null); await scrollAwayFromContent(); const scrollBackButton = queryContainer(".scroll-back-to-content"); expect(scrollBackButton).not.toBe(null); fireEvent.click(scrollBackButton!); await waitFor(() => expect(h.state.scrolledOutside).toBe(false)); }); it("keeps omitted or false controls hidden", async () => { await render( , ); await scrollAwayFromContent(); expect(queryContainer(".zoom-actions")).toBe(null); expect(queryContainer(".scroll-back-to-content")).toBe(null); }); it("supports the scroll-back-to-content control on mobile", async () => { await render( "phone" }} initialData={{ elements: [rectangle()] }} />, ); fireEvent.resize(window); await waitFor(() => expect(h.app.editorInterface.formFactor).toBe("phone")); await scrollAwayFromContent(); expect(queryContainer(".scroll-back-to-content")).not.toBe(null); expect(queryContainer(".mobile-toolbar")).toBe(null); }); }); describe("ui={false} with host UI", () => { it("renders host outlets and dialogs invoked by host UI", async () => { const { container } = await render(
} renderTopRightUI={() =>
} > {}}>host menu item
, ); expect(queryContainer("[data-testid='host-top-left']")).not.toBe(null); expect(queryContainer("[data-testid='host-top-right']")).not.toBe(null); expect(queryContainer("[data-testid='host-footer']")).not.toBe(null); expect(queryContainer("[aria-label='host sidebar']")).not.toBe(null); fireEvent.click(container.querySelector(".dropdown-menu-button")!); expect(container.textContent).toContain("host menu item"); // The internal fallback menu must not be composed. expect(queryContainer("[data-testid='load-button']")).toBe(null); fireEvent.click(queryContainer("[data-testid='image-export-button']")!); await waitFor(() => { expect(document.querySelector(".ImageExportModal")).not.toBe(null); }); }); }); describe("interaction={false} ui={false}", () => { beforeEach(async () => { mockBoundingClientRect(); await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("renders canvases without chrome and stays fully inert", () => { expect(h.state.viewModeEnabled).toBe(true); // canvases and host-placement infrastructure render, default chrome does // not expect(queryContainer("canvas.static")).not.toBe(null); expect(queryContainer("canvas.interactive")).not.toBe(null); expect(queryContainer(".App-toolbar")).toBe(null); expect(queryContainer(".dropdown-menu-button")).toBe(null); const container = queryContainer(".excalidraw-container")!; expect(container.classList.contains("excalidraw--non-interactive")).toBe( true, ); expect(container.classList.contains("excalidraw--ui-hidden")).toBe(true); // wheel / keyboard / pointer / contextmenu are all no-ops const { scrollX, scrollY } = h.state; const zoom = h.state.zoom.value; wheelZoom(); wheelPan(); pressZoomShortcut(CODES.EQUAL); expect(h.state.zoom.value).toBe(zoom); expect(h.state.scrollX).toBe(scrollX); expect(h.state.scrollY).toBe(scrollY); mouse.downAt(30, 30); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect(h.state.selectedElementIds).toEqual({}); expect(h.state.scrollX).toBe(scrollX); expect(h.state.scrollY).toBe(scrollY); rightClickCanvas(); expect(h.state.contextMenu).toBe(null); }); }); describe("interaction={{ enabled: { links } }}", () => { let onLinkOpenSpy: ReturnType; let windowOpenSpy: MockInstance; // prevent default inside the handler so the editor doesn't call // window.open (mocked anyway, defensively) const onLinkOpen: NonNullable = (...args) => { onLinkOpenSpy(...args); args[1].preventDefault(); }; beforeEach(() => { onLinkOpenSpy = vi.fn(); windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); }); afterEach(() => { windowOpenSpy.mockRestore(); }); // NOTE `API.createElement` does not propagate `link`, so it's set via // `API.updateElement` (same as laser.test.tsx) const addRect = (link: string | null = null) => { const rect = API.createElement({ type: "rectangle", x: 20, y: 20, width: 120, height: 90, }); API.setElements([rect]); if (link) { API.updateElement(rect, { link }); } return rect; }; // the link-mode pointerup relies on `hitLinkElement` being set during a // preceding pointermove (on non-touchscreen devices), so hover first const clickElementCenter = () => { mouse.reset(); mouse.moveTo(80, 65); mouse.clickAt(80, 65); }; it("{enabled: {links: true}}: editor is otherwise inert", async () => { await render( , ); expect(h.app.isLinksEnabled()).toBe(true); expect(h.state.viewModeEnabled).toBe(true); expect(queryContainer(".excalidraw--non-interactive")).not.toBe(null); const zoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBe(zoom); pressZoomShortcut(CODES.EQUAL); expect(h.state.zoom.value).toBe(zoom); rightClickCanvas(); expect(h.state.contextMenu).toBe(null); }); it("{enabled: {links: true}}: clicking a linked element opens the link", async () => { await render( , ); const linkedRect = addRect("https://excalidraw.com"); // hovering a linked element shows the pointer cursor (view-mode style: // anywhere on the element counts, not just the link icon) mouse.reset(); mouse.moveTo(80, 65); expect(GlobalTestState.interactiveCanvas.style.cursor).toBe( CURSOR_TYPE.POINTER, ); mouse.clickAt(80, 65); expect(onLinkOpenSpy).toHaveBeenCalledTimes(1); expect(onLinkOpenSpy.mock.calls[0][0]).toMatchObject({ id: linkedRect.id, link: "https://excalidraw.com", }); // the custom event was defaultPrevented, so no window.open expect(windowOpenSpy).not.toHaveBeenCalled(); // and the click didn't select the element expect(h.state.selectedElementIds).toEqual({}); }); it("{enabled: {links: true}}: clicking an element without a link does nothing", async () => { await render( , ); addRect(null); clickElementCenter(); expect(onLinkOpenSpy).not.toHaveBeenCalled(); expect(windowOpenSpy).not.toHaveBeenCalled(); expect(h.state.selectedElementIds).toEqual({}); }); it("{enabled: {links: false}} and {} behave like interaction={false}", async () => { await render( , ); addRect("https://excalidraw.com"); expect(h.app.isLinksEnabled()).toBe(false); expect(h.state.viewModeEnabled).toBe(true); expect(queryContainer(".excalidraw--non-interactive")).not.toBe(null); clickElementCenter(); expect(onLinkOpenSpy).not.toHaveBeenCalled(); // empty object form is equivalent GlobalTestState.renderResult.rerender( , ); expect(h.app.isLinksEnabled()).toBe(false); expect(h.state.viewModeEnabled).toBe(true); clickElementCenter(); expect(onLinkOpenSpy).not.toHaveBeenCalled(); expect(windowOpenSpy).not.toHaveBeenCalled(); }); it("interaction={false}: links are disabled", async () => { await render(); addRect("https://excalidraw.com"); expect(h.app.isLinksEnabled()).toBe(false); clickElementCenter(); expect(onLinkOpenSpy).not.toHaveBeenCalled(); expect(windowOpenSpy).not.toHaveBeenCalled(); }); it("toggling enabled.links false → true at runtime enables link clicks", async () => { await render( , ); const linkedRect = addRect("https://excalidraw.com"); clickElementCenter(); expect(onLinkOpenSpy).not.toHaveBeenCalled(); GlobalTestState.renderResult.rerender( , ); expect(h.app.isLinksEnabled()).toBe(true); // still non-interactive overall expect(h.state.viewModeEnabled).toBe(true); clickElementCenter(); expect(onLinkOpenSpy).toHaveBeenCalledTimes(1); expect(onLinkOpenSpy.mock.calls[0][0]).toMatchObject({ id: linkedRect.id, }); }); }); describe("interaction={{ enabled: { navigation } }}", () => { beforeEach(async () => { mockBoundingClientRect(); await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("wheel pans & ctrl+wheel zooms the canvas", () => { expect(h.app.isNavigationEnabled()).toBe(true); expect(h.state.viewModeEnabled).toBe(true); expect(queryContainer(".excalidraw--navigation")).not.toBe(null); const { scrollX, scrollY } = h.state; wheelPan(); expect([h.state.scrollX, h.state.scrollY]).not.toEqual([scrollX, scrollY]); const zoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBeGreaterThan(zoom); }); it("pointer drag pans without selecting", () => { // the grab cursor applies from mount (managed imperatively) expect(GlobalTestState.interactiveCanvas.style.cursor).toBe( CURSOR_TYPE.GRAB, ); const { scrollX, scrollY } = h.state; mouse.downAt(80, 80); mouse.moveTo(30, 30); mouse.upAt(30, 30); expect([h.state.scrollX, h.state.scrollY]).not.toEqual([scrollX, scrollY]); expect(h.state.selectedElementIds).toEqual({}); }); it("supports page-scroll keys (PageUp/PageDown)", () => { const { scrollY } = h.state; fireEvent.keyDown(document, { key: "PageDown", code: "PageDown" }); expect(h.state.scrollY).toBeLessThan(scrollY); fireEvent.keyDown(document, { key: "PageUp", code: "PageUp" }); expect(h.state.scrollY).toBe(scrollY); // shift scrolls horizontally const { scrollX } = h.state; fireEvent.keyDown(document, { key: "PageDown", code: "PageDown", shiftKey: true, }); expect(h.state.scrollX).toBeLessThan(scrollX); expect(h.state.scrollY).toBe(scrollY); }); it("supports canvas zoom & zoom-to-fit keyboard shortcuts", () => { // ctrl+"=" zooms in (and is prevented from zooming the browser) const zoom = h.state.zoom.value; expect( fireEvent.keyDown(document, { ctrlKey: true, code: CODES.EQUAL }), ).toBe(false); expect(h.state.zoom.value).toBeGreaterThan(zoom); // ctrl+"0" resets zoom fireEvent.keyDown(document, { ctrlKey: true, code: CODES.ZERO }); expect(h.state.zoom.value).toBe(1); // shift+"1" fits all elements const { scrollX, scrollY } = h.state; fireEvent.keyDown(document, { shiftKey: true, code: CODES.ONE }); expect([h.state.scrollX, h.state.scrollY]).not.toEqual([scrollX, scrollY]); }); it("editor is otherwise inert", () => { // non-navigation shortcuts stay disabled (e.g. select all) fireEvent.keyDown(document, { ctrlKey: true, key: "a", code: "KeyA" }); expect(h.state.selectedElementIds).toEqual({}); // no selection, no context menu mouse.reset(); mouse.clickAt(30, 30); expect(h.state.selectedElementIds).toEqual({}); rightClickCanvas(); expect(h.state.contextMenu).toBe(null); }); it("composes with links", async () => { const onLinkOpenSpy = vi.fn(); const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); GlobalTestState.renderResult.rerender( { onLinkOpenSpy(...args); args[1].preventDefault(); }} />, ); const rect = API.createElement({ type: "rectangle", x: 20, y: 20, width: 120, height: 90, }); API.setElements([rect]); API.updateElement(rect, { link: "https://excalidraw.com" }); mouse.reset(); mouse.moveTo(80, 65); mouse.clickAt(80, 65); expect(onLinkOpenSpy).toHaveBeenCalledTimes(1); expect(onLinkOpenSpy.mock.calls[0][0]).toMatchObject({ id: rect.id }); windowOpenSpy.mockRestore(); }); it("toggling enabled.navigation at runtime re-attaches listeners", () => { GlobalTestState.renderResult.rerender( , ); const zoom = h.state.zoom.value; wheelZoom(); expect(h.state.zoom.value).toBe(zoom); GlobalTestState.renderResult.rerender( , ); wheelZoom(); expect(h.state.zoom.value).toBeGreaterThan(zoom); }); }); describe("interaction={{ enabled: { embeds / interactiveContent } }}", () => { beforeEach(() => { mockBoundingClientRect(); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); const addEmbeddable = () => { const embed = API.createElement({ type: "embeddable", x: 20, y: 20, width: 120, height: 90, }); API.setElements([embed]); API.updateElement(embed, { link: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", }); return embed; }; it("embeds: hovering & clicking an embeddable activates it", async () => { await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); const embed = addEmbeddable(); expect(h.app.isEmbedsEnabled()).toBe(true); expect(h.app.isLinksEnabled()).toBe(false); expect(queryContainer(".excalidraw--embeds")).not.toBe(null); mouse.reset(); mouse.moveTo(80, 65); expect(h.state.activeEmbeddable).toMatchObject({ element: expect.objectContaining({ id: embed.id }), state: "hover", }); mouse.clickAt(80, 65); // activation is deferred (see handleIframeLikeCenterClick) await waitFor(() => { expect(h.state.activeEmbeddable).toMatchObject({ state: "active" }); }); // clicking outside deactivates (clicks inside an active embed are // consumed by the embed itself) mouse.clickAt(5, 5); expect(h.state.activeEmbeddable).toBe(null); }); it("without embeds allowed, embeddables stay inert", async () => { await render(); await waitFor(() => expect(h.state.width).toBe(200)); addEmbeddable(); expect(h.app.isEmbedsEnabled()).toBe(false); expect(queryContainer(".excalidraw--embeds")).toBe(null); mouse.reset(); mouse.moveTo(80, 65); mouse.clickAt(80, 65); expect(h.state.activeEmbeddable).toBe(null); }); it("interactiveContent enables links & embeds together (additive)", async () => { await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); expect(h.app.isLinksEnabled()).toBe(true); expect(h.app.isEmbedsEnabled()).toBe(true); // still non-interactive overall expect(h.state.viewModeEnabled).toBe(true); expect(h.app.isInteractionEnabled()).toBe(false); // additive: explicit false on individual keys doesn't override the // umbrella GlobalTestState.renderResult.rerender( , ); expect(h.app.isLinksEnabled()).toBe(true); expect(h.app.isEmbedsEnabled()).toBe(true); }); }); describe("interaction={{ enabled: { tools } }}", () => { const onPointerDownSpy = vi.fn(); const onPointerUpSpy = vi.fn(); const onPointerUpdateSpy = vi.fn(); const renderWithInteraction = async ( interaction: ExcalidrawProps["interaction"], ) => { await render( , ); await waitFor(() => expect(h.state.width).toBe(200)); Object.assign(document, { elementFromPoint: () => GlobalTestState.canvas, }); }; const rerenderWithInteraction = ( interaction: ExcalidrawProps["interaction"], ) => { GlobalTestState.renderResult.rerender( , ); }; beforeEach(() => { onPointerDownSpy.mockClear(); onPointerUpSpy.mockClear(); onPointerUpdateSpy.mockClear(); mockBoundingClientRect(); }); afterEach(() => { restoreOriginalGetBoundingClientRect(); }); it("laser: pointer strokes draw the laser trail without editing effects", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); act(() => { h.app.setActiveTool({ type: "laser" }); }); expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(true); expect(h.state.viewModeEnabled).toBe(true); expect(h.app.isInteractionEnabled()).toBe(false); const { scrollX, scrollY } = h.state; // stroke starting on top of the rectangle mouse.downAt(30, 30); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true); expect(onPointerDownSpy).toHaveBeenCalledTimes(1); expect(onPointerDownSpy.mock.calls[0][0]).toMatchObject({ type: "laser" }); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false); // no selection, no panning, no scene changes expect(h.state.selectedElementIds).toEqual({}); expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]); expect(h.elements.length).toBe(1); }); it("laser: pointer positions broadcast via onPointerUpdate", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); act(() => { h.app.setActiveTool({ type: "laser" }); }); // between strokes (plain hover) mouse.moveTo(50, 50); expect(onPointerUpdateSpy).toHaveBeenCalled(); expect(onPointerUpdateSpy.mock.calls.at(-1)![0].pointer.tool).toBe("laser"); // during a stroke onPointerUpdateSpy.mockClear(); mouse.downAt(30, 30); mouse.moveTo(60, 60); mouse.upAt(60, 60); expect( onPointerUpdateSpy.mock.calls.some( ([payload]) => payload.button === "down", ), ).toBe(true); expect(onPointerUpdateSpy.mock.calls.at(-1)![0].button).toBe("up"); }); it("custom: onPointerDown/onPointerUp keep dispatching when enabled", async () => { await renderWithInteraction({ enabled: { tools: { custom: true } } }); act(() => { h.app.setActiveTool({ type: "custom", customType: "comment", locked: true, }); }); expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(true); mouse.downAt(40, 40); expect(onPointerDownSpy).toHaveBeenCalledTimes(1); expect(onPointerDownSpy.mock.calls[0][0]).toMatchObject({ type: "custom", customType: "comment", }); mouse.upAt(40, 40); expect(onPointerUpSpy).toHaveBeenCalledTimes(1); expect(onPointerUpSpy.mock.calls[0][0]).toMatchObject({ type: "custom", customType: "comment", }); // tool stays active (activated with `locked: true`), scene untouched expect(h.state.activeTool.type).toBe("custom"); expect(h.state.selectedElementIds).toEqual({}); expect(h.elements.length).toBe(1); }); it("pointer input stays inert when the active tool is not enabled", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); // default (selection) tool is not in the enabled set expect(h.state.activeTool.type).toBe("selection"); expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false); mouse.downAt(30, 30); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect(h.state.selectedElementIds).toEqual({}); expect(onPointerDownSpy).not.toHaveBeenCalled(); expect(onPointerUpdateSpy).not.toHaveBeenCalled(); // a custom tool isn't covered by `tools.laser` — the switch itself is // refused act(() => { h.app.setActiveTool({ type: "custom", customType: "comment", locked: true, }); }); expect(h.state.activeTool.type).toBe("selection"); expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false); mouse.reset(); mouse.downAt(40, 40); expect(onPointerDownSpy).not.toHaveBeenCalled(); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false); }); it("setActiveTool only activates enabled tools while non-interactive", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); // enabled tool activates act(() => { h.app.setActiveTool({ type: "laser" }); }); expect(h.state.activeTool.type).toBe("laser"); // tools outside the enabled set are refused act(() => { h.app.setActiveTool({ type: "rectangle" }); }); expect(h.state.activeTool.type).toBe("laser"); act(() => { h.app.setActiveTool({ type: "hand" }); }); expect(h.state.activeTool.type).toBe("laser"); }); it("active tool resets to selection when it loses its interaction exception", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); act(() => { h.app.setActiveTool({ type: "laser" }); }); expect(h.state.activeTool.type).toBe("laser"); // exception removed (e.g. presenter → viewer) → neutral default rerenderWithInteraction(false); expect(h.state.activeTool.type).toBe("selection"); expect(queryContainer(".excalidraw--tools")).toBe(null); // re-enabling the exception doesn't restore the tool — tool selection // stays host-driven rerenderWithInteraction({ enabled: { tools: { laser: true } } }); expect(h.state.activeTool.type).toBe("selection"); }); it("interaction={false} refuses tool switching and keeps the laser inert", async () => { await renderWithInteraction(false); act(() => { h.app.setActiveTool({ type: "laser" }); }); // the switch is refused — no tool is enabled while fully non-interactive expect(h.state.activeTool.type).toBe("selection"); expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false); mouse.downAt(30, 30); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false); expect(onPointerDownSpy).not.toHaveBeenCalled(); expect(onPointerUpdateSpy).not.toHaveBeenCalled(); }); it("container gets the excalidraw--tools class only while the active tool is enabled", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); // selection active — not in the enabled set expect(queryContainer(".excalidraw--tools")).toBe(null); act(() => { h.app.setActiveTool({ type: "laser" }); }); expect(queryContainer(".excalidraw--tools")).not.toBe(null); rerenderWithInteraction(false); expect(queryContainer(".excalidraw--tools")).toBe(null); }); it("editor is otherwise inert (keyboard, context menu, wheel, eraser button)", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); act(() => { h.app.setActiveTool({ type: "laser" }); }); // letter shortcuts don't switch tools fireEvent.keyDown(document, { key: "r", code: "KeyR" }); fireEvent.keyDown(document, { key: "v", code: "KeyV" }); expect(h.state.activeTool.type).toBe("laser"); // the pen's hardware eraser button doesn't switch tools either fireEvent.pointerDown(GlobalTestState.interactiveCanvas, { button: POINTER_BUTTON.ERASER, clientX: 30, clientY: 30, }); expect(h.state.activeTool.type).toBe("laser"); fireEvent.pointerUp(GlobalTestState.interactiveCanvas, { button: POINTER_BUTTON.ERASER, clientX: 30, clientY: 30, }); rightClickCanvas(); expect(h.state.contextMenu).toBe(null); // wheel stays with the page (no navigation enabled) const { scrollX, scrollY } = h.state; const zoom = h.state.zoom.value; wheelPan(); wheelZoom(); expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]); expect(h.state.zoom.value).toBe(zoom); }); it("composes with navigation: drag draws the laser, wheel pans", async () => { await renderWithInteraction({ enabled: { navigation: true, tools: { laser: true } }, }); act(() => { h.app.setActiveTool({ type: "laser" }); }); wheelPan(); const { scrollX, scrollY } = h.state; expect([scrollX, scrollY]).not.toEqual([0, 0]); // primary-pointer drag goes to the laser, not panning mouse.downAt(30, 30); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true); mouse.moveTo(80, 80); mouse.upAt(80, 80); expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]); }); it("disabling the tool exception mid-stroke ends the trail", async () => { await renderWithInteraction({ enabled: { tools: { laser: true } } }); act(() => { h.app.setActiveTool({ type: "laser" }); }); mouse.downAt(30, 30); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true); rerenderWithInteraction(false); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false); mouse.upAt(30, 30); expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false); }); });