@hyperframes/studio 0.6.0 → 0.6.2
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/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -13
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/StudioPreviewArea.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +88 -1007
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1150
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEdits.ts +84 -1081
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +60 -144
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Timeline.tsx +189 -1418
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +69 -1372
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
- package/dist/assets/index-DUqUmaoH.js +0 -117
|
@@ -4,7 +4,8 @@ import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../
|
|
|
4
4
|
import type { TimelineElement } from "../../player";
|
|
5
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
6
6
|
import { NLEPreview } from "./NLEPreview";
|
|
7
|
-
import { CompositionBreadcrumb
|
|
7
|
+
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
8
|
+
import { useCompositionStack } from "./useCompositionStack";
|
|
8
9
|
import {
|
|
9
10
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
10
11
|
getTimelineToggleTitle,
|
|
@@ -101,19 +102,14 @@ export const NLELayout = memo(function NLELayout({
|
|
|
101
102
|
saveSeekPosition,
|
|
102
103
|
} = useTimelinePlayer();
|
|
103
104
|
|
|
104
|
-
// Reset timeline state when the project changes
|
|
105
|
-
// previous project leaking into the new one.
|
|
105
|
+
// Reset timeline state when the project changes
|
|
106
106
|
const prevProjectIdRef = useRef(projectId);
|
|
107
107
|
if (prevProjectIdRef.current !== projectId) {
|
|
108
108
|
prevProjectIdRef.current = projectId;
|
|
109
|
-
// Only reset Zustand state during render (safe — pure state update).
|
|
110
|
-
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
|
|
111
109
|
usePlayerStore.getState().reset();
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
// Save seek position before
|
|
115
|
-
// on refreshKey change. The Player handles the actual reload via the
|
|
116
|
-
// dual-player crossfade; we just need to persist the current time.
|
|
112
|
+
// Save seek position before refresh
|
|
117
113
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
118
114
|
useEffect(() => {
|
|
119
115
|
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
@@ -121,14 +117,61 @@ export const NLELayout = memo(function NLELayout({
|
|
|
121
117
|
saveSeekPosition();
|
|
122
118
|
}, [refreshKey, saveSeekPosition]);
|
|
123
119
|
|
|
124
|
-
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
125
120
|
const onIframeLoad = useCallback(() => {
|
|
126
121
|
baseOnIframeLoad();
|
|
127
122
|
onIframeRef?.(iframeRef.current);
|
|
128
123
|
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
|
129
124
|
|
|
130
|
-
|
|
131
|
-
|
|
125
|
+
const {
|
|
126
|
+
compositionStack,
|
|
127
|
+
updateCompositionStack,
|
|
128
|
+
handleNavigateComposition,
|
|
129
|
+
handleDrillDown: drillDown,
|
|
130
|
+
masterSeekRef,
|
|
131
|
+
compIdToSrc,
|
|
132
|
+
setCompIdToSrc,
|
|
133
|
+
} = useCompositionStack({
|
|
134
|
+
projectId,
|
|
135
|
+
activeCompositionPath,
|
|
136
|
+
onCompositionChange,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Wrap handleDrillDown to also scan the iframe DOM for data-composition-src
|
|
140
|
+
const iframeRef_ = iframeRef;
|
|
141
|
+
const handleDrillDown = useCallback(
|
|
142
|
+
(element: TimelineElement) => {
|
|
143
|
+
if (!element.compositionSrc) return;
|
|
144
|
+
// Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
|
|
145
|
+
const compId = element.id;
|
|
146
|
+
let resolvedPath = compIdToSrc.get(compId);
|
|
147
|
+
if (!resolvedPath) {
|
|
148
|
+
try {
|
|
149
|
+
const doc = iframeRef_.current?.contentDocument;
|
|
150
|
+
if (doc) {
|
|
151
|
+
const host = doc.querySelector(
|
|
152
|
+
`[data-composition-id="${compId}"][data-composition-src]`,
|
|
153
|
+
);
|
|
154
|
+
if (host) {
|
|
155
|
+
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* cross-origin */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Delegate with the resolved compositionSrc (may be same as original)
|
|
163
|
+
drillDown({
|
|
164
|
+
id: compId,
|
|
165
|
+
compositionSrc: resolvedPath ?? element.compositionSrc,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
[compIdToSrc, drillDown, iframeRef_],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Composition ID → file path map from raw index.html
|
|
172
|
+
const compIdToSrcRef = useRef(compIdToSrc);
|
|
173
|
+
compIdToSrcRef.current = compIdToSrc;
|
|
174
|
+
|
|
132
175
|
useMountEffect(() => {
|
|
133
176
|
fetch(`/api/projects/${projectId}/files/index.html`)
|
|
134
177
|
.then((r) => r.json())
|
|
@@ -150,12 +193,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
150
193
|
});
|
|
151
194
|
|
|
152
195
|
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
|
153
|
-
// The runtime strips data-composition-src from the DOM after loading, so elements
|
|
154
|
-
// arrive without it. This bridges the gap using the map built from raw HTML.
|
|
155
|
-
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
|
|
156
|
-
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
|
|
157
|
-
const compIdToSrcRef = useRef(compIdToSrc);
|
|
158
|
-
compIdToSrcRef.current = compIdToSrc;
|
|
159
196
|
// eslint-disable-next-line no-restricted-syntax
|
|
160
197
|
useEffect(() => {
|
|
161
198
|
if (compIdToSrc.size === 0) return;
|
|
@@ -165,7 +202,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
165
202
|
let patched = false;
|
|
166
203
|
const updated = elements.map((el) => {
|
|
167
204
|
if (el.compositionSrc) return el;
|
|
168
|
-
// Try exact match, then strip common suffixes (-host, -comp, -layer)
|
|
169
205
|
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
|
|
170
206
|
if (src) {
|
|
171
207
|
patched = true;
|
|
@@ -175,15 +211,12 @@ export const NLELayout = memo(function NLELayout({
|
|
|
175
211
|
});
|
|
176
212
|
return patched ? updated : null;
|
|
177
213
|
};
|
|
178
|
-
// Patch current elements immediately
|
|
179
214
|
const patched = patchElements(usePlayerStore.getState().elements);
|
|
180
215
|
if (patched) usePlayerStore.getState().setElements(patched);
|
|
181
|
-
// Subscribe for future element updates — use a flag to prevent re-entrant patching
|
|
182
216
|
let patching = false;
|
|
183
217
|
return usePlayerStore.subscribe((state, prev) => {
|
|
184
218
|
if (patching) return;
|
|
185
219
|
if (state.elements === prev.elements || state.elements.length === 0) return;
|
|
186
|
-
// Skip if all elements already have compositionSrc
|
|
187
220
|
if (state.elements.every((el) => el.compositionSrc)) return;
|
|
188
221
|
patching = true;
|
|
189
222
|
const result = patchElements(state.elements);
|
|
@@ -192,23 +225,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
192
225
|
});
|
|
193
226
|
}, [compIdToSrc]);
|
|
194
227
|
|
|
195
|
-
// Composition drill-down stack
|
|
196
|
-
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
|
197
|
-
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
198
|
-
]);
|
|
199
|
-
|
|
200
|
-
// Wrap setCompositionStack to auto-notify parent on composition change
|
|
201
|
-
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
202
|
-
onCompositionChangeRef.current = onCompositionChange;
|
|
203
|
-
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
204
|
-
setCompositionStack((prev) => {
|
|
205
|
-
const next = typeof action === "function" ? action(prev) : action;
|
|
206
|
-
const id = next[next.length - 1]?.id;
|
|
207
|
-
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
208
|
-
return next;
|
|
209
|
-
});
|
|
210
|
-
}, []);
|
|
211
|
-
|
|
212
228
|
// Resizable timeline height
|
|
213
229
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
214
230
|
const hasLoadedOnceRef = useRef(false);
|
|
@@ -223,11 +239,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
223
239
|
useEffect(() => {
|
|
224
240
|
onCompositionLoadingChangeParent?.(compositionLoading);
|
|
225
241
|
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
|
242
|
+
|
|
226
243
|
const isTimelineVisible = timelineVisible ?? true;
|
|
227
244
|
const isDragging = useRef(false);
|
|
228
245
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
229
246
|
|
|
230
|
-
// Current preview URL — derived from composition stack
|
|
231
247
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
232
248
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
233
249
|
|
|
@@ -235,106 +251,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
235
251
|
onIframeRef?.(iframeRef.current);
|
|
236
252
|
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
|
|
237
253
|
|
|
238
|
-
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
239
|
-
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
240
|
-
const masterSeekRef = useRef(0);
|
|
241
|
-
|
|
242
|
-
// Drill-down: push a sub-composition onto the stack
|
|
243
|
-
const iframeRef_ = iframeRef; // stable ref for the callback
|
|
244
|
-
const handleDrillDown = useCallback(
|
|
245
|
-
(element: TimelineElement) => {
|
|
246
|
-
if (!element.compositionSrc) return;
|
|
247
|
-
// Save current master playback position for back-navigation
|
|
248
|
-
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
249
|
-
saveSeekPosition();
|
|
250
|
-
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
|
251
|
-
// Extract the element's composition ID from its timeline ID
|
|
252
|
-
const compId = element.id;
|
|
253
|
-
|
|
254
|
-
// 1. Check compIdToSrc map (from index.html)
|
|
255
|
-
// 2. Scan the current iframe DOM for data-composition-src attribute
|
|
256
|
-
// 3. Fall back to stripping the compositionSrc to a relative path
|
|
257
|
-
let resolvedPath = compIdToSrc.get(compId);
|
|
258
|
-
if (!resolvedPath) {
|
|
259
|
-
try {
|
|
260
|
-
const doc = iframeRef_.current?.contentDocument;
|
|
261
|
-
if (doc) {
|
|
262
|
-
const host = doc.querySelector(
|
|
263
|
-
`[data-composition-id="${compId}"][data-composition-src]`,
|
|
264
|
-
);
|
|
265
|
-
if (host) {
|
|
266
|
-
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
} catch {
|
|
270
|
-
/* cross-origin */
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (!resolvedPath) {
|
|
274
|
-
// Strip full URL to relative path if needed
|
|
275
|
-
const src = element.compositionSrc;
|
|
276
|
-
const compMatch = src.match(/compositions\/.*\.html/);
|
|
277
|
-
resolvedPath = compMatch ? compMatch[0] : src;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
usePlayerStore.getState().setElements([]);
|
|
281
|
-
|
|
282
|
-
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
|
283
|
-
updateCompositionStack((prev) => {
|
|
284
|
-
const currentId = prev[prev.length - 1].id;
|
|
285
|
-
if (currentId === resolvedPath && prev.length > 1) {
|
|
286
|
-
return prev.slice(0, -1);
|
|
287
|
-
}
|
|
288
|
-
// Extract a clean label from the path (strip directories and extension)
|
|
289
|
-
const label =
|
|
290
|
-
resolvedPath
|
|
291
|
-
.split("/")
|
|
292
|
-
.pop()
|
|
293
|
-
?.replace(/\.html$/, "") || resolvedPath;
|
|
294
|
-
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
|
295
|
-
return [...prev, { id: resolvedPath, label, previewUrl }];
|
|
296
|
-
});
|
|
297
|
-
},
|
|
298
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
299
|
-
[projectId, compIdToSrc],
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
// Navigate back to a specific breadcrumb level
|
|
303
|
-
const handleNavigateComposition = useCallback((index: number) => {
|
|
304
|
-
// When going back to master (index 0), restore the saved master position
|
|
305
|
-
if (index === 0 && masterSeekRef.current > 0) {
|
|
306
|
-
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
307
|
-
}
|
|
308
|
-
saveSeekPosition();
|
|
309
|
-
usePlayerStore.getState().setElements([]);
|
|
310
|
-
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
311
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
312
|
-
}, []);
|
|
313
|
-
|
|
314
|
-
// Navigate to a composition when activeCompositionPath changes.
|
|
315
|
-
// Uses useEffect to ensure state updates happen after render commit,
|
|
316
|
-
// avoiding render-time mutations that React can swallow during batching.
|
|
317
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
318
|
-
useEffect(() => {
|
|
319
|
-
if (activeCompositionPath === "index.html") {
|
|
320
|
-
usePlayerStore.getState().setElements([]);
|
|
321
|
-
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
|
322
|
-
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
|
|
323
|
-
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
324
|
-
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
|
325
|
-
usePlayerStore.getState().setElements([]);
|
|
326
|
-
updateCompositionStack((prev) => {
|
|
327
|
-
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
|
328
|
-
return [
|
|
329
|
-
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
330
|
-
{ id: activeCompositionPath, label, previewUrl },
|
|
331
|
-
];
|
|
332
|
-
});
|
|
333
|
-
} else if (!activeCompositionPath) {
|
|
334
|
-
usePlayerStore.getState().setElements([]);
|
|
335
|
-
}
|
|
336
|
-
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
337
|
-
|
|
338
254
|
// Resize divider handlers
|
|
339
255
|
const handleDividerPointerDown = useCallback(
|
|
340
256
|
(e: React.PointerEvent) => {
|
|
@@ -377,6 +293,9 @@ export const NLELayout = memo(function NLELayout({
|
|
|
377
293
|
[compositionStack.length],
|
|
378
294
|
);
|
|
379
295
|
|
|
296
|
+
// Suppress TS unused-var warning for masterSeekRef (used inside useCompositionStack)
|
|
297
|
+
void masterSeekRef;
|
|
298
|
+
|
|
380
299
|
return (
|
|
381
300
|
<div
|
|
382
301
|
ref={containerRef}
|
|
@@ -384,7 +303,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
384
303
|
onKeyDown={handleKeyDown}
|
|
385
304
|
tabIndex={-1}
|
|
386
305
|
>
|
|
387
|
-
{/* Preview + player controls
|
|
306
|
+
{/* Preview + player controls */}
|
|
388
307
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
389
308
|
<div className="flex-1 min-h-0 relative">
|
|
390
309
|
<NLEPreview
|
|
@@ -399,7 +318,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
399
318
|
/>
|
|
400
319
|
{previewOverlay}
|
|
401
320
|
</div>
|
|
402
|
-
{/* Player controls always visible, regardless of timeline state */}
|
|
403
321
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
404
322
|
{compositionStack.length > 1 && (
|
|
405
323
|
<CompositionBreadcrumb
|
|
@@ -424,15 +342,13 @@ export const NLELayout = memo(function NLELayout({
|
|
|
424
342
|
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
|
425
343
|
</div>
|
|
426
344
|
|
|
427
|
-
{/* Timeline section
|
|
345
|
+
{/* Timeline section */}
|
|
428
346
|
<div
|
|
429
347
|
className="relative flex flex-col flex-shrink-0"
|
|
430
348
|
style={{ height: timelineH }}
|
|
431
349
|
aria-disabled={timelineDisabled || undefined}
|
|
432
350
|
>
|
|
433
|
-
{/* Timeline tracks */}
|
|
434
351
|
<div
|
|
435
|
-
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
|
436
352
|
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
437
353
|
onDoubleClick={(e) => {
|
|
438
354
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Composition drill-down stack management for NLELayout
|
|
2
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
3
|
+
import { usePlayerStore } from "../../player";
|
|
4
|
+
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
5
|
+
|
|
6
|
+
interface UseCompositionStackOptions {
|
|
7
|
+
projectId: string;
|
|
8
|
+
activeCompositionPath?: string | null;
|
|
9
|
+
onCompositionChange?: (compositionPath: string | null) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseCompositionStackResult {
|
|
13
|
+
compositionStack: CompositionLevel[];
|
|
14
|
+
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
|
|
15
|
+
handleNavigateComposition: (index: number) => void;
|
|
16
|
+
handleDrillDown: (element: { id: string; compositionSrc?: string }) => void;
|
|
17
|
+
masterSeekRef: React.MutableRefObject<number>;
|
|
18
|
+
compIdToSrc: Map<string, string>;
|
|
19
|
+
setCompIdToSrc: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCompositionStack({
|
|
23
|
+
projectId,
|
|
24
|
+
activeCompositionPath,
|
|
25
|
+
onCompositionChange,
|
|
26
|
+
}: UseCompositionStackOptions): UseCompositionStackResult {
|
|
27
|
+
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
|
28
|
+
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
32
|
+
onCompositionChangeRef.current = onCompositionChange;
|
|
33
|
+
|
|
34
|
+
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
35
|
+
setCompositionStack((prev) => {
|
|
36
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
37
|
+
const id = next[next.length - 1]?.id;
|
|
38
|
+
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const masterSeekRef = useRef(0);
|
|
44
|
+
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
45
|
+
|
|
46
|
+
const compIdToSrcRef = useRef(compIdToSrc);
|
|
47
|
+
compIdToSrcRef.current = compIdToSrc;
|
|
48
|
+
|
|
49
|
+
const handleNavigateComposition = useCallback(
|
|
50
|
+
(index: number) => {
|
|
51
|
+
if (index === 0 && masterSeekRef.current > 0) {
|
|
52
|
+
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
53
|
+
}
|
|
54
|
+
usePlayerStore.getState().setElements([]);
|
|
55
|
+
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
56
|
+
},
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
[],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const handleDrillDown = useCallback(
|
|
62
|
+
(element: { id: string; compositionSrc?: string }) => {
|
|
63
|
+
if (!element.compositionSrc) return;
|
|
64
|
+
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
65
|
+
|
|
66
|
+
const compId = element.id;
|
|
67
|
+
let resolvedPath = compIdToSrcRef.current.get(compId);
|
|
68
|
+
|
|
69
|
+
if (!resolvedPath) {
|
|
70
|
+
const src = element.compositionSrc;
|
|
71
|
+
const compMatch = src.match(/compositions\/.*\.html/);
|
|
72
|
+
resolvedPath = compMatch ? compMatch[0] : src;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
usePlayerStore.getState().setElements([]);
|
|
76
|
+
|
|
77
|
+
updateCompositionStack((prev) => {
|
|
78
|
+
const currentId = prev[prev.length - 1].id;
|
|
79
|
+
if (currentId === resolvedPath && prev.length > 1) {
|
|
80
|
+
return prev.slice(0, -1);
|
|
81
|
+
}
|
|
82
|
+
const label =
|
|
83
|
+
resolvedPath
|
|
84
|
+
.split("/")
|
|
85
|
+
.pop()
|
|
86
|
+
?.replace(/\.html$/, "") || resolvedPath;
|
|
87
|
+
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
|
88
|
+
return [...prev, { id: resolvedPath, label, previewUrl }];
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
[projectId],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Navigate to a composition when activeCompositionPath changes.
|
|
96
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (activeCompositionPath === "index.html") {
|
|
99
|
+
usePlayerStore.getState().setElements([]);
|
|
100
|
+
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
|
101
|
+
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
|
|
102
|
+
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
103
|
+
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
|
104
|
+
usePlayerStore.getState().setElements([]);
|
|
105
|
+
updateCompositionStack((prev) => {
|
|
106
|
+
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
|
107
|
+
return [
|
|
108
|
+
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
109
|
+
{ id: activeCompositionPath, label, previewUrl },
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
} else if (!activeCompositionPath) {
|
|
113
|
+
usePlayerStore.getState().setElements([]);
|
|
114
|
+
}
|
|
115
|
+
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
compositionStack,
|
|
119
|
+
updateCompositionStack,
|
|
120
|
+
handleNavigateComposition,
|
|
121
|
+
handleDrillDown,
|
|
122
|
+
masterSeekRef,
|
|
123
|
+
compIdToSrc,
|
|
124
|
+
setCompIdToSrc,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
import type { AppToast } from "../utils/studioHelpers";
|
|
4
|
+
|
|
5
|
+
export function useToast() {
|
|
6
|
+
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
7
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
8
|
+
|
|
9
|
+
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
10
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
11
|
+
setAppToast({ message, tone });
|
|
12
|
+
timerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
useMountEffect(() => () => {
|
|
16
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return { appToast, showToast };
|
|
20
|
+
}
|