From c6bfa6d1f8f2b5f0170e613acdd40bf5b3b84007 Mon Sep 17 00:00:00 2001 From: func25 Date: Sat, 16 May 2026 12:19:23 +0700 Subject: [PATCH 1/3] feat(studio): support middle-mouse panning in preview --- .../studio/src/components/nle/NLELayout.tsx | 2 +- .../studio/src/components/nle/NLEPreview.tsx | 240 +++++++++++++----- .../src/components/nle/previewZoom.test.ts | 99 +++++++- .../studio/src/components/nle/previewZoom.ts | 31 ++- 4 files changed, 296 insertions(+), 76 deletions(-) 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 */}
-
+
availableHeight) { + height = availableHeight; + width = height * aspectRatio; + } + + return { + width: toDomPrecision(width), + height: toDomPrecision(height), + }; +} + export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, @@ -53,14 +90,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 +118,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 +170,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 +181,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,6 +196,16 @@ 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; @@ -164,6 +235,8 @@ export const NLEPreview = memo(function NLEPreview({ deltaY: event.deltaY, viewportWidth: rect.width, viewportHeight: rect.height, + contentWidth: stageSize.width, + contentHeight: stageSize.height, }); applyZoom(next); return; @@ -177,14 +250,14 @@ export const NLEPreview = memo(function NLEPreview({ 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 +274,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 +317,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 +359,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 +92,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 +174,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 +183,7 @@ 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); }); }); diff --git a/packages/studio/src/components/nle/previewZoom.ts b/packages/studio/src/components/nle/previewZoom.ts index d39296a8b..4e76b6475 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 { From 43952851a4518a9a75865a57761b75626452cffe Mon Sep 17 00:00:00 2001 From: func25 Date: Sun, 17 May 2026 01:25:02 +0700 Subject: [PATCH 2/3] feat(studio): support trackpad panning in preview --- packages/core/src/compositionRoot.ts | 15 ++ .../src/components/nle/NLEPreview.test.ts | 167 +++++++++++++++++- .../studio/src/components/nle/NLEPreview.tsx | 23 ++- .../src/components/nle/previewZoom.test.ts | 30 ++++ .../studio/src/components/nle/previewZoom.ts | 25 +++ 5 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/compositionRoot.ts diff --git a/packages/core/src/compositionRoot.ts b/packages/core/src/compositionRoot.ts new file mode 100644 index 000000000..1c4fff3e3 --- /dev/null +++ b/packages/core/src/compositionRoot.ts @@ -0,0 +1,15 @@ +export const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id"; +export const INNER_ROOT_MARKER_ATTR = "data-hf-inner-root"; + +export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [ + "data-composition-id", + "data-composition-file", + "data-start", + "data-duration", + "data-end", + "data-track-index", + "data-track", + "data-composition-src", + "data-hf-authored-duration", + "data-hf-authored-end", +] as const; diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index 4451458ee..11b4ebecc 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -1,5 +1,101 @@ -import { describe, expect, it } from "vitest"; -import { getPreviewPlayerKey } from "./NLEPreview"; +// @vitest-environment happy-dom + +import React, { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../player", async () => { + 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 40cd5fad4..e533b416d 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -6,6 +6,7 @@ import { clampPreviewPan, clampPreviewZoomPercent, ownsPreviewPanTarget, + resolvePreviewWheelPan, resolvePreviewWheelZoom, toDomPrecision, type PreviewZoomState, @@ -210,8 +211,6 @@ export const NLEPreview = memo(function NLEPreview({ const viewport = viewportRef.current; if (!viewport) return; - let lastZoomTime = 0; - const handleWheel = (event: WheelEvent) => { const rect = viewport.getBoundingClientRect(); if ( @@ -226,7 +225,6 @@ export const NLEPreview = memo(function NLEPreview({ const isZoomGesture = event.ctrlKey || event.metaKey; if (isZoomGesture) { - lastZoomTime = Date.now(); event.preventDefault(); event.stopPropagation(); @@ -242,10 +240,21 @@ export const NLEPreview = memo(function NLEPreview({ 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 }); diff --git a/packages/studio/src/components/nle/previewZoom.test.ts b/packages/studio/src/components/nle/previewZoom.test.ts index f192f7ee3..3363115a0 100644 --- a/packages/studio/src/components/nle/previewZoom.test.ts +++ b/packages/studio/src/components/nle/previewZoom.test.ts @@ -13,6 +13,7 @@ import { ownsPreviewPanTarget, PREVIEW_PAN_OVERSCROLL_PX, PREVIEW_PAN_SURFACE_SELECTOR, + resolvePreviewWheelPan, resolvePreviewWheelZoom, toDomPrecision, } from "./previewZoom"; @@ -187,3 +188,32 @@ describe("resolvePreviewWheelZoom", () => { 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 4e76b6475..7ee49229f 100644 --- a/packages/studio/src/components/nle/previewZoom.ts +++ b/packages/studio/src/components/nle/previewZoom.ts @@ -105,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, + }; +} From 513481d5735288f6f5efef1ca0b0b4638821eab1 Mon Sep 17 00:00:00 2001 From: func25 Date: Sun, 17 May 2026 02:01:34 +0700 Subject: [PATCH 3/3] chore(core): remove stray compositionRoot helper --- packages/core/src/compositionRoot.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 packages/core/src/compositionRoot.ts diff --git a/packages/core/src/compositionRoot.ts b/packages/core/src/compositionRoot.ts deleted file mode 100644 index 1c4fff3e3..000000000 --- a/packages/core/src/compositionRoot.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id"; -export const INNER_ROOT_MARKER_ATTR = "data-hf-inner-root"; - -export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [ - "data-composition-id", - "data-composition-file", - "data-start", - "data-duration", - "data-end", - "data-track-index", - "data-track", - "data-composition-src", - "data-hf-authored-duration", - "data-hf-authored-end", -] as const;