diff --git a/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx b/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx new file mode 100644 index 000000000..63264b2e3 --- /dev/null +++ b/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx @@ -0,0 +1,68 @@ +// The import modal's wheel: over the preview it scales the image, and the +// card behind it stays where it is. jsdom loads no images, so a stand-in +// Image reports itself loaded as soon as it is given a source. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ImportImageModal from "./ImportImageModal"; +import { useLabStore } from "@/lib/lab/store"; + +class LoadedImage { + width = 32; + height = 16; + onload: (() => void) | null = null; + set src(_url: string) { + queueMicrotask(() => this.onload?.()); + } +} + +function mount() { + useLabStore.getState().discardProject(); + useLabStore.getState().addPixelLayer(); + const layer = useLabStore.getState().layers.find((entry) => entry.type === "pixel")!; + render( + {}} onClose={() => {}} />, + ); + return screen.getByLabelText(/^Import preview/); +} + +async function loadImage() { + const file = new File(["x"], "pic.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("Choose image"), { target: { files: [file] } }); + await waitFor(() => expect(screen.getByText("Apply to layer")).toBeEnabled()); +} + +beforeEach(() => { + vi.stubGlobal("Image", LoadedImage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ImportImageModal", () => { + it("leaves the wheel alone until there is an image to scale", () => { + const preview = mount(); + const up = createEvent.wheel(preview, { deltaY: -100 }); + fireEvent(preview, up); + expect(up.defaultPrevented).toBe(false); + expect(screen.getByText(/100%/)).toBeTruthy(); + }); + + it("the wheel scales the image and does not scroll the card", async () => { + const preview = mount(); + await loadImage(); + const up = createEvent.wheel(preview, { deltaY: -100 }); + fireEvent(preview, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText(/108%/)).toBeTruthy(); + const down = createEvent.wheel(preview, { deltaY: 100 }); + fireEvent(preview, down); + expect(down.defaultPrevented).toBe(true); + expect(screen.getByText(/100%/)).toBeTruthy(); + const sideways = createEvent.wheel(preview, { deltaX: 100, deltaY: 0 }); + fireEvent(preview, sideways); + expect(sideways.defaultPrevented).toBe(false); + expect(screen.getByText(/100%/)).toBeTruthy(); + }); +}); diff --git a/web/src/app/pattern-lab/panels/ImportImageModal.tsx b/web/src/app/pattern-lab/panels/ImportImageModal.tsx index 8c630951b..666069c79 100644 --- a/web/src/app/pattern-lab/panels/ImportImageModal.tsx +++ b/web/src/app/pattern-lab/panels/ImportImageModal.tsx @@ -11,6 +11,8 @@ import { labEngine } from "@/lib/lab/engine"; import { useLabStore } from "@/lib/lab/store"; import styles from "../PatternLab.module.css"; +const clampScale = (value: number) => Math.max(0.05, Math.min(8, value)); + export default function ImportImageModal({ width, height, @@ -138,7 +140,21 @@ export default function ImportImageModal({ context.putImageData(imageData, 0, 0); }, [image, fit, smooth, removeBg, bgTolerance, scale, offset, width, height]); - const clampScale = (value: number) => Math.max(0.05, Math.min(8, value)); + // Wheel over the preview scales the image. A native listener, because + // React delegates wheel passively and preventDefault there would let the + // card scroll under the zoom. A sideways wheel has no deltaY and stays a + // scroll. + useEffect(() => { + const canvas = previewRef.current; + if (!canvas || !image) return; + const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + setScale((current) => clampScale(current * (event.deltaY < 0 ? 1.08 : 1 / 1.08))); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [image]); const onPreviewPointerDown = (event: React.PointerEvent) => { if (!image || event.button !== 0) return; @@ -231,11 +247,6 @@ export default function ImportImageModal({ onPointerMove={onPreviewPointerMove} onPointerUp={() => (dragRef.current = null)} onPointerCancel={() => (dragRef.current = null)} - onWheel={(event) => { - if (!image) return; - event.preventDefault(); - setScale((current) => clampScale(current * (event.deltaY < 0 ? 1.08 : 1 / 1.08))); - }} />
{ expect(screen.getByText("zoom 5×")).toBeTruthy(); }); + it("the wheel zooms the drawing and does not scroll the panel", () => { + const { viewport } = mount(); + const up = createEvent.wheel(viewport, { deltaY: -100 }); + fireEvent(viewport, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 7×")).toBeTruthy(); + const down = createEvent.wheel(viewport, { deltaY: 100 }); + fireEvent(viewport, down); + expect(down.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + // a sideways wheel has no up or down: it scrolls the viewport, zoom stays + const sideways = createEvent.wheel(viewport, { deltaX: 100, deltaY: 0 }); + fireEvent(viewport, sideways); + expect(sideways.defaultPrevented).toBe(false); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + }); + + it("the wheel zooms once a pixel layer is picked after a code layer", () => { + const state = useLabStore.getState(); + state.selectLayer(state.layers.find((entry) => entry.type === "code")!.id); + const { container } = render(); + act(() => useLabStore.getState().selectLayer(pixelLayer().id)); + const viewport = container.querySelector('[tabindex="0"]') as HTMLDivElement; + const up = createEvent.wheel(viewport, { deltaY: -100 }); + fireEvent(viewport, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 7×")).toBeTruthy(); + }); + it("switching layers drops the selection", () => { const { overlay, viewport } = mount(); fireEvent.keyDown(viewport, { key: "s" }); diff --git a/web/src/app/pattern-lab/panels/PixelPanel.tsx b/web/src/app/pattern-lab/panels/PixelPanel.tsx index a10a5e17a..dd9a28ed3 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.tsx @@ -166,7 +166,6 @@ export default function PixelPanel() { className={dock.pixelViewport} tabIndex={0} onKeyDown={onKeyDown} - onWheel={viewport.onWheel} >
{ + // The wheel zooms. A native listener, because React delegates wheel + // passively and preventDefault there would let the viewport and the dock + // scroll under the zoom (useTimelineZoom does the same). A sideways wheel + // has no deltaY and stays a scroll. The viewport is only rendered while a + // pixel layer is selected, so the listener goes on when one is. + const hasPixel = pixel !== undefined; + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; event.preventDefault(); zoomBy(event.deltaY < 0 ? 1 : -1); - }, - [zoomBy], - ); + }; + viewport.addEventListener("wheel", onWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", onWheel); + }, [hasPixel, viewportRef, zoomBy]); /** Middle button: start panning. True when the press was taken. */ const beginPan = useCallback( @@ -121,7 +131,6 @@ export function usePixelViewport({ zoom, zoomBy, fitZoom, - onWheel, panning, hoverCell, setHoverCell,