@hyperframes/studio 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DMJCfYoN.css +1 -0
- package/dist/assets/index-DsnMQhJc.js +117 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +14 -18
- package/src/components/StudioRightPanel.tsx +15 -1
- package/src/components/editor/DomEditOverlay.test.ts +165 -1
- package/src/components/editor/DomEditOverlay.tsx +0 -1
- package/src/components/editor/LayersPanel.tsx +289 -0
- package/src/components/editor/SourceEditor.tsx +20 -3
- package/src/components/editor/propertyPanelPrimitives.tsx +24 -5
- package/src/components/editor/propertyPanelStyleSections.tsx +6 -6
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -10
- package/src/components/nle/NLELayout.tsx +4 -2
- package/src/components/nle/NLEPreview.tsx +250 -30
- package/src/components/nle/previewZoom.test.ts +118 -0
- package/src/components/nle/previewZoom.ts +84 -0
- package/src/components/renders/RenderQueue.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/useConsoleErrorCapture.ts +3 -3
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useDomSelection.ts +27 -6
- package/src/hooks/usePanelLayout.ts +8 -2
- package/src/player/hooks/useTimelinePlayer.ts +11 -1
- package/src/player/store/playerStore.ts +18 -2
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioUiPreferences.test.ts +49 -0
- package/src/utils/studioUiPreferences.ts +84 -0
- package/dist/assets/index-D1JDq7Gg.css +0 -1
- package/dist/assets/index-hYc4aP7M.js +0 -117
|
@@ -334,24 +334,43 @@ export function Section({
|
|
|
334
334
|
icon,
|
|
335
335
|
children,
|
|
336
336
|
accessory,
|
|
337
|
+
defaultCollapsed = false,
|
|
337
338
|
}: {
|
|
338
339
|
title: string;
|
|
339
340
|
icon: ReactNode;
|
|
340
341
|
children: ReactNode;
|
|
341
342
|
accessory?: ReactNode;
|
|
343
|
+
defaultCollapsed?: boolean;
|
|
342
344
|
}) {
|
|
345
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
346
|
+
|
|
343
347
|
return (
|
|
344
|
-
<section className="min-w-0 border-t border-neutral-800/80
|
|
345
|
-
<
|
|
348
|
+
<section className="min-w-0 border-t border-neutral-800/80">
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
352
|
+
className="flex w-full items-center justify-between gap-2 px-4 py-3"
|
|
353
|
+
>
|
|
346
354
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
347
355
|
<span className="flex-shrink-0 text-neutral-500">{icon}</span>
|
|
348
356
|
<h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
|
|
349
357
|
{title}
|
|
350
358
|
</h3>
|
|
351
359
|
</div>
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
360
|
+
<div className="flex items-center gap-2">
|
|
361
|
+
{accessory}
|
|
362
|
+
<svg
|
|
363
|
+
width="10"
|
|
364
|
+
height="10"
|
|
365
|
+
viewBox="0 0 10 10"
|
|
366
|
+
fill="currentColor"
|
|
367
|
+
className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
|
368
|
+
>
|
|
369
|
+
<path d="M2 3l3 4 3-4z" />
|
|
370
|
+
</svg>
|
|
371
|
+
</div>
|
|
372
|
+
</button>
|
|
373
|
+
{!collapsed && <div className="px-4 pb-4">{children}</div>}
|
|
355
374
|
</section>
|
|
356
375
|
);
|
|
357
376
|
}
|
|
@@ -109,7 +109,7 @@ export function StyleSections({
|
|
|
109
109
|
return (
|
|
110
110
|
<>
|
|
111
111
|
{isFlex && (
|
|
112
|
-
<Section title="Flex" icon={<Layers size={15} />}>
|
|
112
|
+
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
|
|
113
113
|
<div className="space-y-4">
|
|
114
114
|
<SegmentedControl
|
|
115
115
|
disabled={styleEditingDisabled}
|
|
@@ -154,7 +154,7 @@ export function StyleSections({
|
|
|
154
154
|
)}
|
|
155
155
|
|
|
156
156
|
{hasVisualBackground && (
|
|
157
|
-
<Section title="Radius" icon={<Settings size={15} />}>
|
|
157
|
+
<Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
|
|
158
158
|
<SliderControl
|
|
159
159
|
value={radiusValue}
|
|
160
160
|
min={0}
|
|
@@ -168,7 +168,7 @@ export function StyleSections({
|
|
|
168
168
|
</Section>
|
|
169
169
|
)}
|
|
170
170
|
|
|
171
|
-
<Section title="Stroke" icon={<Square size={15} />}>
|
|
171
|
+
<Section title="Stroke" icon={<Square size={15} />} defaultCollapsed>
|
|
172
172
|
<div className="space-y-4">
|
|
173
173
|
<div className={RESPONSIVE_GRID}>
|
|
174
174
|
<MetricField
|
|
@@ -226,7 +226,7 @@ export function StyleSections({
|
|
|
226
226
|
</div>
|
|
227
227
|
</Section>
|
|
228
228
|
|
|
229
|
-
<Section title="Effects" icon={<Zap size={15} />}>
|
|
229
|
+
<Section title="Effects" icon={<Zap size={15} />} defaultCollapsed>
|
|
230
230
|
<div className="space-y-4">
|
|
231
231
|
<SelectField
|
|
232
232
|
label="Shadow"
|
|
@@ -279,7 +279,7 @@ export function StyleSections({
|
|
|
279
279
|
</div>
|
|
280
280
|
</Section>
|
|
281
281
|
|
|
282
|
-
<Section title="Clip" icon={<Layers size={15} />}>
|
|
282
|
+
<Section title="Clip" icon={<Layers size={15} />} defaultCollapsed>
|
|
283
283
|
<div className="space-y-4">
|
|
284
284
|
<div className={RESPONSIVE_GRID}>
|
|
285
285
|
<SelectField
|
|
@@ -325,7 +325,7 @@ export function StyleSections({
|
|
|
325
325
|
</div>
|
|
326
326
|
</Section>
|
|
327
327
|
|
|
328
|
-
<Section title="Transparency" icon={<Eye size={15} />}>
|
|
328
|
+
<Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
|
|
329
329
|
<div className="space-y-4">
|
|
330
330
|
<SliderControl
|
|
331
331
|
value={opacityValue}
|
|
@@ -23,12 +23,7 @@ import {
|
|
|
23
23
|
restoreStudioPathOffset,
|
|
24
24
|
restoreStudioRotation,
|
|
25
25
|
} from "./manualEdits";
|
|
26
|
-
import {
|
|
27
|
-
type GroupOverlayItem,
|
|
28
|
-
type OverlayRect,
|
|
29
|
-
groupOverlayItemsEqual,
|
|
30
|
-
rectsEqual,
|
|
31
|
-
} from "./domEditOverlayGeometry";
|
|
26
|
+
import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry";
|
|
32
27
|
import {
|
|
33
28
|
BLOCKED_MOVE_THRESHOLD_PX,
|
|
34
29
|
type BlockedMoveState,
|
|
@@ -87,8 +82,6 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
87
82
|
|
|
88
83
|
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
|
|
89
84
|
const setDraftOverlayRect = (next: OverlayRect) => {
|
|
90
|
-
if (rectsEqual(opts.overlayRectRef.current, next)) return;
|
|
91
|
-
opts.overlayRectRef.current = next;
|
|
92
85
|
opts.setOverlayRect(next);
|
|
93
86
|
};
|
|
94
87
|
const restoreGestureOverlayRect = (g: GestureState) => {
|
|
@@ -102,8 +95,6 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
102
95
|
});
|
|
103
96
|
};
|
|
104
97
|
const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
|
|
105
|
-
if (groupOverlayItemsEqual(opts.groupOverlayItemsRef.current, next)) return;
|
|
106
|
-
opts.groupOverlayItemsRef.current = next;
|
|
107
98
|
opts.setGroupOverlayItems(next);
|
|
108
99
|
};
|
|
109
100
|
|
|
@@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
247
247
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
248
248
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
249
249
|
|
|
250
|
+
const onIframeRefStable = useRef(onIframeRef);
|
|
251
|
+
onIframeRefStable.current = onIframeRef;
|
|
250
252
|
useEffect(() => {
|
|
251
|
-
|
|
252
|
-
}, [compositionStack.length,
|
|
253
|
+
onIframeRefStable.current?.(iframeRef.current);
|
|
254
|
+
}, [compositionStack.length, refreshKey, iframeRef]);
|
|
253
255
|
|
|
254
256
|
// Resize divider handlers
|
|
255
257
|
const handleDividerPointerDown = useCallback(
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { memo, useRef, useState, type Ref } from "react";
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
|
|
2
2
|
import { Player } from "../../player";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PREVIEW_ZOOM,
|
|
5
|
+
clampPreviewPan,
|
|
6
|
+
clampPreviewZoomPercent,
|
|
7
|
+
resolvePreviewWheelZoom,
|
|
8
|
+
toDomPrecision,
|
|
9
|
+
type PreviewZoomState,
|
|
10
|
+
} from "./previewZoom";
|
|
11
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
3
12
|
|
|
4
13
|
interface NLEPreviewProps {
|
|
5
14
|
projectId: string;
|
|
@@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
|
|
|
23
32
|
return directUrl ?? projectId;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const ZOOM_HUD_TIMEOUT_MS = 1200;
|
|
36
|
+
const ZOOM_SETTLE_MS = 200;
|
|
37
|
+
|
|
38
|
+
function loadInitialZoom(): PreviewZoomState {
|
|
39
|
+
const stored = readStudioUiPreferences().previewZoom;
|
|
40
|
+
return stored
|
|
41
|
+
? {
|
|
42
|
+
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
|
|
43
|
+
panX: stored.panX,
|
|
44
|
+
panY: stored.panY,
|
|
45
|
+
}
|
|
46
|
+
: DEFAULT_PREVIEW_ZOOM;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export const NLEPreview = memo(function NLEPreview({
|
|
38
50
|
projectId,
|
|
39
51
|
iframeRef,
|
|
@@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
46
58
|
}: NLEPreviewProps) {
|
|
47
59
|
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
48
60
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
61
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
49
63
|
const [retiringKey, setRetiringKey] = useState<string | null>(null);
|
|
50
64
|
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
67
|
+
const hudRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
69
|
+
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
70
|
+
const zoomingRef = useRef(false);
|
|
71
|
+
const dragRef = useRef<{
|
|
72
|
+
pointerId: number;
|
|
73
|
+
startX: number;
|
|
74
|
+
startY: number;
|
|
75
|
+
originX: number;
|
|
76
|
+
originY: number;
|
|
77
|
+
} | null>(null);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
return () => {
|
|
81
|
+
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
|
82
|
+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
|
|
83
|
+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const writeTransform = useCallback((state: PreviewZoomState) => {
|
|
88
|
+
const stage = stageRef.current;
|
|
89
|
+
if (!stage) return;
|
|
90
|
+
const s = toDomPrecision(state.zoomPercent / 100);
|
|
91
|
+
const px = toDomPrecision(state.panX);
|
|
92
|
+
const py = toDomPrecision(state.panY);
|
|
93
|
+
stage.style.zoom = String(s);
|
|
94
|
+
stage.style.transform = `translate(${px}px, ${py}px)`;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const applyZoom = useCallback(
|
|
98
|
+
(next: PreviewZoomState) => {
|
|
99
|
+
const clamped: PreviewZoomState = {
|
|
100
|
+
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
|
|
101
|
+
panX: Number.isFinite(next.panX) ? next.panX : 0,
|
|
102
|
+
panY: Number.isFinite(next.panY) ? next.panY : 0,
|
|
103
|
+
};
|
|
104
|
+
zoomRef.current = clamped;
|
|
105
|
+
|
|
106
|
+
if (!zoomingRef.current) {
|
|
107
|
+
zoomingRef.current = true;
|
|
108
|
+
const hud = hudRef.current;
|
|
109
|
+
if (hud) hud.style.opacity = "1";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
writeTransform(clamped);
|
|
113
|
+
|
|
114
|
+
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
|
115
|
+
settleTimerRef.current = setTimeout(() => {
|
|
116
|
+
zoomingRef.current = false;
|
|
117
|
+
const final = zoomRef.current;
|
|
118
|
+
writeStudioUiPreferences({ previewZoom: final });
|
|
119
|
+
const hud = hudRef.current;
|
|
120
|
+
if (hud) {
|
|
121
|
+
const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
|
|
122
|
+
hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
|
|
123
|
+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
|
|
124
|
+
hudTimerRef.current = setTimeout(() => {
|
|
125
|
+
if (hudRef.current) hudRef.current.style.opacity = "0";
|
|
126
|
+
}, ZOOM_HUD_TIMEOUT_MS);
|
|
127
|
+
}
|
|
128
|
+
}, ZOOM_SETTLE_MS);
|
|
129
|
+
},
|
|
130
|
+
[writeTransform],
|
|
131
|
+
);
|
|
132
|
+
|
|
55
133
|
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
56
134
|
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
|
|
57
135
|
prevRefreshKeyRef.current = refreshKey;
|
|
@@ -60,8 +138,16 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
60
138
|
|
|
61
139
|
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
|
|
62
140
|
|
|
141
|
+
const applyInitialZoom = useCallback(() => {
|
|
142
|
+
const z = zoomRef.current;
|
|
143
|
+
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
|
|
144
|
+
writeTransform(z);
|
|
145
|
+
}
|
|
146
|
+
}, [writeTransform]);
|
|
147
|
+
|
|
63
148
|
const handleNewPlayerLoad = () => {
|
|
64
149
|
onIframeLoad();
|
|
150
|
+
applyInitialZoom();
|
|
65
151
|
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
66
152
|
retiringTimerRef.current = setTimeout(() => {
|
|
67
153
|
setRetiringKey(null);
|
|
@@ -69,33 +155,167 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
69
155
|
}, 160);
|
|
70
156
|
};
|
|
71
157
|
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const viewport = viewportRef.current;
|
|
160
|
+
if (!viewport) return;
|
|
161
|
+
|
|
162
|
+
let lastZoomTime = 0;
|
|
163
|
+
|
|
164
|
+
const handleWheel = (event: WheelEvent) => {
|
|
165
|
+
const rect = viewport.getBoundingClientRect();
|
|
166
|
+
if (
|
|
167
|
+
event.clientX < rect.left ||
|
|
168
|
+
event.clientX > rect.right ||
|
|
169
|
+
event.clientY < rect.top ||
|
|
170
|
+
event.clientY > rect.bottom
|
|
171
|
+
) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const isZoomGesture = event.ctrlKey || event.metaKey;
|
|
176
|
+
|
|
177
|
+
if (isZoomGesture) {
|
|
178
|
+
lastZoomTime = Date.now();
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
|
|
182
|
+
const next = resolvePreviewWheelZoom({
|
|
183
|
+
state: zoomRef.current,
|
|
184
|
+
deltaY: event.deltaY,
|
|
185
|
+
viewportWidth: rect.width,
|
|
186
|
+
viewportHeight: rect.height,
|
|
187
|
+
});
|
|
188
|
+
applyZoom(next);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (Date.now() - lastZoomTime < 400) {
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
|
|
199
|
+
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
|
|
200
|
+
}, [applyZoom]);
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const viewport = viewportRef.current;
|
|
204
|
+
if (!viewport) return;
|
|
205
|
+
|
|
206
|
+
const handleDblClick = (event: MouseEvent) => {
|
|
207
|
+
if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
|
|
208
|
+
const rect = viewport.getBoundingClientRect();
|
|
209
|
+
if (
|
|
210
|
+
event.clientX < rect.left ||
|
|
211
|
+
event.clientX > rect.right ||
|
|
212
|
+
event.clientY < rect.top ||
|
|
213
|
+
event.clientY > rect.bottom
|
|
214
|
+
) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
applyZoom(DEFAULT_PREVIEW_ZOOM);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
document.addEventListener("dblclick", handleDblClick, { capture: true });
|
|
221
|
+
return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
|
|
222
|
+
}, [applyZoom]);
|
|
223
|
+
|
|
224
|
+
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
225
|
+
if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
|
|
226
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
227
|
+
dragRef.current = {
|
|
228
|
+
pointerId: event.pointerId,
|
|
229
|
+
startX: event.clientX,
|
|
230
|
+
startY: event.clientY,
|
|
231
|
+
originX: zoomRef.current.panX,
|
|
232
|
+
originY: zoomRef.current.panY,
|
|
233
|
+
};
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
const handlePointerMove = useCallback(
|
|
237
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
238
|
+
const drag = dragRef.current;
|
|
239
|
+
const viewport = viewportRef.current;
|
|
240
|
+
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
const rect = viewport.getBoundingClientRect();
|
|
243
|
+
const pan = clampPreviewPan({
|
|
244
|
+
panX: drag.originX + event.clientX - drag.startX,
|
|
245
|
+
panY: drag.originY + event.clientY - drag.startY,
|
|
246
|
+
zoomPercent: zoomRef.current.zoomPercent,
|
|
247
|
+
viewportWidth: rect.width,
|
|
248
|
+
viewportHeight: rect.height,
|
|
249
|
+
});
|
|
250
|
+
applyZoom({ ...zoomRef.current, ...pan });
|
|
251
|
+
},
|
|
252
|
+
[applyZoom],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
256
|
+
if (dragRef.current?.pointerId === event.pointerId) {
|
|
257
|
+
dragRef.current = null;
|
|
258
|
+
}
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const initial = zoomRef.current;
|
|
262
|
+
|
|
72
263
|
return (
|
|
73
264
|
<div className="flex flex-col h-full min-h-0">
|
|
74
265
|
<div
|
|
266
|
+
ref={viewportRef}
|
|
75
267
|
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"
|
|
76
268
|
tabIndex={0}
|
|
77
269
|
aria-label="Composition preview"
|
|
270
|
+
onPointerDown={handlePointerDown}
|
|
271
|
+
onPointerMove={handlePointerMove}
|
|
272
|
+
onPointerUp={finishDrag}
|
|
273
|
+
onPointerCancel={finishDrag}
|
|
78
274
|
>
|
|
79
|
-
|
|
275
|
+
<div
|
|
276
|
+
ref={stageRef}
|
|
277
|
+
className="absolute inset-2"
|
|
278
|
+
style={{
|
|
279
|
+
zoom: toDomPrecision(initial.zoomPercent / 100),
|
|
280
|
+
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
|
|
281
|
+
transformOrigin: "0 0",
|
|
282
|
+
}}
|
|
283
|
+
data-testid="preview-zoom-stage"
|
|
284
|
+
>
|
|
285
|
+
{retiringKey && (
|
|
286
|
+
<Player
|
|
287
|
+
key={retiringKey}
|
|
288
|
+
projectId={directUrl ? undefined : projectId}
|
|
289
|
+
directUrl={directUrl}
|
|
290
|
+
onLoad={() => {}}
|
|
291
|
+
portrait={portrait}
|
|
292
|
+
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
80
295
|
<Player
|
|
81
|
-
key={
|
|
296
|
+
key={activeKey}
|
|
297
|
+
ref={iframeRef}
|
|
82
298
|
projectId={directUrl ? undefined : projectId}
|
|
83
299
|
directUrl={directUrl}
|
|
84
|
-
onLoad={
|
|
300
|
+
onLoad={
|
|
301
|
+
retiringKey
|
|
302
|
+
? handleNewPlayerLoad
|
|
303
|
+
: () => {
|
|
304
|
+
onIframeLoad();
|
|
305
|
+
applyInitialZoom();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
85
309
|
portrait={portrait}
|
|
86
|
-
style={{ position: "absolute", inset: 0, zIndex:
|
|
310
|
+
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
311
|
+
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
87
312
|
/>
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
|
95
|
-
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
96
|
-
portrait={portrait}
|
|
97
|
-
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
98
|
-
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
313
|
+
</div>
|
|
314
|
+
<div
|
|
315
|
+
ref={hudRef}
|
|
316
|
+
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
|
|
317
|
+
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
|
|
318
|
+
aria-live="polite"
|
|
99
319
|
/>
|
|
100
320
|
</div>
|
|
101
321
|
</div>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PREVIEW_ZOOM,
|
|
4
|
+
MAX_PREVIEW_ZOOM_PERCENT,
|
|
5
|
+
MIN_PREVIEW_ZOOM_PERCENT,
|
|
6
|
+
clampPreviewPan,
|
|
7
|
+
clampPreviewZoomPercent,
|
|
8
|
+
getNextPreviewZoomPercent,
|
|
9
|
+
getPreviewWheelZoomPercent,
|
|
10
|
+
resolvePreviewWheelZoom,
|
|
11
|
+
toDomPrecision,
|
|
12
|
+
} from "./previewZoom";
|
|
13
|
+
|
|
14
|
+
describe("toDomPrecision", () => {
|
|
15
|
+
it("rounds to 4 decimal places", () => {
|
|
16
|
+
expect(toDomPrecision(1.23456789)).toBe(1.2346);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("preserves zero", () => {
|
|
20
|
+
expect(toDomPrecision(0)).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles negative values", () => {
|
|
24
|
+
expect(toDomPrecision(-3.14159)).toBe(-3.1416);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("clampPreviewZoomPercent", () => {
|
|
29
|
+
it("falls back to fit zoom for invalid input", () => {
|
|
30
|
+
expect(clampPreviewZoomPercent(Number.NaN)).toBe(100);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("clamps to supported preview zoom bounds", () => {
|
|
34
|
+
expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT);
|
|
35
|
+
expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("getPreviewWheelZoomPercent", () => {
|
|
40
|
+
it("zooms in on negative deltaY (scroll up / pinch out)", () => {
|
|
41
|
+
expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("zooms out on positive deltaY (scroll down / pinch in)", () => {
|
|
45
|
+
expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("clamps large deltas to prevent overshoot", () => {
|
|
49
|
+
const small = getPreviewWheelZoomPercent(-5, 100);
|
|
50
|
+
const large = getPreviewWheelZoomPercent(-50, 100);
|
|
51
|
+
expect(large).toBeLessThan(small * 2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("preserves the current zoom for invalid input", () => {
|
|
55
|
+
expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("getNextPreviewZoomPercent", () => {
|
|
60
|
+
it("steps preview zoom in and out", () => {
|
|
61
|
+
expect(getNextPreviewZoomPercent("in", 100)).toBe(125);
|
|
62
|
+
expect(getNextPreviewZoomPercent("out", 125)).toBe(100);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("clampPreviewPan", () => {
|
|
67
|
+
it("centers the preview when fit or zoomed out", () => {
|
|
68
|
+
expect(
|
|
69
|
+
clampPreviewPan({
|
|
70
|
+
panX: 120,
|
|
71
|
+
panY: -90,
|
|
72
|
+
zoomPercent: 100,
|
|
73
|
+
viewportWidth: 800,
|
|
74
|
+
viewportHeight: 600,
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({ panX: 0, panY: 0 });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps pan within the zoomed preview bounds", () => {
|
|
80
|
+
expect(
|
|
81
|
+
clampPreviewPan({
|
|
82
|
+
panX: 900,
|
|
83
|
+
panY: -900,
|
|
84
|
+
zoomPercent: 200,
|
|
85
|
+
viewportWidth: 800,
|
|
86
|
+
viewportHeight: 600,
|
|
87
|
+
}),
|
|
88
|
+
).toEqual({ panX: 400, panY: -300 });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("resolvePreviewWheelZoom", () => {
|
|
93
|
+
it("zooms in from center without shifting pan", () => {
|
|
94
|
+
const next = resolvePreviewWheelZoom({
|
|
95
|
+
state: DEFAULT_PREVIEW_ZOOM,
|
|
96
|
+
deltaY: -5,
|
|
97
|
+
viewportWidth: 800,
|
|
98
|
+
viewportHeight: 600,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(next.zoomPercent).toBeGreaterThan(100);
|
|
102
|
+
expect(next.panX).toBe(0);
|
|
103
|
+
expect(next.panY).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clamps pan when zooming out past minimum", () => {
|
|
107
|
+
const next = resolvePreviewWheelZoom({
|
|
108
|
+
state: { zoomPercent: 26, panX: 20, panY: 20 },
|
|
109
|
+
deltaY: 500,
|
|
110
|
+
viewportWidth: 800,
|
|
111
|
+
viewportHeight: 600,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0);
|
|
115
|
+
expect(next.panX).toBe(0);
|
|
116
|
+
expect(next.panY).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface PreviewZoomState {
|
|
2
|
+
zoomPercent: number;
|
|
3
|
+
panX: number;
|
|
4
|
+
panY: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const MIN_PREVIEW_ZOOM_PERCENT = 25;
|
|
8
|
+
export const MAX_PREVIEW_ZOOM_PERCENT = 400;
|
|
9
|
+
export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
|
|
10
|
+
zoomPercent: 100,
|
|
11
|
+
panX: 0,
|
|
12
|
+
panY: 0,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const ZOOM_SENSITIVITY = 0.007;
|
|
16
|
+
const MAX_DELTA = 10;
|
|
17
|
+
|
|
18
|
+
export function toDomPrecision(value: number): number {
|
|
19
|
+
return Math.round(value * 10000) / 10000;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clampPreviewZoomPercent(percent: number): number {
|
|
23
|
+
if (!Number.isFinite(percent)) return 100;
|
|
24
|
+
return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number {
|
|
28
|
+
if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent);
|
|
29
|
+
const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY;
|
|
30
|
+
const step = -clamped * ZOOM_SENSITIVITY;
|
|
31
|
+
const current = clampPreviewZoomPercent(currentZoomPercent);
|
|
32
|
+
return clampPreviewZoomPercent(current * Math.exp(step));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getNextPreviewZoomPercent(
|
|
36
|
+
direction: "in" | "out",
|
|
37
|
+
currentZoomPercent: number,
|
|
38
|
+
): number {
|
|
39
|
+
const current = clampPreviewZoomPercent(currentZoomPercent);
|
|
40
|
+
const multiplier = direction === "in" ? 1.25 : 0.8;
|
|
41
|
+
return clampPreviewZoomPercent(current * multiplier);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clampPreviewPan(input: {
|
|
45
|
+
panX: number;
|
|
46
|
+
panY: number;
|
|
47
|
+
zoomPercent: number;
|
|
48
|
+
viewportWidth: number;
|
|
49
|
+
viewportHeight: number;
|
|
50
|
+
}): Pick<PreviewZoomState, "panX" | "panY"> {
|
|
51
|
+
const scale = clampPreviewZoomPercent(input.zoomPercent) / 100;
|
|
52
|
+
if (scale <= 1) return { panX: 0, panY: 0 };
|
|
53
|
+
|
|
54
|
+
const maxPanX = ((scale - 1) * input.viewportWidth) / 2;
|
|
55
|
+
const maxPanY = ((scale - 1) * input.viewportHeight) / 2;
|
|
56
|
+
return {
|
|
57
|
+
panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
|
|
58
|
+
panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolvePreviewWheelZoom(input: {
|
|
63
|
+
state: PreviewZoomState;
|
|
64
|
+
deltaY: number;
|
|
65
|
+
viewportWidth: number;
|
|
66
|
+
viewportHeight: number;
|
|
67
|
+
}): PreviewZoomState {
|
|
68
|
+
const nextZoomPercent = getPreviewWheelZoomPercent(
|
|
69
|
+
input.deltaY,
|
|
70
|
+
clampPreviewZoomPercent(input.state.zoomPercent),
|
|
71
|
+
);
|
|
72
|
+
const pan = clampPreviewPan({
|
|
73
|
+
panX: input.state.panX,
|
|
74
|
+
panY: input.state.panY,
|
|
75
|
+
zoomPercent: nextZoomPercent,
|
|
76
|
+
viewportWidth: input.viewportWidth,
|
|
77
|
+
viewportHeight: input.viewportHeight,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
zoomPercent: nextZoomPercent,
|
|
82
|
+
...pan,
|
|
83
|
+
};
|
|
84
|
+
}
|