feat(studio): support preview panning with mouse and trackpad (#888)

* feat(studio): support middle-mouse panning in preview

* feat(studio): support trackpad panning in preview

* chore(core): remove stray compositionRoot helper
This commit is contained in:
Phuong Le
2026-05-16 17:20:06 -07:00
committed by GitHub
parent 5f8391bd96
commit 0d726ae010
5 changed files with 532 additions and 85 deletions
@@ -310,7 +310,7 @@ export const NLELayout = memo(function NLELayout({
>
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 relative">
<div className="flex-1 min-h-0 relative" data-preview-pan-surface="true">
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
@@ -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<HTMLIFrameElement>,
) {
React.useEffect(() => {
props.onLoad?.();
}, [props]);
return React.createElement("div", {
ref: ref as React.ForwardedRef<HTMLDivElement>,
"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<HTMLIFrameElement>();
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();
});
});
+199 -64
View File
@@ -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<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [retiringKey, setRetiringKey] = useState<string | null>(null);
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const hudRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
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 = (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<HTMLDivElement>) => {
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}
>
<div
ref={stageRef}
className="absolute inset-2"
style={{
zoom: toDomPrecision(initial.zoomPercent / 100),
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
transformOrigin: "0 0",
}}
data-testid="preview-zoom-stage"
>
<Player
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {
onIframeLoad();
applyInitialZoom();
<div className="absolute inset-2 flex items-center justify-center pointer-events-none">
<div
ref={stageRef}
className="relative shrink-0 pointer-events-auto"
style={{
width: `${stageSize.width}px`,
height: `${stageSize.height}px`,
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
transformOrigin: "center center",
}}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
suppressLoadingOverlay={suppressLoadingOverlay}
/>
data-testid="preview-zoom-stage"
>
{retiringKey && (
<Player
key={retiringKey}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
/>
)}
<Player
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={
retiringKey
? handleNewPlayerLoad
: () => {
onIframeLoad();
applyInitialZoom();
}
}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
suppressLoadingOverlay={suppressLoadingOverlay}
/>
</div>
</div>
<div
ref={hudRef}
@@ -1,12 +1,19 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
DEFAULT_PREVIEW_ZOOM,
MAX_PREVIEW_ZOOM_PERCENT,
MIN_PREVIEW_ZOOM_PERCENT,
canStartPreviewPan,
clampPreviewPan,
clampPreviewZoomPercent,
getNextPreviewZoomPercent,
getPreviewWheelZoomPercent,
ownsPreviewPanTarget,
PREVIEW_PAN_OVERSCROLL_PX,
PREVIEW_PAN_SURFACE_SELECTOR,
resolvePreviewWheelPan,
resolvePreviewWheelZoom,
toDomPrecision,
} from "./previewZoom";
@@ -64,16 +71,17 @@ describe("getNextPreviewZoomPercent", () => {
});
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);
});
});
@@ -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<PreviewZoomState, "panX" | "panY"> {
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,
};
}