diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e211eab0e..685ce24ff 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -310,7 +310,7 @@ export const NLELayout = memo(function NLELayout({ > {/* Preview + player controls */}
-
+
{ + const React = await import("react"); + + return { + Player: React.forwardRef(function MockPlayer( + props: { + onLoad?: () => void; + style?: React.CSSProperties; + }, + ref: React.ForwardedRef, + ) { + React.useEffect(() => { + props.onLoad?.(); + }, [props]); + + return React.createElement("div", { + ref: ref as React.ForwardedRef, + "data-testid": "mock-player", + style: props.style, + }); + }), + }; +}); + +vi.mock("../../utils/studioUiPreferences", () => ({ + readStudioUiPreferences: () => ({}), + writeStudioUiPreferences: () => {}, +})); + +class MockResizeObserver { + observe() {} + disconnect() {} +} + +const originalResizeObserver = globalThis.ResizeObserver; + +function setRect(node: Element, rect: { width: number; height: number }) { + Object.defineProperty(node, "getBoundingClientRect", { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: rect.width, + bottom: rect.height, + width: rect.width, + height: rect.height, + toJSON: () => ({}), + }), + }); +} + +function renderPreview() { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const iframeRef = createRef(); + + act(() => { + root.render( + React.createElement(NLEPreview, { + projectId: "timeline-edit-playground", + iframeRef, + onIframeLoad: () => {}, + }), + ); + }); + + const viewport = host.querySelector('[aria-label="Composition preview"]') as HTMLDivElement; + const stage = host.querySelector('[data-testid="preview-zoom-stage"]') as HTMLDivElement; + expect(viewport).toBeTruthy(); + expect(stage).toBeTruthy(); + + setRect(viewport, { width: 800, height: 600 }); + + return { + host, + root, + viewport, + stage, + cleanup() { + act(() => { + root.unmount(); + }); + host.remove(); + }, + }; +} describe("getPreviewPlayerKey", () => { it("keeps the same player identity when only refreshKey changes", () => { @@ -30,3 +126,70 @@ describe("getPreviewPlayerKey", () => { ); }); }); + +describe("NLEPreview", () => { + beforeEach(() => { + globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it("pans the preview with middle mouse drag", () => { + const view = renderPreview(); + const target = document.createElement("div"); + view.stage.appendChild(target); + + act(() => { + target.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 1, + clientX: 240, + clientY: 180, + }), + ); + document.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 300, + clientY: 220, + }), + ); + document.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + }), + ); + }); + + expect(view.stage.style.transform).toContain("translate(48px, 40px)"); + view.cleanup(); + }); + + it("pans the preview with a two-finger wheel gesture", () => { + const view = renderPreview(); + const target = document.createElement("div"); + view.stage.appendChild(target); + + act(() => { + target.dispatchEvent( + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: 240, + clientY: 180, + deltaX: -30, + deltaY: 24, + }), + ); + }); + + expect(view.stage.style.transform).toContain("translate(30px, -24px)"); + view.cleanup(); + }); +}); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index c655f6e9b..e533b416d 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -1,9 +1,12 @@ -import { memo, useCallback, useEffect, useRef, type Ref } from "react"; +import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react"; import { Player } from "../../player"; import { DEFAULT_PREVIEW_ZOOM, + canStartPreviewPan, clampPreviewPan, clampPreviewZoomPercent, + ownsPreviewPanTarget, + resolvePreviewWheelPan, resolvePreviewWheelZoom, toDomPrecision, type PreviewZoomState, @@ -34,6 +37,15 @@ export function getPreviewPlayerKey({ const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; +const PREVIEW_STAGE_INSET_PX = 16; + +function isPreviewAtFit(state: PreviewZoomState): boolean { + return ( + Math.abs(state.zoomPercent - 100) < 0.5 && + Math.abs(state.panX) < 0.1 && + Math.abs(state.panY) < 0.1 + ); +} function loadInitialZoom(): PreviewZoomState { const stored = readStudioUiPreferences().previewZoom; @@ -46,6 +58,32 @@ function loadInitialZoom(): PreviewZoomState { : DEFAULT_PREVIEW_ZOOM; } +function resolvePreviewStageSize( + viewportWidth: number, + viewportHeight: number, + portrait: boolean | undefined, +): { width: number; height: number } { + const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX); + const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX); + const aspectRatio = portrait ? 9 / 16 : 16 / 9; + + if (availableWidth === 0 || availableHeight === 0) { + return { width: 0, height: 0 }; + } + + let width = availableWidth; + let height = width / aspectRatio; + if (height > availableHeight) { + height = availableHeight; + width = height * aspectRatio; + } + + return { + width: toDomPrecision(width), + height: toDomPrecision(height), + }; +} + export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, @@ -53,14 +91,16 @@ export const NLEPreview = memo(function NLEPreview({ onCompositionLoadingChange, portrait, directUrl, + refreshKey, suppressLoadingOverlay, }: NLEPreviewProps) { - // Player key only changes for structural changes (project switch, composition - // drill-down), NOT for content refreshes. Content refreshes use the lighter - // iframe.src reload path handled by NLELayout → refreshPlayer(). - const activeKey = getPreviewPlayerKey({ projectId, directUrl }); + const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); + const prevRefreshKeyRef = useRef(refreshKey); const viewportRef = useRef(null); const stageRef = useRef(null); + const [retiringKey, setRetiringKey] = useState(null); + const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait)); + const retiringTimerRef = useRef | null>(null); const zoomRef = useRef(loadInitialZoom()); const hudRef = useRef(null); @@ -79,17 +119,32 @@ export const NLEPreview = memo(function NLEPreview({ return () => { if (settleTimerRef.current) clearTimeout(settleTimerRef.current); if (hudTimerRef.current) clearTimeout(hudTimerRef.current); + if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); }; }, []); + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const updateStageSize = () => { + const rect = viewport.getBoundingClientRect(); + setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait)); + }; + + updateStageSize(); + const observer = new ResizeObserver(updateStageSize); + observer.observe(viewport); + return () => observer.disconnect(); + }, [portrait]); + const writeTransform = useCallback((state: PreviewZoomState) => { const stage = stageRef.current; if (!stage) return; const s = toDomPrecision(state.zoomPercent / 100); const px = toDomPrecision(state.panX); const py = toDomPrecision(state.panY); - stage.style.zoom = String(s); - stage.style.transform = `translate(${px}px, ${py}px)`; + stage.style.transform = `translate(${px}px, ${py}px) scale(${s})`; }, []); const applyZoom = useCallback( @@ -116,8 +171,7 @@ export const NLEPreview = memo(function NLEPreview({ writeStudioUiPreferences({ previewZoom: final }); const hud = hudRef.current; if (hud) { - const zoomed = Math.abs(final.zoomPercent - 100) > 0.5; - hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit"; + hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`; if (hudTimerRef.current) clearTimeout(hudTimerRef.current); hudTimerRef.current = setTimeout(() => { if (hudRef.current) hudRef.current.style.opacity = "0"; @@ -128,6 +182,14 @@ export const NLEPreview = memo(function NLEPreview({ [writeTransform], ); + if (refreshKey !== prevRefreshKeyRef.current) { + const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`; + prevRefreshKeyRef.current = refreshKey; + setRetiringKey(oldKey); + } + + const activeKey = `${baseKey}:${refreshKey ?? 0}`; + const applyInitialZoom = useCallback(() => { const z = zoomRef.current; if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) { @@ -135,12 +197,20 @@ export const NLEPreview = memo(function NLEPreview({ } }, [writeTransform]); + const handleNewPlayerLoad = () => { + onIframeLoad(); + applyInitialZoom(); + if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); + retiringTimerRef.current = setTimeout(() => { + setRetiringKey(null); + retiringTimerRef.current = null; + }, 160); + }; + useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; - let lastZoomTime = 0; - const handleWheel = (event: WheelEvent) => { const rect = viewport.getBoundingClientRect(); if ( @@ -155,7 +225,6 @@ export const NLEPreview = memo(function NLEPreview({ const isZoomGesture = event.ctrlKey || event.metaKey; if (isZoomGesture) { - lastZoomTime = Date.now(); event.preventDefault(); event.stopPropagation(); @@ -164,27 +233,40 @@ export const NLEPreview = memo(function NLEPreview({ deltaY: event.deltaY, viewportWidth: rect.width, viewportHeight: rect.height, + contentWidth: stageSize.width, + contentHeight: stageSize.height, }); applyZoom(next); return; } - if (Date.now() - lastZoomTime < 400) { - event.preventDefault(); - event.stopPropagation(); - } + if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; + + event.preventDefault(); + event.stopPropagation(); + + const next = resolvePreviewWheelPan({ + state: zoomRef.current, + deltaX: event.deltaX, + deltaY: event.deltaY, + viewportWidth: rect.width, + viewportHeight: rect.height, + contentWidth: stageSize.width, + contentHeight: stageSize.height, + }); + applyZoom(next); }; document.addEventListener("wheel", handleWheel, { passive: false, capture: true }); return () => document.removeEventListener("wheel", handleWheel, { capture: true }); - }, [applyZoom]); + }, [applyZoom, stageSize.height, stageSize.width]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const handleDblClick = (event: MouseEvent) => { - if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return; + if (isPreviewAtFit(zoomRef.current)) return; const rect = viewport.getBoundingClientRect(); if ( event.clientX < rect.left || @@ -201,20 +283,38 @@ export const NLEPreview = memo(function NLEPreview({ return () => document.removeEventListener("dblclick", handleDblClick, { capture: true }); }, [applyZoom]); - const handlePointerDown = useCallback((event: React.PointerEvent) => { - if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return; - event.currentTarget.setPointerCapture(event.pointerId); - dragRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: zoomRef.current.panX, - originY: zoomRef.current.panY, + useEffect(() => { + const isInsideViewport = (clientX: number, clientY: number): DOMRect | null => { + const viewport = viewportRef.current; + if (!viewport) return null; + const rect = viewport.getBoundingClientRect(); + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return rect; + }; + + const handlePointerDown = (event: PointerEvent) => { + const rect = isInsideViewport(event.clientX, event.clientY); + if (!rect) return; + if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; + if (!canStartPreviewPan(event.button)) return; + event.preventDefault(); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: zoomRef.current.panX, + originY: zoomRef.current.panY, + }; }; - }, []); - const handlePointerMove = useCallback( - (event: React.PointerEvent) => { + const handlePointerMove = (event: PointerEvent) => { const drag = dragRef.current; const viewport = viewportRef.current; if (!drag || !viewport || drag.pointerId !== event.pointerId) return; @@ -226,17 +326,38 @@ export const NLEPreview = memo(function NLEPreview({ zoomPercent: zoomRef.current.zoomPercent, viewportWidth: rect.width, viewportHeight: rect.height, + contentWidth: stageSize.width, + contentHeight: stageSize.height, }); applyZoom({ ...zoomRef.current, ...pan }); - }, - [applyZoom], - ); + }; - const finishDrag = useCallback((event: React.PointerEvent) => { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null; - } - }, []); + const finishDrag = (event: PointerEvent) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null; + } + }; + + const handleAuxClick = (event: MouseEvent) => { + if (event.button !== 1) return; + if (!isInsideViewport(event.clientX, event.clientY)) return; + if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; + event.preventDefault(); + }; + + document.addEventListener("pointerdown", handlePointerDown, { capture: true }); + document.addEventListener("pointermove", handlePointerMove, { capture: true }); + document.addEventListener("pointerup", finishDrag, { capture: true }); + document.addEventListener("pointercancel", finishDrag, { capture: true }); + document.addEventListener("auxclick", handleAuxClick, { capture: true }); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, { capture: true }); + document.removeEventListener("pointermove", handlePointerMove, { capture: true }); + document.removeEventListener("pointerup", finishDrag, { capture: true }); + document.removeEventListener("pointercancel", finishDrag, { capture: true }); + document.removeEventListener("auxclick", handleAuxClick, { capture: true }); + }; + }, [applyZoom, stageSize.height, stageSize.width]); const initial = zoomRef.current; @@ -247,34 +368,48 @@ export const NLEPreview = memo(function NLEPreview({ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700" tabIndex={0} aria-label="Composition preview" - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={finishDrag} - onPointerCancel={finishDrag} > -
- { - onIframeLoad(); - applyInitialZoom(); +
+
+ data-testid="preview-zoom-stage" + > + {retiringKey && ( + {}} + portrait={portrait} + style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} + /> + )} + { + onIframeLoad(); + applyInitialZoom(); + } + } + onCompositionLoadingChange={onCompositionLoadingChange} + portrait={portrait} + style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} + suppressLoadingOverlay={suppressLoadingOverlay} + /> +
{ }); describe("clampPreviewPan", () => { - it("centers the preview when fit or zoomed out", () => { - expect( - clampPreviewPan({ - panX: 120, - panY: -90, - zoomPercent: 100, - viewportWidth: 800, - viewportHeight: 600, - }), - ).toEqual({ panX: 0, panY: 0 }); + it("allows a small overscroll margin at fit zoom", () => { + const next = clampPreviewPan({ + panX: 900, + panY: -900, + zoomPercent: 100, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(next.panX).toBe(PREVIEW_PAN_OVERSCROLL_PX); + expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX); }); it("keeps pan within the zoomed preview bounds", () => { @@ -85,7 +93,71 @@ describe("clampPreviewPan", () => { viewportWidth: 800, viewportHeight: 600, }), - ).toEqual({ panX: 400, panY: -300 }); + ).toEqual({ + panX: 400 + PREVIEW_PAN_OVERSCROLL_PX, + panY: -(300 + PREVIEW_PAN_OVERSCROLL_PX), + }); + }); + + it("allows overscroll even when only one axis overflows", () => { + expect( + clampPreviewPan({ + panX: 120, + panY: -90, + zoomPercent: 107.25, + viewportWidth: 1352, + viewportHeight: 682, + contentWidth: 1184, + contentHeight: 666, + }), + ).toEqual({ + panX: PREVIEW_PAN_OVERSCROLL_PX, + panY: -(16.142499999999984 + PREVIEW_PAN_OVERSCROLL_PX), + }); + }); +}); + +describe("canStartPreviewPan", () => { + it("allows middle mouse pan at fit zoom", () => { + expect(canStartPreviewPan(1)).toBe(true); + }); + + it("allows middle mouse pan when zoomed in", () => { + expect(canStartPreviewPan(1)).toBe(true); + }); + + it("rejects other mouse buttons", () => { + expect(canStartPreviewPan(0)).toBe(false); + expect(canStartPreviewPan(2)).toBe(false); + }); +}); + +describe("ownsPreviewPanTarget", () => { + it("accepts targets inside the preview stage", () => { + const stage = document.createElement("div"); + const child = document.createElement("div"); + stage.appendChild(child); + + expect(ownsPreviewPanTarget(child, stage)).toBe(true); + }); + + it("accepts targets inside the shared preview pan surface", () => { + const surface = document.createElement("div"); + surface.setAttribute("data-preview-pan-surface", "true"); + const overlay = document.createElement("div"); + surface.appendChild(overlay); + + expect(ownsPreviewPanTarget(overlay, null)).toBe(true); + }); + + it("rejects targets outside the preview stage and preview pan surface", () => { + const outside = document.createElement("div"); + + expect(ownsPreviewPanTarget(outside, null)).toBe(false); + }); + + it("uses the shared preview pan surface selector contract", () => { + expect(PREVIEW_PAN_SURFACE_SELECTOR).toBe('[data-preview-pan-surface="true"]'); }); }); @@ -103,7 +175,7 @@ describe("resolvePreviewWheelZoom", () => { expect(next.panY).toBe(0); }); - it("clamps pan when zooming out past minimum", () => { + it("preserves small pan inside the overscroll margin when zooming out past minimum", () => { const next = resolvePreviewWheelZoom({ state: { zoomPercent: 26, panX: 20, panY: 20 }, deltaY: 500, @@ -112,7 +184,36 @@ describe("resolvePreviewWheelZoom", () => { }); expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0); - expect(next.panX).toBe(0); - expect(next.panY).toBe(0); + expect(next.panX).toBe(20); + expect(next.panY).toBe(20); + }); +}); + +describe("resolvePreviewWheelPan", () => { + it("moves preview pan from wheel deltas", () => { + const next = resolvePreviewWheelPan({ + state: DEFAULT_PREVIEW_ZOOM, + deltaX: 18, + deltaY: -12, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(next.zoomPercent).toBe(100); + expect(next.panX).toBe(-18); + expect(next.panY).toBe(12); + }); + + it("keeps wheel pan inside overscroll bounds", () => { + const next = resolvePreviewWheelPan({ + state: DEFAULT_PREVIEW_ZOOM, + deltaX: -900, + deltaY: 900, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(next.panX).toBe(PREVIEW_PAN_OVERSCROLL_PX); + expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX); }); }); diff --git a/packages/studio/src/components/nle/previewZoom.ts b/packages/studio/src/components/nle/previewZoom.ts index d39296a8b..7ee49229f 100644 --- a/packages/studio/src/components/nle/previewZoom.ts +++ b/packages/studio/src/components/nle/previewZoom.ts @@ -6,6 +6,8 @@ export interface PreviewZoomState { export const MIN_PREVIEW_ZOOM_PERCENT = 25; export const MAX_PREVIEW_ZOOM_PERCENT = 400; +export const PREVIEW_PAN_SURFACE_SELECTOR = '[data-preview-pan-surface="true"]'; +export const PREVIEW_PAN_OVERSCROLL_PX = 48; export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = { zoomPercent: 100, panX: 0, @@ -24,6 +26,19 @@ export function clampPreviewZoomPercent(percent: number): number { return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent)); } +export function canStartPreviewPan(button: number): boolean { + return button === 1; +} + +export function ownsPreviewPanTarget( + target: EventTarget | null, + stage: HTMLElement | null, +): boolean { + if (!(target instanceof Element)) return false; + if (stage?.contains(target)) return true; + return !!target.closest(PREVIEW_PAN_SURFACE_SELECTOR); +} + export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number { if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent); const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY; @@ -47,12 +62,16 @@ export function clampPreviewPan(input: { zoomPercent: number; viewportWidth: number; viewportHeight: number; + contentWidth?: number; + contentHeight?: number; }): Pick { const scale = clampPreviewZoomPercent(input.zoomPercent) / 100; - if (scale <= 1) return { panX: 0, panY: 0 }; - - const maxPanX = ((scale - 1) * input.viewportWidth) / 2; - const maxPanY = ((scale - 1) * input.viewportHeight) / 2; + const contentWidth = input.contentWidth ?? input.viewportWidth; + const contentHeight = input.contentHeight ?? input.viewportHeight; + const maxPanX = + Math.max(0, (contentWidth * scale - input.viewportWidth) / 2) + PREVIEW_PAN_OVERSCROLL_PX; + const maxPanY = + Math.max(0, (contentHeight * scale - input.viewportHeight) / 2) + PREVIEW_PAN_OVERSCROLL_PX; return { panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)), panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)), @@ -64,6 +83,8 @@ export function resolvePreviewWheelZoom(input: { deltaY: number; viewportWidth: number; viewportHeight: number; + contentWidth?: number; + contentHeight?: number; }): PreviewZoomState { const nextZoomPercent = getPreviewWheelZoomPercent( input.deltaY, @@ -75,6 +96,8 @@ export function resolvePreviewWheelZoom(input: { zoomPercent: nextZoomPercent, viewportWidth: input.viewportWidth, viewportHeight: input.viewportHeight, + contentWidth: input.contentWidth, + contentHeight: input.contentHeight, }); return { @@ -82,3 +105,28 @@ export function resolvePreviewWheelZoom(input: { ...pan, }; } + +export function resolvePreviewWheelPan(input: { + state: PreviewZoomState; + deltaX: number; + deltaY: number; + viewportWidth: number; + viewportHeight: number; + contentWidth?: number; + contentHeight?: number; +}): PreviewZoomState { + const pan = clampPreviewPan({ + panX: input.state.panX - input.deltaX, + panY: input.state.panY - input.deltaY, + zoomPercent: input.state.zoomPercent, + viewportWidth: input.viewportWidth, + viewportHeight: input.viewportHeight, + contentWidth: input.contentWidth, + contentHeight: input.contentHeight, + }); + + return { + zoomPercent: clampPreviewZoomPercent(input.state.zoomPercent), + ...pan, + }; +}