mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user