@hyperframes/studio 0.6.1 → 0.6.3
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-C_Hp2qSf.js +117 -0
- package/dist/assets/index-DMJCfYoN.css +1 -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 +302 -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
|
@@ -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
|
+
}
|
|
@@ -207,7 +207,7 @@ function FormatExportButton({
|
|
|
207
207
|
const showQuality = format !== "mov";
|
|
208
208
|
|
|
209
209
|
return (
|
|
210
|
-
<div className="flex items-center gap-1">
|
|
210
|
+
<div className="flex items-center gap-1 flex-wrap justify-end">
|
|
211
211
|
<FormatInfoTooltip format={format} />
|
|
212
212
|
{/* Resolution must remain the leftmost <select> in this row — it
|
|
213
213
|
carries `rounded-l` for the joined-button look. If you ever hide it
|
|
@@ -298,7 +298,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
298
298
|
return (
|
|
299
299
|
<div className="flex flex-col h-full">
|
|
300
300
|
{/* Header — no title, already shown in header button */}
|
|
301
|
-
<div className="flex items-center justify-end px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
|
|
301
|
+
<div className="flex items-center justify-end flex-wrap gap-y-1.5 px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
|
|
302
302
|
<div className="flex items-center gap-1.5">
|
|
303
303
|
{completedCount > 0 && (
|
|
304
304
|
<button
|
|
@@ -45,6 +45,7 @@ export function DomEditProvider({
|
|
|
45
45
|
handleDomManualDragStart,
|
|
46
46
|
handleDomEditElementDelete,
|
|
47
47
|
buildDomSelectionForTimelineElement,
|
|
48
|
+
updateDomEditHoverSelection,
|
|
48
49
|
resolveImportedFontAsset,
|
|
49
50
|
setAgentModalOpen,
|
|
50
51
|
setAgentPromptSelectionContext,
|
|
@@ -89,6 +90,7 @@ export function DomEditProvider({
|
|
|
89
90
|
handleDomManualDragStart,
|
|
90
91
|
handleDomEditElementDelete,
|
|
91
92
|
buildDomSelectionForTimelineElement,
|
|
93
|
+
updateDomEditHoverSelection,
|
|
92
94
|
resolveImportedFontAsset,
|
|
93
95
|
setAgentModalOpen,
|
|
94
96
|
setAgentPromptSelectionContext,
|
|
@@ -127,6 +129,7 @@ export function DomEditProvider({
|
|
|
127
129
|
handleDomManualDragStart,
|
|
128
130
|
handleDomEditElementDelete,
|
|
129
131
|
buildDomSelectionForTimelineElement,
|
|
132
|
+
updateDomEditHoverSelection,
|
|
130
133
|
resolveImportedFontAsset,
|
|
131
134
|
setAgentModalOpen,
|
|
132
135
|
setAgentPromptSelectionContext,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { LintFinding } from "../components/LintModal";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,10 +9,10 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
|
|
|
9
9
|
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
10
10
|
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
11
11
|
|
|
12
|
-
const resetErrors = () => {
|
|
12
|
+
const resetErrors = useCallback(() => {
|
|
13
13
|
consoleErrorsRef.current = [];
|
|
14
14
|
setConsoleErrors(null);
|
|
15
|
-
};
|
|
15
|
+
}, []);
|
|
16
16
|
|
|
17
17
|
// eslint-disable-next-line no-restricted-syntax
|
|
18
18
|
useEffect(() => {
|
|
@@ -334,6 +334,7 @@ export function useDomEditSession({
|
|
|
334
334
|
handleDomManualDragStart,
|
|
335
335
|
handleDomEditElementDelete,
|
|
336
336
|
buildDomSelectionForTimelineElement,
|
|
337
|
+
updateDomEditHoverSelection,
|
|
337
338
|
resolveImportedFontAsset,
|
|
338
339
|
setAgentModalOpen,
|
|
339
340
|
setAgentPromptSelectionContext,
|
|
@@ -50,7 +50,11 @@ export interface UseDomSelectionReturn {
|
|
|
50
50
|
// Callbacks
|
|
51
51
|
applyDomSelection: (
|
|
52
52
|
selection: DomEditSelection | null,
|
|
53
|
-
options?: {
|
|
53
|
+
options?: {
|
|
54
|
+
revealPanel?: boolean;
|
|
55
|
+
additive?: boolean;
|
|
56
|
+
preserveGroup?: boolean;
|
|
57
|
+
},
|
|
54
58
|
) => void;
|
|
55
59
|
clearDomSelection: () => void;
|
|
56
60
|
buildDomSelectionFromTarget: (
|
|
@@ -108,7 +112,11 @@ export function useDomSelection({
|
|
|
108
112
|
const applyDomSelection = useCallback(
|
|
109
113
|
(
|
|
110
114
|
selection: DomEditSelection | null,
|
|
111
|
-
options?: {
|
|
115
|
+
options?: {
|
|
116
|
+
revealPanel?: boolean;
|
|
117
|
+
additive?: boolean;
|
|
118
|
+
preserveGroup?: boolean;
|
|
119
|
+
},
|
|
112
120
|
) => {
|
|
113
121
|
if (!selection) {
|
|
114
122
|
domEditSelectionRef.current = null;
|
|
@@ -157,7 +165,9 @@ export function useDomSelection({
|
|
|
157
165
|
if (nextSelection) {
|
|
158
166
|
if (options?.revealPanel !== false) {
|
|
159
167
|
setRightCollapsed(false);
|
|
160
|
-
|
|
168
|
+
if (rightPanelTab !== "layers") {
|
|
169
|
+
setRightPanelTab("design");
|
|
170
|
+
}
|
|
161
171
|
}
|
|
162
172
|
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
163
173
|
nextSelection,
|
|
@@ -169,7 +179,13 @@ export function useDomSelection({
|
|
|
169
179
|
|
|
170
180
|
setSelectedTimelineElementId(null);
|
|
171
181
|
},
|
|
172
|
-
[
|
|
182
|
+
[
|
|
183
|
+
setSelectedTimelineElementId,
|
|
184
|
+
timelineElements,
|
|
185
|
+
setRightCollapsed,
|
|
186
|
+
setRightPanelTab,
|
|
187
|
+
rightPanelTab,
|
|
188
|
+
],
|
|
173
189
|
);
|
|
174
190
|
|
|
175
191
|
const clearDomSelection = useCallback(() => {
|
|
@@ -223,7 +239,9 @@ export function useDomSelection({
|
|
|
223
239
|
isMasterView,
|
|
224
240
|
});
|
|
225
241
|
return targetElement
|
|
226
|
-
? buildDomSelectionFromTarget(targetElement, {
|
|
242
|
+
? buildDomSelectionFromTarget(targetElement, {
|
|
243
|
+
preferClipAncestor: false,
|
|
244
|
+
})
|
|
227
245
|
: null;
|
|
228
246
|
},
|
|
229
247
|
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView, previewIframeRef],
|
|
@@ -259,7 +277,10 @@ export function useDomSelection({
|
|
|
259
277
|
|
|
260
278
|
const nextSelection = buildDomSelectionFromTarget(element);
|
|
261
279
|
if (nextSelection) {
|
|
262
|
-
applyDomSelection(nextSelection, {
|
|
280
|
+
applyDomSelection(nextSelection, {
|
|
281
|
+
revealPanel: false,
|
|
282
|
+
preserveGroup: true,
|
|
283
|
+
});
|
|
263
284
|
}
|
|
264
285
|
},
|
|
265
286
|
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|