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..06cf91211b7 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,176 @@ 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" + /> + ); +} + +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.cellPx}px ${box.cellPx}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; + +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 ( <> +