@hyperframes/studio 0.6.6 → 0.6.7
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-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
- package/dist/assets/index-Yvtxngdi.js +116 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +54 -31
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioRightPanel.tsx +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +6 -168
- package/src/components/editor/manualEditsDom.ts +361 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +9 -3
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +82 -77
- package/src/hooks/useDomEditSession.ts +4 -16
- package/src/hooks/useFileManager.ts +10 -1
- package/src/hooks/useManifestPersistence.ts +51 -187
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +0 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +15 -1
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +61 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +76 -18
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +12 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.ts +50 -14
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- package/dist/assets/index-DYqqzECY.js +0 -117
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import {
|
|
4
|
-
STUDIO_MANUAL_EDITS_PATH,
|
|
5
|
-
applyStudioManualEditManifest,
|
|
6
|
-
emptyStudioManualEditManifest,
|
|
7
4
|
installStudioManualEditSeekReapply,
|
|
8
|
-
|
|
9
|
-
parseStudioManualEditManifest,
|
|
5
|
+
reapplyPositionEditsAfterSeek,
|
|
10
6
|
readStudioFileChangePath,
|
|
11
|
-
serializeStudioManualEditManifest,
|
|
12
|
-
type StudioManualEditManifest,
|
|
13
7
|
} from "../components/editor/manualEdits";
|
|
14
8
|
import {
|
|
15
9
|
STUDIO_MOTION_PATH,
|
|
@@ -41,6 +35,11 @@ interface UseManifestPersistenceParams {
|
|
|
41
35
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
42
36
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
43
37
|
activeCompPathRef: React.MutableRefObject<string | null>;
|
|
38
|
+
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
|
39
|
+
* Used to suppress SSE echoes so we don't double-reload after our own saves. */
|
|
40
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
41
|
+
/** Called to reload the preview after undo/redo or external file changes. */
|
|
42
|
+
reloadPreview: () => void;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
// ── Hook ──
|
|
@@ -48,21 +47,19 @@ interface UseManifestPersistenceParams {
|
|
|
48
47
|
export function useManifestPersistence({
|
|
49
48
|
projectId,
|
|
50
49
|
showToast,
|
|
51
|
-
readOptionalProjectFile,
|
|
50
|
+
readOptionalProjectFile: _readOptionalProjectFile,
|
|
52
51
|
writeProjectFile,
|
|
53
52
|
recordEdit,
|
|
54
53
|
previewIframeRef,
|
|
55
54
|
activeCompPathRef,
|
|
55
|
+
domEditSaveTimestampRef,
|
|
56
|
+
reloadPreview,
|
|
56
57
|
}: UseManifestPersistenceParams) {
|
|
57
|
-
|
|
58
|
+
void _readOptionalProjectFile;
|
|
58
59
|
|
|
59
|
-
const
|
|
60
|
+
const [, setStudioMotionRevision] = useState(0);
|
|
60
61
|
const domTextCommitVersionRef = useRef(0);
|
|
61
62
|
const domEditSaveQueueRef = useRef(Promise.resolve());
|
|
62
|
-
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
|
63
|
-
emptyStudioManualEditManifest(),
|
|
64
|
-
);
|
|
65
|
-
const studioManualEditRevisionRef = useRef(0);
|
|
66
63
|
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
|
67
64
|
const studioMotionRevisionRef = useRef(0);
|
|
68
65
|
const applyStudioManualEditsToPreviewRef = useRef<
|
|
@@ -77,9 +74,7 @@ export function useManifestPersistence({
|
|
|
77
74
|
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
78
75
|
) => Promise<void>
|
|
79
76
|
>(async () => {});
|
|
80
|
-
const manifestBootstrappedRef = useRef(false);
|
|
81
77
|
const motionBootstrappedRef = useRef(false);
|
|
82
|
-
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
|
83
78
|
|
|
84
79
|
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
85
80
|
// current value, even when the callback was captured in a stale closure.
|
|
@@ -101,7 +96,7 @@ export function useManifestPersistence({
|
|
|
101
96
|
await domEditSaveQueueRef.current.catch(() => undefined);
|
|
102
97
|
}, []);
|
|
103
98
|
|
|
104
|
-
// ── Apply manual edits ──
|
|
99
|
+
// ── Apply manual edits (HTML-baked — just install seek hooks) ──
|
|
105
100
|
|
|
106
101
|
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
107
102
|
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
@@ -113,68 +108,38 @@ export function useManifestPersistence({
|
|
|
113
108
|
return;
|
|
114
109
|
}
|
|
115
110
|
if (!doc) return;
|
|
116
|
-
const previewDoc = doc;
|
|
117
111
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
};
|
|
125
|
-
const applyAndInstallSeekHooks = () => {
|
|
126
|
-
applyManifest();
|
|
127
|
-
if (iframe.contentWindow) {
|
|
128
|
-
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
112
|
+
const reapply = () => {
|
|
113
|
+
let d: Document | null = null;
|
|
114
|
+
try {
|
|
115
|
+
d = iframe.contentDocument;
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
129
118
|
}
|
|
119
|
+
if (d) reapplyPositionEditsAfterSeek(d);
|
|
120
|
+
};
|
|
121
|
+
const install = () => {
|
|
122
|
+
reapply();
|
|
123
|
+
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
|
|
130
124
|
};
|
|
131
125
|
|
|
132
126
|
const win = iframe.contentWindow;
|
|
133
|
-
|
|
134
|
-
win?.requestAnimationFrame?.(
|
|
135
|
-
win?.setTimeout?.(
|
|
136
|
-
win?.setTimeout?.(
|
|
137
|
-
win?.setTimeout?.(
|
|
138
|
-
win?.setTimeout?.(
|
|
139
|
-
win?.setTimeout?.(
|
|
127
|
+
install();
|
|
128
|
+
win?.requestAnimationFrame?.(install);
|
|
129
|
+
win?.setTimeout?.(install, 80);
|
|
130
|
+
win?.setTimeout?.(install, 250);
|
|
131
|
+
win?.setTimeout?.(install, 500);
|
|
132
|
+
win?.setTimeout?.(install, 1000);
|
|
133
|
+
win?.setTimeout?.(install, 2000);
|
|
140
134
|
},
|
|
141
|
-
[
|
|
135
|
+
[previewIframeRef],
|
|
142
136
|
);
|
|
143
137
|
|
|
144
138
|
const applyStudioManualEditsToPreview = useCallback(
|
|
145
|
-
async (
|
|
146
|
-
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
147
|
-
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
148
|
-
) => {
|
|
149
|
-
// Bootstrap from disk on first apply per session; explicit flag avoids
|
|
150
|
-
// re-reading disk after the user deletes all edits (async write race).
|
|
151
|
-
const needsBootstrap = !manifestBootstrappedRef.current;
|
|
152
|
-
if (needsBootstrap) manifestBootstrappedRef.current = true;
|
|
153
|
-
const readFromDiskFirst = Boolean(
|
|
154
|
-
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
|
|
155
|
-
);
|
|
156
|
-
if (!readFromDiskFirst) {
|
|
157
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const readRevision = studioManualEditRevisionRef.current;
|
|
161
|
-
let content: string;
|
|
162
|
-
try {
|
|
163
|
-
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
const message =
|
|
166
|
-
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
167
|
-
showToast(message);
|
|
168
|
-
applyCurrentStudioManualEditsToPreview(iframe);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
172
|
-
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
173
|
-
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
174
|
-
}
|
|
139
|
+
async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
175
140
|
applyCurrentStudioManualEditsToPreview(iframe);
|
|
176
141
|
},
|
|
177
|
-
[applyCurrentStudioManualEditsToPreview, previewIframeRef
|
|
142
|
+
[applyCurrentStudioManualEditsToPreview, previewIframeRef],
|
|
178
143
|
);
|
|
179
144
|
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
180
145
|
|
|
@@ -230,7 +195,7 @@ export function useManifestPersistence({
|
|
|
230
195
|
const readRevision = studioMotionRevisionRef.current;
|
|
231
196
|
let content: string;
|
|
232
197
|
try {
|
|
233
|
-
content = await
|
|
198
|
+
content = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
234
199
|
} catch (error) {
|
|
235
200
|
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
|
236
201
|
showToast(message);
|
|
@@ -244,80 +209,11 @@ export function useManifestPersistence({
|
|
|
244
209
|
}
|
|
245
210
|
applyCurrentStudioMotionToPreview(iframe);
|
|
246
211
|
},
|
|
247
|
-
[applyCurrentStudioMotionToPreview, previewIframeRef,
|
|
212
|
+
[applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast],
|
|
248
213
|
);
|
|
249
214
|
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
|
250
215
|
|
|
251
|
-
// ── Optimistic
|
|
252
|
-
|
|
253
|
-
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
254
|
-
(
|
|
255
|
-
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
256
|
-
options: { label: string; coalesceKey: string },
|
|
257
|
-
) => {
|
|
258
|
-
const previousManifest = studioManualEditManifestRef.current;
|
|
259
|
-
const nextManifest = updateManifest(previousManifest);
|
|
260
|
-
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
261
|
-
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
262
|
-
if (nextContent === previousContent) {
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const revision = studioManualEditRevisionRef.current + 1;
|
|
267
|
-
studioManualEditRevisionRef.current = revision;
|
|
268
|
-
studioManualEditManifestRef.current = nextManifest;
|
|
269
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
270
|
-
|
|
271
|
-
const save = async () => {
|
|
272
|
-
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
273
|
-
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
274
|
-
const nextDiskManifest = updateManifest(diskManifest);
|
|
275
|
-
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
276
|
-
if (nextDiskContent === originalContent) {
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const pid = projectIdRef.current;
|
|
281
|
-
if (!pid) throw new Error("No active project");
|
|
282
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
283
|
-
await saveProjectFilesWithHistory({
|
|
284
|
-
projectId: pid,
|
|
285
|
-
label: options.label,
|
|
286
|
-
kind: "manual",
|
|
287
|
-
coalesceKey: options.coalesceKey,
|
|
288
|
-
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
289
|
-
readFile: async () => originalContent,
|
|
290
|
-
writeFile: writeProjectFile,
|
|
291
|
-
recordEdit,
|
|
292
|
-
});
|
|
293
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
294
|
-
|
|
295
|
-
if (studioManualEditRevisionRef.current === revision) {
|
|
296
|
-
studioManualEditManifestRef.current = nextDiskManifest;
|
|
297
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
void queueDomEditSave(save).catch((error) => {
|
|
302
|
-
if (studioManualEditRevisionRef.current === revision) {
|
|
303
|
-
studioManualEditRevisionRef.current += 1;
|
|
304
|
-
studioManualEditManifestRef.current = previousManifest;
|
|
305
|
-
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
306
|
-
}
|
|
307
|
-
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
308
|
-
showToast(message);
|
|
309
|
-
});
|
|
310
|
-
},
|
|
311
|
-
[
|
|
312
|
-
applyCurrentStudioManualEditsToPreview,
|
|
313
|
-
recordEdit,
|
|
314
|
-
queueDomEditSave,
|
|
315
|
-
readOptionalProjectFile,
|
|
316
|
-
showToast,
|
|
317
|
-
writeProjectFile,
|
|
318
|
-
previewIframeRef,
|
|
319
|
-
],
|
|
320
|
-
);
|
|
216
|
+
// ── Optimistic motion commit ──
|
|
321
217
|
|
|
322
218
|
const commitStudioMotionManifestOptimistically = useCallback(
|
|
323
219
|
(
|
|
@@ -339,7 +235,7 @@ export function useManifestPersistence({
|
|
|
339
235
|
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
340
236
|
|
|
341
237
|
const save = async () => {
|
|
342
|
-
const originalContent = await
|
|
238
|
+
const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
343
239
|
const diskManifest = parseStudioMotionManifest(originalContent);
|
|
344
240
|
const nextDiskManifest = updateManifest(diskManifest);
|
|
345
241
|
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
|
@@ -384,10 +280,11 @@ export function useManifestPersistence({
|
|
|
384
280
|
applyCurrentStudioMotionToPreview,
|
|
385
281
|
recordEdit,
|
|
386
282
|
queueDomEditSave,
|
|
387
|
-
|
|
283
|
+
_readOptionalProjectFile,
|
|
388
284
|
showToast,
|
|
389
285
|
writeProjectFile,
|
|
390
286
|
previewIframeRef,
|
|
287
|
+
domEditSaveTimestampRef,
|
|
391
288
|
],
|
|
392
289
|
);
|
|
393
290
|
|
|
@@ -396,69 +293,40 @@ export function useManifestPersistence({
|
|
|
396
293
|
const syncHistoryPreviewAfterApply = useCallback(
|
|
397
294
|
async (paths: string[] | undefined) => {
|
|
398
295
|
const changedPaths = paths ?? [];
|
|
399
|
-
const manualManifestOnly =
|
|
400
|
-
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
401
296
|
const motionManifestOnly =
|
|
402
297
|
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
|
403
298
|
|
|
404
|
-
if (manualManifestOnly) {
|
|
405
|
-
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
299
|
if (motionManifestOnly) {
|
|
409
300
|
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
410
301
|
return;
|
|
411
302
|
}
|
|
412
303
|
|
|
413
|
-
// Reload
|
|
414
|
-
|
|
415
|
-
// transition cache — only the iframe document reloads, so transitions that
|
|
416
|
-
// weren't touched by the undo/redo don't need to rebuild from scratch.
|
|
417
|
-
const iframe = previewIframeRef.current;
|
|
418
|
-
if (iframe?.contentWindow) {
|
|
419
|
-
try {
|
|
420
|
-
iframe.contentWindow.location.reload();
|
|
421
|
-
return;
|
|
422
|
-
} catch {
|
|
423
|
-
// Cross-origin or detached — fall through to full refresh
|
|
424
|
-
}
|
|
425
|
-
}
|
|
304
|
+
// Reload via refreshKey so NLELayout saves seek position before the iframe reloads.
|
|
305
|
+
reloadPreview();
|
|
426
306
|
},
|
|
427
|
-
[
|
|
307
|
+
[applyStudioMotionToPreview, previewIframeRef, reloadPreview],
|
|
428
308
|
);
|
|
429
309
|
|
|
430
310
|
// ── Reset manifests when project changes ──
|
|
431
311
|
|
|
312
|
+
const projectTrackerRef = useRef<string | null>(projectId);
|
|
313
|
+
|
|
432
314
|
// eslint-disable-next-line no-restricted-syntax
|
|
433
315
|
useEffect(() => {
|
|
434
|
-
const previousProjectId =
|
|
435
|
-
|
|
316
|
+
const previousProjectId = projectTrackerRef.current;
|
|
317
|
+
projectTrackerRef.current = projectId;
|
|
436
318
|
if (!previousProjectId || previousProjectId === projectId) return;
|
|
437
|
-
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
|
438
|
-
studioManualEditRevisionRef.current += 1;
|
|
439
319
|
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
|
440
320
|
studioMotionRevisionRef.current += 1;
|
|
441
321
|
setStudioMotionRevision((revision) => revision + 1);
|
|
442
|
-
|
|
322
|
+
motionBootstrappedRef.current = false;
|
|
443
323
|
}, [projectId]);
|
|
444
324
|
|
|
445
325
|
// ── Listen for external file changes (HMR / SSE) ──
|
|
446
|
-
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
447
|
-
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
448
|
-
// those changes are already applied to the iframe DOM and a full reload
|
|
449
|
-
// would flash the preview.
|
|
450
326
|
useMountEffect(() => {
|
|
451
327
|
const handler = (payload?: unknown) => {
|
|
452
328
|
const changedPath = readStudioFileChangePath(payload);
|
|
453
329
|
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
|
454
|
-
if (isStudioManualEditManifestPath(changedPath)) {
|
|
455
|
-
if (!recentDomEditSave) {
|
|
456
|
-
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
|
457
|
-
forceFromDisk: true,
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
330
|
if (isStudioMotionManifestPath(changedPath)) {
|
|
463
331
|
if (!recentDomEditSave) {
|
|
464
332
|
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
|
@@ -467,9 +335,10 @@ export function useManifestPersistence({
|
|
|
467
335
|
}
|
|
468
336
|
return;
|
|
469
337
|
}
|
|
470
|
-
// Non-
|
|
471
|
-
|
|
472
|
-
|
|
338
|
+
// Non-motion external file change — reload unless it's an echo of our own save.
|
|
339
|
+
if (!recentDomEditSave) {
|
|
340
|
+
reloadPreview();
|
|
341
|
+
}
|
|
473
342
|
};
|
|
474
343
|
if (import.meta.hot) {
|
|
475
344
|
import.meta.hot.on("hf:file-change", handler);
|
|
@@ -482,23 +351,18 @@ export function useManifestPersistence({
|
|
|
482
351
|
});
|
|
483
352
|
|
|
484
353
|
return {
|
|
485
|
-
domEditSaveTimestampRef,
|
|
486
354
|
domTextCommitVersionRef,
|
|
487
355
|
domEditSaveQueueRef,
|
|
488
|
-
studioManualEditManifestRef,
|
|
489
|
-
studioManualEditRevisionRef,
|
|
490
356
|
studioMotionManifestRef,
|
|
491
357
|
studioMotionRevisionRef,
|
|
492
358
|
applyStudioManualEditsToPreviewRef,
|
|
493
359
|
applyStudioMotionToPreviewRef,
|
|
494
|
-
studioManualEditProjectRef,
|
|
495
360
|
queueDomEditSave,
|
|
496
361
|
waitForPendingDomEditSaves,
|
|
497
362
|
applyCurrentStudioManualEditsToPreview,
|
|
498
363
|
applyStudioManualEditsToPreview,
|
|
499
364
|
applyCurrentStudioMotionToPreview,
|
|
500
365
|
applyStudioMotionToPreview,
|
|
501
|
-
commitStudioManualEditManifestOptimistically,
|
|
502
366
|
commitStudioMotionManifestOptimistically,
|
|
503
367
|
syncHistoryPreviewAfterApply,
|
|
504
368
|
};
|
|
@@ -2,14 +2,21 @@ import { useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
3
3
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export interface InitialPanelLayoutState {
|
|
6
|
+
rightCollapsed?: boolean | null;
|
|
7
|
+
rightPanelTab?: RightPanelTab | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
6
11
|
const [leftWidth, setLeftWidth] = useState(240);
|
|
7
12
|
const [rightWidth, setRightWidth] = useState(400);
|
|
8
13
|
const [leftCollapsed, setLeftCollapsed] = useState(
|
|
9
14
|
() => readStudioUiPreferences().leftCollapsed ?? false,
|
|
10
15
|
);
|
|
11
|
-
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
12
|
-
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
16
|
+
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true);
|
|
17
|
+
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
18
|
+
initialState?.rightPanelTab ?? "renders",
|
|
19
|
+
);
|
|
13
20
|
const panelDragRef = useRef<{
|
|
14
21
|
side: "left" | "right";
|
|
15
22
|
startX: number;
|
|
@@ -18,7 +18,6 @@ export interface UsePreviewInteractionParams {
|
|
|
18
18
|
captionEditMode: boolean;
|
|
19
19
|
compositionLoading: boolean;
|
|
20
20
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
21
|
-
activeCompPath: string | null;
|
|
22
21
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
23
22
|
|
|
24
23
|
// From useDomSelection
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player";
|
|
3
|
+
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
|
|
5
|
+
import {
|
|
6
|
+
buildStudioHash,
|
|
7
|
+
type StudioUrlSelectionState,
|
|
8
|
+
type StudioUrlState,
|
|
9
|
+
} from "../utils/studioUrlState";
|
|
10
|
+
|
|
11
|
+
interface UseStudioUrlStateParams {
|
|
12
|
+
projectId: string | null;
|
|
13
|
+
activeCompPath: string | null;
|
|
14
|
+
currentTime: number;
|
|
15
|
+
duration: number;
|
|
16
|
+
isPlaying: boolean;
|
|
17
|
+
compositionLoading: boolean;
|
|
18
|
+
refreshKey: number;
|
|
19
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
20
|
+
rightPanelTab: RightPanelTab;
|
|
21
|
+
rightCollapsed: boolean;
|
|
22
|
+
timelineVisible: boolean;
|
|
23
|
+
activeCompPathHydrated: boolean;
|
|
24
|
+
domEditSelection: DomEditSelection | null;
|
|
25
|
+
buildDomSelectionFromTarget: (
|
|
26
|
+
target: HTMLElement,
|
|
27
|
+
options?: { preferClipAncestor?: boolean },
|
|
28
|
+
) => DomEditSelection | null;
|
|
29
|
+
applyDomSelection: (
|
|
30
|
+
selection: DomEditSelection | null,
|
|
31
|
+
options?: {
|
|
32
|
+
revealPanel?: boolean;
|
|
33
|
+
additive?: boolean;
|
|
34
|
+
preserveGroup?: boolean;
|
|
35
|
+
},
|
|
36
|
+
) => void;
|
|
37
|
+
initialState: StudioUrlState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSelectionState | null {
|
|
41
|
+
if (!selection) return null;
|
|
42
|
+
if (!selection.id && !selection.selector) return null;
|
|
43
|
+
return {
|
|
44
|
+
sourceFile: selection.sourceFile || undefined,
|
|
45
|
+
id: selection.id || undefined,
|
|
46
|
+
selector: selection.selector || undefined,
|
|
47
|
+
selectorIndex: selection.selectorIndex ?? undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function replaceHash(nextHash: string) {
|
|
52
|
+
if (typeof window === "undefined") return;
|
|
53
|
+
if (window.location.hash === nextHash) return;
|
|
54
|
+
window.history.replaceState(null, "", nextHash);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useStudioUrlState({
|
|
58
|
+
projectId,
|
|
59
|
+
activeCompPath,
|
|
60
|
+
currentTime,
|
|
61
|
+
duration,
|
|
62
|
+
isPlaying,
|
|
63
|
+
compositionLoading,
|
|
64
|
+
refreshKey,
|
|
65
|
+
previewIframeRef,
|
|
66
|
+
rightPanelTab,
|
|
67
|
+
rightCollapsed,
|
|
68
|
+
timelineVisible,
|
|
69
|
+
activeCompPathHydrated,
|
|
70
|
+
domEditSelection,
|
|
71
|
+
buildDomSelectionFromTarget,
|
|
72
|
+
applyDomSelection,
|
|
73
|
+
initialState,
|
|
74
|
+
}: UseStudioUrlStateParams) {
|
|
75
|
+
const hydratedSeekRef = useRef(initialState.currentTime == null);
|
|
76
|
+
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
|
|
77
|
+
const hydratedSelectionRef = useRef(initialState.selection == null);
|
|
78
|
+
const pendingSelectionRef = useRef(initialState.selection);
|
|
79
|
+
const stableTimeRef = useRef<number | null>(initialState.currentTime);
|
|
80
|
+
|
|
81
|
+
const buildUrlState = useCallback(
|
|
82
|
+
(): StudioUrlState => ({
|
|
83
|
+
activeCompPath,
|
|
84
|
+
currentTime: stableTimeRef.current,
|
|
85
|
+
rightPanelTab,
|
|
86
|
+
rightCollapsed,
|
|
87
|
+
timelineVisible,
|
|
88
|
+
selection: hydratedSelectionRef.current
|
|
89
|
+
? toPersistedSelection(domEditSelection)
|
|
90
|
+
: pendingSelectionRef.current,
|
|
91
|
+
}),
|
|
92
|
+
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!projectId || hydratedSeekRef.current || compositionLoading) return;
|
|
97
|
+
const nextTime =
|
|
98
|
+
duration > 0
|
|
99
|
+
? clampNumber(initialState.currentTime ?? 0, 0, duration)
|
|
100
|
+
: Math.max(0, initialState.currentTime ?? 0);
|
|
101
|
+
usePlayerStore.getState().requestSeek(nextTime);
|
|
102
|
+
stableTimeRef.current = nextTime;
|
|
103
|
+
hydratedSeekRef.current = true;
|
|
104
|
+
}, [projectId, compositionLoading, duration, initialState.currentTime]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
|
|
108
|
+
if (!hydratedSeekRef.current) return;
|
|
109
|
+
const targetTime = initialState.currentTime;
|
|
110
|
+
if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return;
|
|
111
|
+
|
|
112
|
+
const pendingSelection = pendingSelectionRef.current;
|
|
113
|
+
if (!pendingSelection) {
|
|
114
|
+
hydratedSelectionRef.current = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let doc: Document | null = null;
|
|
119
|
+
try {
|
|
120
|
+
doc = previewIframeRef.current?.contentDocument ?? null;
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!doc) return;
|
|
125
|
+
|
|
126
|
+
const element = findElementForSelection(
|
|
127
|
+
doc,
|
|
128
|
+
{
|
|
129
|
+
sourceFile: pendingSelection.sourceFile ?? "",
|
|
130
|
+
id: pendingSelection.id,
|
|
131
|
+
selector: pendingSelection.selector,
|
|
132
|
+
selectorIndex: pendingSelection.selectorIndex,
|
|
133
|
+
},
|
|
134
|
+
activeCompPath,
|
|
135
|
+
);
|
|
136
|
+
if (!element) {
|
|
137
|
+
applyDomSelection(null, { revealPanel: false });
|
|
138
|
+
hydratedSelectionRef.current = true;
|
|
139
|
+
pendingSelectionRef.current = null;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
|
|
144
|
+
applyDomSelection(selection, { revealPanel: false });
|
|
145
|
+
hydratedSelectionRef.current = true;
|
|
146
|
+
pendingSelectionRef.current = null;
|
|
147
|
+
}, [
|
|
148
|
+
activeCompPath,
|
|
149
|
+
applyDomSelection,
|
|
150
|
+
buildDomSelectionFromTarget,
|
|
151
|
+
compositionLoading,
|
|
152
|
+
currentTime,
|
|
153
|
+
initialState.currentTime,
|
|
154
|
+
previewIframeRef,
|
|
155
|
+
projectId,
|
|
156
|
+
refreshKey,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (hydratedInitialTimeRef.current) return;
|
|
161
|
+
const targetTime = stableTimeRef.current;
|
|
162
|
+
if (targetTime == null) {
|
|
163
|
+
hydratedInitialTimeRef.current = true;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (Math.abs(currentTime - targetTime) > 0.05) return;
|
|
167
|
+
hydratedInitialTimeRef.current = true;
|
|
168
|
+
}, [currentTime]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!activeCompPathHydrated) return;
|
|
172
|
+
if (!hydratedSeekRef.current) return;
|
|
173
|
+
if (!hydratedInitialTimeRef.current) return;
|
|
174
|
+
if (!projectId || isPlaying) return;
|
|
175
|
+
const handle = window.setTimeout(() => {
|
|
176
|
+
stableTimeRef.current = clampNumber(currentTime, 0, Math.max(0, duration));
|
|
177
|
+
replaceHash(buildStudioHash(projectId, buildUrlState()));
|
|
178
|
+
}, 200);
|
|
179
|
+
|
|
180
|
+
return () => window.clearTimeout(handle);
|
|
181
|
+
}, [activeCompPathHydrated, buildUrlState, currentTime, duration, isPlaying, projectId]);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!activeCompPathHydrated) return;
|
|
185
|
+
if (!projectId) return;
|
|
186
|
+
replaceHash(buildStudioHash(projectId, buildUrlState()));
|
|
187
|
+
}, [activeCompPathHydrated, buildUrlState, projectId]);
|
|
188
|
+
}
|
|
@@ -163,7 +163,21 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
163
163
|
player.style.width = "100%";
|
|
164
164
|
player.style.height = "100%";
|
|
165
165
|
player.style.display = "block";
|
|
166
|
+
player.style.background = "transparent";
|
|
166
167
|
container.appendChild(player);
|
|
168
|
+
|
|
169
|
+
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
170
|
+
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
171
|
+
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
172
|
+
// against the gray pasteboard, consistent with professional editors.
|
|
173
|
+
if (player.shadowRoot) {
|
|
174
|
+
const pasteboardStyle = document.createElement("style");
|
|
175
|
+
pasteboardStyle.textContent =
|
|
176
|
+
".hfp-container{overflow:visible}" +
|
|
177
|
+
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
178
|
+
player.shadowRoot.appendChild(pasteboardStyle);
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
enableInteractiveIframe(player);
|
|
168
182
|
|
|
169
183
|
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
@@ -309,7 +323,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
309
323
|
|
|
310
324
|
return (
|
|
311
325
|
<div
|
|
312
|
-
className="relative w-full h-full max-w-full max-h-full overflow-hidden
|
|
326
|
+
className="relative w-full h-full max-w-full max-h-full overflow-hidden flex items-center justify-center"
|
|
313
327
|
style={style}
|
|
314
328
|
>
|
|
315
329
|
<div ref={containerRef} className="w-full h-full" />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { resolveSeekPercent } from "./PlayerControls";
|
|
3
|
+
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
|
3
4
|
|
|
4
5
|
describe("resolveSeekPercent", () => {
|
|
5
6
|
it("returns 0 when the track width is invalid", () => {
|
|
@@ -18,3 +19,19 @@ describe("resolveSeekPercent", () => {
|
|
|
18
19
|
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
|
|
19
20
|
});
|
|
20
21
|
});
|
|
22
|
+
|
|
23
|
+
describe("shouldMutePreviewAudio", () => {
|
|
24
|
+
it("mutes when the user toggled audio off", () => {
|
|
25
|
+
expect(shouldMutePreviewAudio(true, 1)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("auto-mutes above 1x playback", () => {
|
|
29
|
+
expect(shouldMutePreviewAudio(false, 1.5)).toBe(true);
|
|
30
|
+
expect(shouldMutePreviewAudio(false, 2)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps audio on at 1x or slower when the user has not muted it", () => {
|
|
34
|
+
expect(shouldMutePreviewAudio(false, 1)).toBe(false);
|
|
35
|
+
expect(shouldMutePreviewAudio(false, 0.5)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|