diff --git a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx index dedbb95f3..9ca7ed329 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx @@ -432,6 +432,42 @@ describe("PixelPanel", () => { expect(viewport.scrollTop).toBeGreaterThan(0); }); + it("the zoom buttons keep the cell in the middle of the panel where it was", () => { + // a 300×200 panel, so the picture opens fitted at 2× + panelSize(300, 200); + const { overlay, viewport } = mount(); + expect(screen.getByText("zoom 2×")).toBeTruthy(); + // laid out as in the wheel test above; the viewport's box starts at 0, 0 + const stage = overlay.parentElement as HTMLDivElement; + Object.defineProperty(overlay, "getBoundingClientRect", { + configurable: true, + value: () => { + const left = 16 - viewport.scrollLeft; + const top = 16 - viewport.scrollTop; + const width = parseFloat(stage.style.width); + const height = parseFloat(stage.style.height); + return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) }; + }, + }); + for (let i = 0; i < 4; i++) fireEvent.click(screen.getByLabelText("Zoom in")); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + // scroll the middle of cell (50, 20) to the middle of the panel, (150, 100) + viewport.scrollLeft = 50.5 * 6 + 16 - 150; + viewport.scrollTop = 20.5 * 6 + 16 - 100; + const middle = () => fireEvent.pointerMove(overlay, at(150, 100, { buttons: 0 })); + middle(); + expect(screen.getByText("50,20")).toBeTruthy(); + for (const [button, zoom] of [["Zoom in", 7], ["Zoom in", 8], ["Zoom out", 7], ["Zoom out", 6]] as const) { + fireEvent.click(screen.getByLabelText(button)); + expect(screen.getByText(`zoom ${zoom}×`)).toBeTruthy(); + middle(); + expect(screen.getByText("50,20")).toBeTruthy(); + } + // the same scroll it started from, after going up two and down two + expect(viewport.scrollLeft).toBeCloseTo(50.5 * 6 + 16 - 150, 6); + expect(viewport.scrollTop).toBeCloseTo(20.5 * 6 + 16 - 100, 6); + }); + it("switching layers drops the selection", () => { const { overlay, viewport } = mount(); fireEvent.keyDown(viewport, { key: "s" }); diff --git a/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts b/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts index 9c9bacff3..e3ae44f21 100644 --- a/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts +++ b/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts @@ -65,9 +65,48 @@ export function usePixelViewport({ [pixel, overlayCanvasRef], ); - const zoomBy = useCallback((delta: number) => { - setZoom((current) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current + delta))); - }, []); + // Where a zoom holds still: a spot on the picture (as a fraction of it) + // and the screen point it sat under, so the layout effect below can scroll + // that spot back there. The wheel holds the spot under the cursor, the ± + // buttons the one in the middle of the viewport. + const zoomAnchor = useRef<{ fx: number; fy: number; clientX: number; clientY: number } | null>(null); + const anchorAt = useCallback( + (clientX: number, clientY: number) => { + const rect = overlayCanvasRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return; + const fraction = (offset: number, size: number) => Math.max(0, Math.min(1, offset / size)); + zoomAnchor.current = { + fx: fraction(clientX - rect.left, rect.width), + fy: fraction(clientY - rect.top, rect.height), + clientX, + clientY, + }; + }, + [overlayCanvasRef], + ); + + // The ± buttons keep the middle of what the viewport shows in the middle, + // as Director's ± does (useTimelineZoom). Zooming from the top-left corner + // moved the middle from column 64 to 58 on one + at 10× in the 649 px + // desktop panel. + const zoomBy = useCallback( + (delta: number) => { + const current = zoomRef.current; + const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current + delta)); + if (next === current) return; + const viewport = viewportRef.current; + if (viewport) { + const box = viewport.getBoundingClientRect(); + anchorAt( + box.left + viewport.clientLeft + viewport.clientWidth / 2, + box.top + viewport.clientTop + viewport.clientHeight / 2, + ); + } + zoomRef.current = next; + setZoom(next); + }, + [viewportRef, anchorAt], + ); const fitZoom = useCallback(() => { if (!pixel) return; @@ -115,9 +154,6 @@ export function usePixelViewport({ // across events, so a trackpad swipe of thirty 4 px events is one step // too, not thirty. Turning the other way drops what was left over. const wheelRest = useRef(0); - // Where the cursor was over the picture (as a fraction of it) and on the - // screen, so the layout effect below can scroll that spot back under it. - const wheelAnchor = useRef<{ fx: number; fy: number; clientX: number; clientY: number } | null>(null); const hasPixel = pixel !== undefined; useEffect(() => { const viewport = viewportRef.current; @@ -137,30 +173,21 @@ export function usePixelViewport({ if (steps !== 0) wheelRest.current = 0; return; } - const rect = overlayCanvasRef.current?.getBoundingClientRect(); - if (rect && rect.width > 0 && rect.height > 0) { - const fraction = (offset: number, size: number) => Math.max(0, Math.min(1, offset / size)); - wheelAnchor.current = { - fx: fraction(event.clientX - rect.left, rect.width), - fy: fraction(event.clientY - rect.top, rect.height), - clientX: event.clientX, - clientY: event.clientY, - }; - } + anchorAt(event.clientX, event.clientY); zoomRef.current = next; setZoom(next); }; viewport.addEventListener("wheel", onWheel, { passive: false }); return () => viewport.removeEventListener("wheel", onWheel); - }, [hasPixel, viewportRef, overlayCanvasRef]); + }, [hasPixel, viewportRef, anchorAt]); - // After a zoom: scroll the spot the wheel was over back under the cursor. - // The browser clamps the scroll, so a picture smaller than the viewport - // stays centred. + // After a zoom: scroll the anchored spot back under its screen point. The + // browser clamps the scroll, so a picture smaller than the viewport stays + // centred. useLayoutEffect(() => { zoomRef.current = zoom; - const anchor = wheelAnchor.current; - wheelAnchor.current = null; + const anchor = zoomAnchor.current; + zoomAnchor.current = null; const viewport = viewportRef.current; const canvas = overlayCanvasRef.current; if (!anchor || !viewport || !canvas) return;