From 74db11e8faf4ec153fee569b9649215d2cc87f31 Mon Sep 17 00:00:00 2001 From: Mihir Menon Date: Fri, 16 Oct 2026 10:09:22 -0700 Subject: [PATCH 1/2] feat(studio): type an exact preview zoom and see a pixel grid at high zoom Clicking the preview's zoom readout opens an input: Enter applies the typed percentage (clamped to the zoom limits, keeping the view's centre), Esc or blur cancels. Above 800% a one-pixel grid is drawn over the stage, one cell per composition pixel. It lives in Studio's DOM, so renders and snapshots never include it. The max preview zoom goes from 400% to 1600% so the 800% grid is reachable. Closes #5587 Co-Authored-By: Claude Opus 5.5 --- .../src/components/nle/NLEPreview.test.ts | 72 ++++++++ .../studio/src/components/nle/NLEPreview.tsx | 17 +- .../src/components/nle/PreviewZoomOverlay.tsx | 170 +++++++++++++++++- .../src/components/nle/previewZoom.test.ts | 98 ++++++++++ .../studio/src/components/nle/previewZoom.ts | 70 +++++++- packages/studio/src/styles/theme.css | 2 + 6 files changed, 410 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index 102206d9068..1be4410d213 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -390,6 +390,78 @@ describe("NLEPreview", () => { view.cleanup(); }); + /** Opens the zoom readout's input, types `text` into it and presses `key`. */ + function typeZoom(view: ReturnType, text: string, key: string) { + act(() => { + view.host.querySelector('[data-testid="preview-zoom-readout"]')!.click(); + }); + const input = view.host.querySelector( + '[data-testid="preview-zoom-input"]', + )!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input, + text, + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); + act(() => vi.advanceTimersByTime(300)); + } + const pixelGrid = (view: ReturnType) => + view.host.querySelector('[data-testid="preview-pixel-grid"]'); + + it("applies a typed zoom on Enter, clamped to the zoom limit, and closes the input", () => { + const view = renderPreview(); + pinchIn(view, 10); + typeZoom(view, "350%", "Enter"); + expect(view.stage.style.transform).toContain("scale(3.5)"); + expect(chip(view)?.textContent).toBe("Zoomed 350%·Fit"); + + typeZoom(view, "99999", "Enter"); + expect(view.stage.style.transform).toContain("scale(16)"); + expect(view.host.querySelector('[data-testid="preview-zoom-input"]')).toBeNull(); + view.cleanup(); + }); + + it("keeps the zoom when the typed value is cancelled with Esc or is not a number", () => { + const view = renderPreview(); + pinchIn(view, 10); + const before = view.stage.style.transform; + + typeZoom(view, "500", "Escape"); + expect(view.stage.style.transform).toBe(before); + expect(view.host.querySelector('[data-testid="preview-zoom-input"]')).toBeNull(); + + typeZoom(view, "abc", "Enter"); + expect(view.stage.style.transform).toBe(before); + view.cleanup(); + }); + + it("draws a one-pixel grid above 800% over the stage, one cell per composition pixel", () => { + const view = renderPreview(undefined, { compositionSizeHint: { width: 1920, height: 1080 } }); + pinchIn(view, 10); + typeZoom(view, "800", "Enter"); + expect(pixelGrid(view)?.hidden).toBe(true); + + typeZoom(view, "1000", "Enter"); + const grid = pixelGrid(view)!; + expect(grid.hidden).toBe(false); + expect(view.stage.contains(grid)).toBe(false); + const stageWidth = parseFloat(view.stage.style.width); + expect(parseFloat(grid.style.width)).toBeCloseTo(stageWidth * 10, 2); + expect(parseFloat(grid.style.backgroundSize)).toBeCloseTo((stageWidth * 10) / 1920, 3); + + act(() => { + view.host.querySelector('[data-testid="preview-zoom-fit"]')!.click(); + }); + act(() => vi.advanceTimersByTime(300)); + expect(pixelGrid(view)).toBeNull(); + view.cleanup(); + }); + it("keeps a zoom only while the project is open: nothing is saved, and another project opens at Fit", () => { const view = renderPreview(); pinchIn(view, 10); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index c2dd8caaa0d..75b0d71cfc2 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -27,7 +27,7 @@ import { type PreviewZoomState, } from "./previewZoom"; import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore"; -import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay"; +import { PreviewZoomOverlay, usePreviewZoomOverlay } from "./PreviewZoomOverlay"; import { PREVIEW_STAGE_INSET_PX, resolvePreviewStageSize, @@ -192,11 +192,13 @@ export const NLEPreview = memo(function NLEPreview({ const stageSizeRef = useRef(stageSize); stageSizeRef.current = stageSize; - const { draw: drawNavigator, setRegion: setNavigatorRegion } = usePreviewNavigator( + const zoomOverlay = usePreviewZoomOverlay( viewportRef, stageSize, zoomRef, + compositionSize?.width ?? hintWidth, ); + const { draw: drawZoomOverlay } = zoomOverlay; const writeTransform = useCallback( (state: PreviewZoomState) => { const stage = stageRef.current; @@ -205,9 +207,9 @@ export const NLEPreview = memo(function NLEPreview({ const px = toDomPrecision(state.panX); const py = toDomPrecision(state.panY); stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`; - drawNavigator(state); + drawZoomOverlay(state); }, - [drawNavigator], + [drawZoomOverlay], ); const zoomProjectRef = useRef(projectId); @@ -551,11 +553,8 @@ export const NLEPreview = memo(function NLEPreview({ { - applyZoom(DEFAULT_PREVIEW_ZOOM); - viewportRef.current?.focus(); - }} - navigatorRegionRef={setNavigatorRegion} + onZoom={applyZoom} + controls={zoomOverlay} /> diff --git a/packages/studio/src/components/nle/PreviewZoomOverlay.tsx b/packages/studio/src/components/nle/PreviewZoomOverlay.tsx index 1dcc520780f..f491b2ed5e3 100644 --- a/packages/studio/src/components/nle/PreviewZoomOverlay.tsx +++ b/packages/studio/src/components/nle/PreviewZoomOverlay.tsx @@ -1,7 +1,11 @@ -import { useCallback, useEffect, useRef, type RefObject } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { + DEFAULT_PREVIEW_ZOOM, isFitZoom, isPreviewAtFit, + parsePreviewZoomInput, + resolvePreviewPixelGrid, + resolvePreviewTypedZoom, resolvePreviewVisibleRegion, toDomPrecision, type PreviewZoomState, @@ -21,7 +25,7 @@ function navigatorFrameSize(stage: { width: number; height: number }) { } /** `draw` moves the navigator's highlight without a render, so it can follow every transform write. */ -export function usePreviewNavigator( +function usePreviewNavigator( viewportRef: RefObject, stageSize: { width: number; height: number }, zoomRef: RefObject, @@ -59,34 +63,182 @@ export function usePreviewNavigator( return { draw, setRegion }; } +function ZoomPercentInput({ + zoomPercent, + onSubmit, + onDone, +}: { + zoomPercent: number; + onSubmit: (zoomPercent: number) => void; + onDone: () => void; +}) { + const [text, setText] = useState(() => String(Math.round(zoomPercent))); + return ( + event.currentTarget.select()} + value={text} + onChange={(event) => setText(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + const next = parsePreviewZoomInput(text); + if (next !== null) onSubmit(next); + onDone(); + } else if (event.key === "Escape") { + onDone(); + } + }} + onBlur={onDone} + inputMode="decimal" + aria-label="Zoom percentage" + className="w-12 rounded bg-white/10 px-1 py-0.5 text-xs tabular-nums text-white outline-hidden focus:ring-1 focus:ring-studio-accent" + data-testid="preview-zoom-input" + /> + ); +} + +/** + * Positions the pixel grid over the stage in screen pixels. Inside the scaled stage a one-pixel line + * would be a fraction of a stage pixel and blur away when the stage is rasterised. The grid is + * studio DOM, never the composition's, so renders and snapshots never include it. + */ +function usePreviewPixelGrid( + viewportRef: RefObject, + stageSize: { width: number; height: number }, + compositionWidth: number | undefined, + zoomRef: RefObject, +) { + const gridRef = useRef(null); + const layoutRef = useRef({ stageSize, compositionWidth }); + layoutRef.current = { stageSize, compositionWidth }; + const draw = useCallback( + (state: PreviewZoomState) => { + const grid = gridRef.current; + if (!grid) return; + const rect = viewportRef.current?.getBoundingClientRect(); + const { stageSize: stage, compositionWidth: width } = layoutRef.current; + const box = rect && width ? resolvePreviewPixelGrid({ state, rect, stage, width }) : null; + grid.hidden = !box; + if (!box) return; + grid.style.left = `${box.left}px`; + grid.style.top = `${box.top}px`; + grid.style.width = `${box.width}px`; + grid.style.height = `${box.height}px`; + grid.style.backgroundSize = `${box.cell}px ${box.cell}px`; + }, + [viewportRef], + ); + const setGrid = useCallback( + (node: HTMLDivElement | null) => { + gridRef.current = node; + draw(zoomRef.current); + }, + [draw, zoomRef], + ); + useEffect(() => draw(zoomRef.current), [stageSize, compositionWidth, draw, zoomRef]); + return { draw, setGrid }; +} + +export type PreviewZoomOverlayControls = ReturnType; + +/** The navigator and pixel grid as one `draw`, so the preview's transform write keeps both in step. */ +export function usePreviewZoomOverlay( + viewportRef: RefObject, + stageSize: { width: number; height: number }, + zoomRef: RefObject, + compositionWidth: number | undefined, +) { + const { draw: drawNavigator, setRegion } = usePreviewNavigator(viewportRef, stageSize, zoomRef); + const { draw: drawGrid, setGrid } = usePreviewPixelGrid( + viewportRef, + stageSize, + compositionWidth, + zoomRef, + ); + const draw = useCallback( + (state: PreviewZoomState) => { + drawNavigator(state); + drawGrid(state); + }, + [drawNavigator, drawGrid], + ); + return { viewportRef, draw, setRegion, setGrid }; +} + export function PreviewZoomOverlay({ zoom, stageSize, - onFit, - navigatorRegionRef, + onZoom, + controls, }: { zoom: PreviewZoomState; stageSize: { width: number; height: number }; - onFit: () => void; - navigatorRegionRef: (node: HTMLDivElement | null) => void; + onZoom: (next: PreviewZoomState) => void; + controls: PreviewZoomOverlayControls; }) { + const [editing, setEditing] = useState(false); if (isPreviewAtFit(zoom)) return null; + const { viewportRef } = controls; + const zoomTo = (zoomPercent: number) => { + const rect = viewportRef.current?.getBoundingClientRect(); + onZoom( + resolvePreviewTypedZoom({ + state: zoom, + zoomPercent, + viewportWidth: rect?.width ?? stageSize.width, + viewportHeight: rect?.height ?? stageSize.height, + contentWidth: stageSize.width, + contentHeight: stageSize.height, + }), + ); + viewportRef.current?.focus(); + }; return ( <> +