import { act, cleanup, createEvent, fireEvent, screen, waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import { useToolcraftStore } from "../app-shell/toolcraft-store-context"; import { CanvasStateProbe, createSchema, mockCanvasRect, renderCanvasShell, } from "./canvas-shell-test-utils"; afterEach(() => { vi.useRealTimers(); cleanup(); window.localStorage.clear(); vi.restoreAllMocks(); }); function CaptureStore({ capture, }: { capture: (store: ToolcraftExternalStore) => void; }): null { capture(useToolcraftStore()); return null; } function createGestureEvent( type: "gesturechange" | "gestureend" | "gesturestart", properties: { clientX?: number; clientY?: number; scale?: number } = {}, ): Event { const event = new Event(type, { bubbles: true, cancelable: true }); Object.defineProperties( event, Object.fromEntries( Object.entries(properties).map(([key, value]) => [ key, { configurable: true, value }, ]), ), ); return event; } describe("CanvasShell viewport interactions", () => { it("updates runtime canvas offset while panning", () => { let store: ToolcraftExternalStore | undefined; renderCanvasShell({ afterCanvas: ( <> { store = value; }} /> ), }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); fireEvent.pointerDown(canvas, { button: 0, clientX: 10, clientY: 20, pointerId: 1 }); fireEvent.pointerMove(canvas, { clientX: 25, clientY: 45, pointerId: 1 }); expect(store?.getState().canvas.offset).toEqual({ x: 15, y: 25 }); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 0, y: 0 }); const commitTransient = vi.spyOn(store!, "commitTransient"); fireEvent.pointerUp(canvas, { pointerId: 1 }); expect(screen.getByTestId("canvas-offset").textContent).toBe("15,25"); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 15, y: 25 }); expect(commitTransient).toHaveBeenCalledTimes(1); expect(commitTransient).toHaveBeenCalledWith("viewport"); }); it("accumulates wheel movement from the latest effective viewport and commits after idle", () => { vi.useFakeTimers(); let store: ToolcraftExternalStore | undefined; renderCanvasShell({ afterCanvas: { store = value; }} />, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const dispatchWheel = () => canvas.dispatchEvent( new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX: 8, deltaY: -12, }), ); expect(dispatchWheel()).toBe(false); expect(dispatchWheel()).toBe(false); expect(store?.getState().canvas.offset).toEqual({ x: -16, y: 24 }); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 0, y: 0 }); act(() => { vi.advanceTimersByTime(119); }); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 0, y: 0 }); act(() => { vi.advanceTimersByTime(1); }); expect(store?.getCommittedState().canvas.offset).toEqual({ x: -16, y: 24 }); }); it("commits a pending wheel viewport when the canvas unmounts", () => { vi.useFakeTimers(); let store: ToolcraftExternalStore | undefined; const view = renderCanvasShell({ afterCanvas: { store = value; }} />, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); canvas.dispatchEvent( new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 20, }), ); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 0, y: 0 }); view.unmount(); expect(store?.getCommittedState().canvas.offset).toEqual({ x: 0, y: -20 }); }); it("uses the active transient viewport when positioning a dropped image", async () => { let store: ToolcraftExternalStore | undefined; renderCanvasShell({ afterCanvas: { store = value; }} />, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); mockCanvasRect(canvas); act(() => { store?.dispatchTransient({ offset: { x: 100, y: -50 }, type: "canvas.setViewport", zoom: 200, }); }); const dropEvent = createEvent.drop(canvas, { dataTransfer: { files: [new File(["image"], "positioned.png", { type: "image/png" })], }, }); Object.defineProperties(dropEvent, { clientX: { value: 500 }, clientY: { value: 400 }, }); fireEvent(canvas, dropEvent); await waitFor(() => { expect(store?.getState().mediaAssets).toHaveLength(1); }); const mediaAsset = store?.getState().mediaAssets[0]; expect( mediaAsset?.assetKind === "image" ? mediaAsset.position : undefined, ).toEqual({ x: 0, y: 75 }); }); it("pans the canvas from a two finger wheel gesture", async () => { renderCanvasShell({ afterCanvas: , }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const wheelEvent = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX: 8, deltaY: -12, }); expect(canvas.dispatchEvent(wheelEvent)).toBe(false); expect(wheelEvent.defaultPrevented).toBe(true); await waitFor(() => { expect(screen.getByTestId("canvas-offset").textContent).toBe("-8,12"); }); expect(screen.getByTestId("canvas-zoom").textContent).toBe("100"); }); it("zooms the canvas world from a trackpad pinch around the pointer", async () => { renderCanvasShell({ afterCanvas: , }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const world = canvas.querySelector("[data-toolcraft-canvas-world]") as HTMLElement; mockCanvasRect(canvas); expect(world.style.transform).toBe("translate(-50%, -50%) translate(0px, 0px) scale(1)"); const wheelEvent = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 600, clientY: 300, ctrlKey: true, deltaY: -100, }); expect(canvas.dispatchEvent(wheelEvent)).toBe(false); expect(wheelEvent.defaultPrevented).toBe(true); await waitFor(() => { expect(screen.getByTestId("canvas-zoom").textContent).toBe("150"); }); expect(world.style.transform).toContain("translate(-100px"); expect(world.style.transform).toContain("scale(1.5)"); }); it("prevents panel pinch gestures without moving the canvas viewport", async () => { renderCanvasShell({ afterCanvas: ( <> ), frameProps: { "data-slot": "toolcraft-runtime-app", } as React.HTMLAttributes, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const overlay = screen.getByTestId("panel-overlay"); mockCanvasRect(canvas); const wheelEvent = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 600, clientY: 300, ctrlKey: true, deltaY: -100, }); expect(overlay.dispatchEvent(wheelEvent)).toBe(false); expect(wheelEvent.defaultPrevented).toBe(true); expect(screen.getByTestId("canvas-zoom").textContent).toBe("100"); expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0"); expect(overlay.closest("[data-toolcraft-canvas-world]")).toBeNull(); const gestureStart = createGestureEvent("gesturestart", { scale: 1 }); const gestureChange = createGestureEvent("gesturechange", { scale: 1.5 }); const gestureEnd = createGestureEvent("gestureend"); expect(overlay.dispatchEvent(gestureStart)).toBe(false); expect(overlay.dispatchEvent(gestureChange)).toBe(false); expect(overlay.dispatchEvent(gestureEnd)).toBe(false); expect(gestureStart.defaultPrevented).toBe(true); expect(gestureChange.defaultPrevented).toBe(true); expect(gestureEnd.defaultPrevented).toBe(true); expect(screen.getByTestId("canvas-zoom").textContent).toBe("100"); expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0"); const scrollEvent = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80, }); expect(overlay.dispatchEvent(scrollEvent)).toBe(true); expect(scrollEvent.defaultPrevented).toBe(false); expect(screen.getByTestId("canvas-zoom").textContent).toBe("100"); expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0"); }); it("prevents browser pinch zoom when canvas zoom is already clamped", () => { renderCanvasShell({ afterCanvas: , initialState: { canvas: { offset: { x: 0, y: 0 }, size: { height: 200, unit: "px", width: 300 }, zoom: 400, }, }, schema: createSchema(), }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); mockCanvasRect(canvas); const wheelEvent = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 600, clientY: 300, ctrlKey: true, deltaY: -100, }); expect(canvas.dispatchEvent(wheelEvent)).toBe(false); expect(wheelEvent.defaultPrevented).toBe(true); expect(screen.getByTestId("canvas-zoom").textContent).toBe("400"); expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0"); }); it("treats clamp continuation and reverse as one persisted wheel boundary", () => { vi.useFakeTimers(); const setItem = vi.spyOn(Storage.prototype, "setItem"); const schema = defineToolcraft({ canvas: { enabled: true }, panels: {}, persistence: { include: ["canvas", "panels"], key: "toolcraft:wheel-clamp:state:v1", storage: "localStorage", version: 1, }, }); let store: ToolcraftExternalStore | undefined; renderCanvasShell({ afterCanvas: { store = value; }} />, initialState: { canvas: { offset: { x: 0, y: 0 }, size: { height: 200, unit: "px", width: 300 }, zoom: 350, }, }, schema, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); mockCanvasRect(canvas); let committedCanvas = store!.getCommittedState().canvas; let committedBoundaries = 0; const unsubscribe = store!.subscribe(() => { const nextCanvas = store!.getCommittedState().canvas; if (nextCanvas !== committedCanvas) { committedCanvas = nextCanvas; committedBoundaries += 1; } }); const pinch = (deltaY: number): void => { canvas.dispatchEvent( new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 400, clientY: 300, ctrlKey: true, deltaY, }), ); }; act(() => { vi.advanceTimersByTime(120); }); setItem.mockClear(); pinch(-100); act(() => { vi.advanceTimersByTime(100); }); pinch(-100); act(() => { vi.advanceTimersByTime(30); }); expect(store?.getCommittedState().canvas.zoom).toBe(350); expect(committedBoundaries).toBe(0); pinch(50); expect(store?.getState().canvas.zoom).toBe(375); expect(store?.getCommittedState().canvas.zoom).toBe(350); act(() => { vi.advanceTimersByTime(119); }); expect(committedBoundaries).toBe(0); expect(setItem).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(1); }); expect(store?.getCommittedState().canvas.zoom).toBe(375); expect(committedBoundaries).toBe(1); act(() => { vi.advanceTimersByTime(120); }); expect(setItem).toHaveBeenCalledTimes(1); expect( JSON.parse( window.localStorage.getItem("toolcraft:wheel-clamp:state:v1") ?? "{}", ), ).toMatchObject({ state: { canvas: { zoom: 375 } } }); unsubscribe(); }); });