Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions web/src/app/pattern-lab/panels/ImportImageModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ImportImageModal width={128} height={64} layerId={layer.id} onApply={() => {}} 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();
});
});
23 changes: 17 additions & 6 deletions web/src/app/pattern-lab/panels/ImportImageModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLCanvasElement>) => {
if (!image || event.button !== 0) return;
Expand Down Expand Up @@ -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)));
}}
/>
</div>
<div
Expand Down
31 changes: 30 additions & 1 deletion web/src/app/pattern-lab/panels/PixelPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// thing the device receives — not what the canvas shows.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import PixelPanel from "./PixelPanel";
import { useLabStore } from "@/lib/lab/store";
import type { PixelLayer } from "@/lib/lab/types";
Expand Down Expand Up @@ -246,6 +246,35 @@ describe("PixelPanel", () => {
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(<PixelPanel />);
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" });
Expand Down
1 change: 0 additions & 1 deletion web/src/app/pattern-lab/panels/PixelPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ export default function PixelPanel() {
className={dock.pixelViewport}
tabIndex={0}
onKeyDown={onKeyDown}
onWheel={viewport.onWheel}
>
<div
className={dock.pixelStage}
Expand Down
23 changes: 16 additions & 7 deletions web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// box. The refs belong to the panel (it renders the elements); this hook is
// handed them.

import { useCallback, useRef, useState, type RefObject } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { PixelLayer } from "@/lib/lab/types";
import type { Cell } from "./tools";

Expand Down Expand Up @@ -68,13 +68,23 @@ export function usePixelViewport({
setZoom(next);
}, [pixel, viewportRef]);

const onWheel = useCallback(
(event: React.WheelEvent) => {
// 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(
Expand Down Expand Up @@ -121,7 +131,6 @@ export function usePixelViewport({
zoom,
zoomBy,
fitZoom,
onWheel,
panning,
hoverCell,
setHoverCell,
Expand Down
Loading