@hyperframes/studio 0.6.30 → 0.6.32
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-pv1DOD.js +120 -0
- package/dist/assets/index-Cd3DF1je.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +52 -1
- package/src/components/StudioErrorBoundary.tsx +2 -1
- package/src/components/StudioLeftSidebar.tsx +4 -0
- package/src/components/StudioPreviewArea.tsx +29 -1
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/manualEditingAvailability.ts +10 -1
- package/src/components/nle/NLELayout.tsx +76 -10
- package/src/components/nle/NLEPreview.test.ts +3 -11
- package/src/components/nle/NLEPreview.tsx +10 -46
- package/src/components/nle/usePreviewBlockDrop.ts +109 -0
- package/src/components/sidebar/BlocksTab.tsx +22 -98
- package/src/components/sidebar/LeftSidebar.tsx +12 -4
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useDomEditSession.ts +19 -1
- package/src/hooks/useFileManager.ts +3 -0
- package/src/main.tsx +26 -6
- package/src/player/components/PlayerControls.tsx +59 -0
- package/src/utils/blockInstaller.ts +65 -32
- package/src/utils/timelineAssetDrop.ts +8 -1
- package/dist/assets/index-BWBj8I6Q.css +0 -1
- package/dist/assets/index-D790O3az.js +0 -115
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
type PreviewZoomState,
|
|
13
13
|
} from "./previewZoom";
|
|
14
14
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
15
|
-
|
|
16
15
|
interface NLEPreviewProps {
|
|
17
16
|
projectId: string;
|
|
18
17
|
iframeRef: Ref<HTMLIFrameElement>;
|
|
@@ -20,8 +19,8 @@ interface NLEPreviewProps {
|
|
|
20
19
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
21
20
|
portrait?: boolean;
|
|
22
21
|
directUrl?: string;
|
|
23
|
-
refreshKey?: number;
|
|
24
22
|
suppressLoadingOverlay?: boolean;
|
|
23
|
+
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
export function getPreviewPlayerKey({
|
|
@@ -30,7 +29,6 @@ export function getPreviewPlayerKey({
|
|
|
30
29
|
}: {
|
|
31
30
|
projectId: string;
|
|
32
31
|
directUrl?: string;
|
|
33
|
-
refreshKey?: number;
|
|
34
32
|
}): string {
|
|
35
33
|
return directUrl ?? projectId;
|
|
36
34
|
}
|
|
@@ -91,16 +89,16 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
91
89
|
onCompositionLoadingChange,
|
|
92
90
|
portrait,
|
|
93
91
|
directUrl,
|
|
94
|
-
refreshKey,
|
|
95
92
|
suppressLoadingOverlay,
|
|
93
|
+
onStageRef,
|
|
96
94
|
}: NLEPreviewProps) {
|
|
97
|
-
const
|
|
98
|
-
const prevRefreshKeyRef = useRef(refreshKey);
|
|
95
|
+
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
99
96
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
100
97
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
101
|
-
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
onStageRef?.(stageRef);
|
|
100
|
+
}, [onStageRef]);
|
|
102
101
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
|
|
103
|
-
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
104
102
|
|
|
105
103
|
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
106
104
|
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
|
|
@@ -120,7 +118,6 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
120
118
|
return () => {
|
|
121
119
|
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
|
122
120
|
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
|
|
123
|
-
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
124
121
|
};
|
|
125
122
|
}, []);
|
|
126
123
|
|
|
@@ -205,14 +202,6 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
205
202
|
[applyTransform],
|
|
206
203
|
);
|
|
207
204
|
|
|
208
|
-
if (refreshKey !== prevRefreshKeyRef.current) {
|
|
209
|
-
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
|
|
210
|
-
prevRefreshKeyRef.current = refreshKey;
|
|
211
|
-
setRetiringKey(oldKey);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
|
|
215
|
-
|
|
216
205
|
const applyInitialZoom = useCallback(() => {
|
|
217
206
|
const z = zoomRef.current;
|
|
218
207
|
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
|
|
@@ -220,16 +209,6 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
220
209
|
}
|
|
221
210
|
}, [writeTransform]);
|
|
222
211
|
|
|
223
|
-
const handleNewPlayerLoad = () => {
|
|
224
|
-
onIframeLoad();
|
|
225
|
-
applyInitialZoom();
|
|
226
|
-
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
|
227
|
-
retiringTimerRef.current = setTimeout(() => {
|
|
228
|
-
setRetiringKey(null);
|
|
229
|
-
retiringTimerRef.current = null;
|
|
230
|
-
}, 160);
|
|
231
|
-
};
|
|
232
|
-
|
|
233
212
|
useEffect(() => {
|
|
234
213
|
const viewport = viewportRef.current;
|
|
235
214
|
if (!viewport) return;
|
|
@@ -412,32 +391,17 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
412
391
|
}}
|
|
413
392
|
data-testid="preview-zoom-stage"
|
|
414
393
|
>
|
|
415
|
-
{retiringKey && (
|
|
416
|
-
<Player
|
|
417
|
-
key={retiringKey}
|
|
418
|
-
projectId={directUrl ? undefined : projectId}
|
|
419
|
-
directUrl={directUrl}
|
|
420
|
-
onLoad={() => {}}
|
|
421
|
-
portrait={portrait}
|
|
422
|
-
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
|
423
|
-
/>
|
|
424
|
-
)}
|
|
425
394
|
<Player
|
|
426
395
|
key={activeKey}
|
|
427
396
|
ref={iframeRef}
|
|
428
397
|
projectId={directUrl ? undefined : projectId}
|
|
429
398
|
directUrl={directUrl}
|
|
430
|
-
onLoad={
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
onIframeLoad();
|
|
435
|
-
applyInitialZoom();
|
|
436
|
-
}
|
|
437
|
-
}
|
|
399
|
+
onLoad={() => {
|
|
400
|
+
onIframeLoad();
|
|
401
|
+
applyInitialZoom();
|
|
402
|
+
}}
|
|
438
403
|
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
439
404
|
portrait={portrait}
|
|
440
|
-
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
|
441
405
|
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
442
406
|
/>
|
|
443
407
|
</div>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useCallback, useState, type RefObject } from "react";
|
|
2
|
+
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
|
|
4
|
+
interface UsePreviewBlockDropOptions {
|
|
5
|
+
portrait?: boolean;
|
|
6
|
+
stageRef: RefObject<HTMLDivElement | null>;
|
|
7
|
+
onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface BlockDropPayload {
|
|
11
|
+
name: string;
|
|
12
|
+
dimensions?: { width: number; height: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseBlockPayload(raw: string): BlockDropPayload | null {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(raw) as {
|
|
18
|
+
name?: string;
|
|
19
|
+
dimensions?: { width: number; height: number };
|
|
20
|
+
};
|
|
21
|
+
return parsed.name ? (parsed as BlockDropPayload) : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveCompositionPosition(
|
|
28
|
+
clientX: number,
|
|
29
|
+
clientY: number,
|
|
30
|
+
stageRect: DOMRect,
|
|
31
|
+
portrait: boolean | undefined,
|
|
32
|
+
): { left: number; top: number } | null {
|
|
33
|
+
if (stageRect.width === 0 || stageRect.height === 0) return null;
|
|
34
|
+
|
|
35
|
+
const normalizedX = (clientX - stageRect.left) / stageRect.width;
|
|
36
|
+
const normalizedY = (clientY - stageRect.top) / stageRect.height;
|
|
37
|
+
|
|
38
|
+
const compWidth = portrait ? 1080 : 1920;
|
|
39
|
+
const compHeight = portrait ? 1920 : 1080;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
|
|
43
|
+
top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function centerBlockAtPosition(
|
|
48
|
+
pos: { left: number; top: number },
|
|
49
|
+
block: BlockDropPayload,
|
|
50
|
+
): { left: number; top: number } {
|
|
51
|
+
const blockW = block.dimensions?.width ?? 0;
|
|
52
|
+
const blockH = block.dimensions?.height ?? 0;
|
|
53
|
+
return {
|
|
54
|
+
left: Math.max(0, pos.left - blockW / 2),
|
|
55
|
+
top: Math.max(0, pos.top - blockH / 2),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function usePreviewBlockDrop({
|
|
60
|
+
portrait,
|
|
61
|
+
stageRef,
|
|
62
|
+
onBlockDrop,
|
|
63
|
+
}: UsePreviewBlockDropOptions) {
|
|
64
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
65
|
+
|
|
66
|
+
const handleDragOver = useCallback(
|
|
67
|
+
(e: React.DragEvent) => {
|
|
68
|
+
if (!onBlockDrop) return;
|
|
69
|
+
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
e.dataTransfer.dropEffect = "copy";
|
|
72
|
+
setIsDragOver(true);
|
|
73
|
+
},
|
|
74
|
+
[onBlockDrop],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const handleDragLeave = useCallback(() => {
|
|
78
|
+
setIsDragOver(false);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
82
|
+
const handleDrop = useCallback(
|
|
83
|
+
(e: React.DragEvent) => {
|
|
84
|
+
setIsDragOver(false);
|
|
85
|
+
if (!onBlockDrop) return;
|
|
86
|
+
|
|
87
|
+
const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
|
88
|
+
if (!payload) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
|
|
91
|
+
const block = parseBlockPayload(payload);
|
|
92
|
+
const stage = stageRef.current;
|
|
93
|
+
if (!block || !stage) return;
|
|
94
|
+
|
|
95
|
+
const pos = resolveCompositionPosition(
|
|
96
|
+
e.clientX,
|
|
97
|
+
e.clientY,
|
|
98
|
+
stage.getBoundingClientRect(),
|
|
99
|
+
portrait,
|
|
100
|
+
);
|
|
101
|
+
if (!pos) return;
|
|
102
|
+
|
|
103
|
+
onBlockDrop(block.name, centerBlockAtPosition(pos, block));
|
|
104
|
+
},
|
|
105
|
+
[onBlockDrop, stageRef, portrait],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
|
|
109
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
-
import { createPortal } from "react-dom";
|
|
3
2
|
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
|
|
4
3
|
import {
|
|
5
4
|
BLOCK_CATEGORIES,
|
|
@@ -8,12 +7,19 @@ import {
|
|
|
8
7
|
} from "../../utils/blockCategories";
|
|
9
8
|
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
10
9
|
|
|
10
|
+
export interface BlockPreviewInfo {
|
|
11
|
+
videoUrl?: string;
|
|
12
|
+
posterUrl?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
interface BlocksTabProps {
|
|
12
17
|
onAddBlock: (blockName: string) => void;
|
|
18
|
+
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
// fallow-ignore-next-line complexity
|
|
16
|
-
export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
|
|
22
|
+
export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
|
|
17
23
|
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
|
|
18
24
|
useBlockCatalog();
|
|
19
25
|
|
|
@@ -114,6 +120,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps)
|
|
|
114
120
|
videoUrl={block.preview?.video}
|
|
115
121
|
dimensions={dims}
|
|
116
122
|
onAdd={() => onAddBlock(block.name)}
|
|
123
|
+
onPreview={onPreviewBlock}
|
|
117
124
|
/>
|
|
118
125
|
);
|
|
119
126
|
})}
|
|
@@ -163,6 +170,7 @@ function BlockCard({
|
|
|
163
170
|
videoUrl,
|
|
164
171
|
dimensions,
|
|
165
172
|
onAdd,
|
|
173
|
+
onPreview,
|
|
166
174
|
}: {
|
|
167
175
|
name: string;
|
|
168
176
|
title: string;
|
|
@@ -173,52 +181,35 @@ function BlockCard({
|
|
|
173
181
|
videoUrl?: string;
|
|
174
182
|
dimensions?: { width: number; height: number };
|
|
175
183
|
onAdd: () => void;
|
|
184
|
+
onPreview?: (preview: BlockPreviewInfo | null) => void;
|
|
176
185
|
}) {
|
|
177
186
|
const [hovered, setHovered] = useState(false);
|
|
178
187
|
const [adding, setAdding] = useState(false);
|
|
179
188
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
180
|
-
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
181
|
-
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
182
189
|
const colors = getCategoryColors(category);
|
|
183
190
|
const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
|
|
184
191
|
|
|
185
|
-
const cancelLeave = useCallback(() => {
|
|
186
|
-
if (leaveTimer.current) {
|
|
187
|
-
clearTimeout(leaveTimer.current);
|
|
188
|
-
leaveTimer.current = null;
|
|
189
|
-
}
|
|
190
|
-
}, []);
|
|
191
|
-
|
|
192
192
|
const handleEnter = useCallback(() => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (hoverTimer.current) {
|
|
199
|
-
clearTimeout(hoverTimer.current);
|
|
200
|
-
hoverTimer.current = null;
|
|
201
|
-
}
|
|
202
|
-
cancelLeave();
|
|
203
|
-
setHovered(false);
|
|
204
|
-
}, [cancelLeave]);
|
|
193
|
+
hoverTimer.current = setTimeout(() => {
|
|
194
|
+
setHovered(true);
|
|
195
|
+
onPreview?.({ videoUrl, posterUrl, title });
|
|
196
|
+
}, 300);
|
|
197
|
+
}, [onPreview, videoUrl, posterUrl, title]);
|
|
205
198
|
|
|
206
199
|
const handleLeave = useCallback(() => {
|
|
207
200
|
if (hoverTimer.current) {
|
|
208
201
|
clearTimeout(hoverTimer.current);
|
|
209
202
|
hoverTimer.current = null;
|
|
210
203
|
}
|
|
211
|
-
|
|
212
|
-
|
|
204
|
+
setHovered(false);
|
|
205
|
+
onPreview?.(null);
|
|
206
|
+
}, [onPreview]);
|
|
213
207
|
|
|
214
208
|
useEffect(() => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (e.key === "Escape") dismiss();
|
|
209
|
+
return () => {
|
|
210
|
+
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
218
211
|
};
|
|
219
|
-
|
|
220
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
221
|
-
}, [hovered, dismiss]);
|
|
212
|
+
}, []);
|
|
222
213
|
|
|
223
214
|
const handleAdd = useCallback(
|
|
224
215
|
(e: React.MouseEvent) => {
|
|
@@ -251,7 +242,6 @@ function BlockCard({
|
|
|
251
242
|
<div className="aspect-video w-full overflow-hidden relative">
|
|
252
243
|
{hovered && videoUrl ? (
|
|
253
244
|
<video
|
|
254
|
-
ref={videoRef}
|
|
255
245
|
src={videoUrl}
|
|
256
246
|
autoPlay
|
|
257
247
|
muted
|
|
@@ -313,72 +303,6 @@ function BlockCard({
|
|
|
313
303
|
</span>
|
|
314
304
|
</div>
|
|
315
305
|
</div>
|
|
316
|
-
|
|
317
|
-
{/* Fullscreen hover preview */}
|
|
318
|
-
{hovered &&
|
|
319
|
-
(videoUrl || posterUrl) &&
|
|
320
|
-
createPortal(
|
|
321
|
-
<div
|
|
322
|
-
className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
|
|
323
|
-
onClick={dismiss}
|
|
324
|
-
onPointerEnter={cancelLeave}
|
|
325
|
-
onPointerLeave={handleLeave}
|
|
326
|
-
>
|
|
327
|
-
<div className="bg-black/80 absolute inset-0" />
|
|
328
|
-
<button
|
|
329
|
-
type="button"
|
|
330
|
-
onClick={dismiss}
|
|
331
|
-
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
|
|
332
|
-
>
|
|
333
|
-
<svg
|
|
334
|
-
width="16"
|
|
335
|
-
height="16"
|
|
336
|
-
viewBox="0 0 24 24"
|
|
337
|
-
fill="none"
|
|
338
|
-
stroke="currentColor"
|
|
339
|
-
strokeWidth="2"
|
|
340
|
-
strokeLinecap="round"
|
|
341
|
-
strokeLinejoin="round"
|
|
342
|
-
>
|
|
343
|
-
<path d="M18 6 6 18" />
|
|
344
|
-
<path d="m6 6 12 12" />
|
|
345
|
-
</svg>
|
|
346
|
-
</button>
|
|
347
|
-
<div
|
|
348
|
-
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
|
|
349
|
-
style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
|
|
350
|
-
onClick={(e) => e.stopPropagation()}
|
|
351
|
-
>
|
|
352
|
-
<div className="aspect-video bg-neutral-950">
|
|
353
|
-
{videoUrl ? (
|
|
354
|
-
<video
|
|
355
|
-
src={videoUrl}
|
|
356
|
-
autoPlay
|
|
357
|
-
muted
|
|
358
|
-
loop
|
|
359
|
-
playsInline
|
|
360
|
-
className="w-full h-full object-contain"
|
|
361
|
-
/>
|
|
362
|
-
) : (
|
|
363
|
-
<img src={posterUrl} alt={title} className="w-full h-full object-contain" />
|
|
364
|
-
)}
|
|
365
|
-
</div>
|
|
366
|
-
<div className="bg-neutral-900/95 px-4 py-3">
|
|
367
|
-
<div className="text-[14px] font-semibold text-neutral-100">{title}</div>
|
|
368
|
-
<div className="flex items-center gap-2 mt-1">
|
|
369
|
-
<span className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
|
370
|
-
<span className={`text-[11px] ${colors.text}`}>
|
|
371
|
-
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
|
|
372
|
-
</span>
|
|
373
|
-
{duration != null && (
|
|
374
|
-
<span className="text-[11px] text-neutral-500">{duration}s</span>
|
|
375
|
-
)}
|
|
376
|
-
</div>
|
|
377
|
-
</div>
|
|
378
|
-
</div>
|
|
379
|
-
</div>,
|
|
380
|
-
document.body,
|
|
381
|
-
)}
|
|
382
306
|
</div>
|
|
383
307
|
);
|
|
384
308
|
}
|
|
@@ -3,13 +3,14 @@ import {
|
|
|
3
3
|
useState,
|
|
4
4
|
useCallback,
|
|
5
5
|
useImperativeHandle,
|
|
6
|
+
useRef,
|
|
6
7
|
forwardRef,
|
|
7
8
|
type ReactNode,
|
|
8
9
|
} from "react";
|
|
9
10
|
import { CompositionsTab } from "./CompositionsTab";
|
|
10
11
|
import { AssetsTab } from "./AssetsTab";
|
|
11
12
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
12
|
-
import { BlocksTab } from "./BlocksTab";
|
|
13
|
+
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
13
14
|
import { FileTree } from "../editor/FileTree";
|
|
14
15
|
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
|
15
16
|
|
|
@@ -17,6 +18,7 @@ export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
|
|
17
18
|
|
|
18
19
|
export interface LeftSidebarHandle {
|
|
19
20
|
selectTab: (tab: SidebarTab) => void;
|
|
21
|
+
getTab: () => SidebarTab;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
@@ -53,6 +55,7 @@ interface LeftSidebarProps {
|
|
|
53
55
|
linting?: boolean;
|
|
54
56
|
onToggleCollapse?: () => void;
|
|
55
57
|
onAddBlock?: (blockName: string) => void;
|
|
58
|
+
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
56
59
|
takeoverContent?: ReactNode;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -82,11 +85,14 @@ export const LeftSidebar = memo(
|
|
|
82
85
|
linting,
|
|
83
86
|
onToggleCollapse,
|
|
84
87
|
onAddBlock,
|
|
88
|
+
onPreviewBlock,
|
|
85
89
|
takeoverContent,
|
|
86
90
|
},
|
|
87
91
|
ref,
|
|
88
92
|
) {
|
|
89
93
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
94
|
+
const tabRef = useRef(tab);
|
|
95
|
+
tabRef.current = tab;
|
|
90
96
|
|
|
91
97
|
const selectTab = useCallback((t: SidebarTab) => {
|
|
92
98
|
setTab(t);
|
|
@@ -94,7 +100,9 @@ export const LeftSidebar = memo(
|
|
|
94
100
|
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
|
95
101
|
}, []);
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
const getTab = useCallback(() => tabRef.current, []);
|
|
104
|
+
|
|
105
|
+
useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
|
|
98
106
|
|
|
99
107
|
return (
|
|
100
108
|
<div
|
|
@@ -159,7 +167,7 @@ export const LeftSidebar = memo(
|
|
|
159
167
|
: "text-neutral-500 hover:text-neutral-200"
|
|
160
168
|
}`}
|
|
161
169
|
>
|
|
162
|
-
|
|
170
|
+
Catalog
|
|
163
171
|
</button>
|
|
164
172
|
)}
|
|
165
173
|
</div>
|
|
@@ -239,7 +247,7 @@ export const LeftSidebar = memo(
|
|
|
239
247
|
)}
|
|
240
248
|
|
|
241
249
|
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
|
|
242
|
-
<BlocksTab onAddBlock={onAddBlock} />
|
|
250
|
+
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
|
|
243
251
|
)}
|
|
244
252
|
|
|
245
253
|
{/* Lint button pinned at the bottom */}
|
|
@@ -248,6 +248,24 @@ export function useAppHotkeys({
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
// F — toggle fullscreen preview
|
|
252
|
+
if (
|
|
253
|
+
event.key.toLowerCase() === "f" &&
|
|
254
|
+
!event.metaKey &&
|
|
255
|
+
!event.ctrlKey &&
|
|
256
|
+
!event.altKey &&
|
|
257
|
+
!event.shiftKey &&
|
|
258
|
+
!isEditableTarget(event.target)
|
|
259
|
+
) {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
if (document.fullscreenElement) {
|
|
262
|
+
void document.exitFullscreen();
|
|
263
|
+
} else {
|
|
264
|
+
document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
|
252
270
|
if (
|
|
253
271
|
(event.key === "Delete" || event.key === "Backspace") &&
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
4
4
|
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
|
|
@@ -56,6 +56,7 @@ export interface UseDomEditSessionParams {
|
|
|
56
56
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
57
57
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
|
58
58
|
selectSidebarTab?: (tab: SidebarTab) => void;
|
|
59
|
+
getSidebarTab?: () => SidebarTab;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
// ── Hook ──
|
|
@@ -93,6 +94,7 @@ export function useDomEditSession({
|
|
|
93
94
|
setRefreshKey: _setRefreshKey,
|
|
94
95
|
openSourceForSelection,
|
|
95
96
|
selectSidebarTab,
|
|
97
|
+
getSidebarTab,
|
|
96
98
|
}: UseDomEditSessionParams) {
|
|
97
99
|
void _setRefreshKey;
|
|
98
100
|
|
|
@@ -281,6 +283,22 @@ export function useDomEditSession({
|
|
|
281
283
|
applyStudioManualEditsToPreviewRef,
|
|
282
284
|
]);
|
|
283
285
|
|
|
286
|
+
// Auto-reveal source when an element is selected while the Code tab is active.
|
|
287
|
+
// Use a ref for the callback so the effect only fires on selection changes,
|
|
288
|
+
// not when openSourceForSelection is recreated due to editingFile content updates.
|
|
289
|
+
const openSourceRef = useRef(openSourceForSelection);
|
|
290
|
+
openSourceRef.current = openSourceForSelection;
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
|
|
293
|
+
if (!domEditSelection.sourceFile) return;
|
|
294
|
+
if (getSidebarTab() !== "code") return;
|
|
295
|
+
openSourceRef.current(domEditSelection.sourceFile, {
|
|
296
|
+
id: domEditSelection.id,
|
|
297
|
+
selector: domEditSelection.selector,
|
|
298
|
+
selectorIndex: domEditSelection.selectorIndex,
|
|
299
|
+
});
|
|
300
|
+
}, [domEditSelection, getSidebarTab]);
|
|
301
|
+
|
|
284
302
|
return {
|
|
285
303
|
// State
|
|
286
304
|
domEditSelection,
|
|
@@ -122,6 +122,9 @@ export function useFileManager({
|
|
|
122
122
|
const handleFileSelect = useCallback((path: string) => {
|
|
123
123
|
const pid = projectIdRef.current;
|
|
124
124
|
if (!pid) return;
|
|
125
|
+
revealAbortRef.current?.abort();
|
|
126
|
+
revealAbortRef.current = null;
|
|
127
|
+
revealRequestIdRef.current++;
|
|
125
128
|
// Skip fetching binary content for media files — just set the path for preview
|
|
126
129
|
if (isMediaFile(path)) {
|
|
127
130
|
setEditingFile({ path, content: null });
|
package/src/main.tsx
CHANGED
|
@@ -7,19 +7,39 @@ import "./styles/studio.css";
|
|
|
7
7
|
|
|
8
8
|
trackStudioEvent("session_start");
|
|
9
9
|
|
|
10
|
+
function errorProps(value: unknown): {
|
|
11
|
+
error_message: string;
|
|
12
|
+
error_name: string | null;
|
|
13
|
+
stack_trace: string | null;
|
|
14
|
+
} {
|
|
15
|
+
if (value instanceof Error) {
|
|
16
|
+
return {
|
|
17
|
+
error_message: value.message,
|
|
18
|
+
error_name: value.name,
|
|
19
|
+
stack_trace: value.stack?.slice(0, 4000) ?? null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { error_message: String(value), error_name: null, stack_trace: null };
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
window.addEventListener("error", (event) => {
|
|
26
|
+
if (event.message?.includes("ResizeObserver loop")) {
|
|
27
|
+
event.stopImmediatePropagation();
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
trackStudioEvent("unhandled_error", {
|
|
33
|
+
...errorProps(event.error),
|
|
12
34
|
error_message: event.message,
|
|
13
|
-
filename: event.filename
|
|
14
|
-
lineno: event.lineno
|
|
15
|
-
colno: event.colno
|
|
35
|
+
filename: event.filename,
|
|
36
|
+
lineno: event.lineno,
|
|
37
|
+
colno: event.colno,
|
|
16
38
|
});
|
|
17
39
|
});
|
|
18
40
|
|
|
19
41
|
window.addEventListener("unhandledrejection", (event) => {
|
|
20
|
-
trackStudioEvent("unhandled_promise_rejection",
|
|
21
|
-
error_message: event.reason instanceof Error ? event.reason.message : String(event.reason),
|
|
22
|
-
});
|
|
42
|
+
trackStudioEvent("unhandled_promise_rejection", errorProps(event.reason));
|
|
23
43
|
});
|
|
24
44
|
|
|
25
45
|
createRoot(document.getElementById("root")!).render(
|