@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

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.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,492 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import {
4
+ STUDIO_MANUAL_EDITS_PATH,
5
+ applyStudioManualEditManifest,
6
+ emptyStudioManualEditManifest,
7
+ installStudioManualEditSeekReapply,
8
+ isStudioManualEditManifestPath,
9
+ parseStudioManualEditManifest,
10
+ readStudioFileChangePath,
11
+ serializeStudioManualEditManifest,
12
+ type StudioManualEditManifest,
13
+ } from "../components/editor/manualEdits";
14
+ import {
15
+ STUDIO_MOTION_PATH,
16
+ applyStudioMotionManifest,
17
+ emptyStudioMotionManifest,
18
+ installStudioMotionSeekReapply,
19
+ isStudioMotionManifestPath,
20
+ parseStudioMotionManifest,
21
+ serializeStudioMotionManifest,
22
+ type StudioMotionManifest,
23
+ } from "../components/editor/studioMotion";
24
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
25
+ import type { EditHistoryKind } from "../utils/editHistory";
26
+
27
+ // ── Types ──
28
+
29
+ interface RecordEditInput {
30
+ label: string;
31
+ kind: EditHistoryKind;
32
+ coalesceKey?: string;
33
+ files: Record<string, { before: string; after: string }>;
34
+ }
35
+
36
+ interface UseManifestPersistenceParams {
37
+ projectId: string | null;
38
+ showToast: (message: string, tone?: "error" | "info") => void;
39
+ readOptionalProjectFile: (path: string) => Promise<string>;
40
+ writeProjectFile: (path: string, content: string) => Promise<void>;
41
+ recordEdit: (entry: RecordEditInput) => Promise<void>;
42
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
43
+ activeCompPathRef: React.MutableRefObject<string | null>;
44
+ }
45
+
46
+ // ── Hook ──
47
+
48
+ export function useManifestPersistence({
49
+ projectId,
50
+ showToast,
51
+ readOptionalProjectFile,
52
+ writeProjectFile,
53
+ recordEdit,
54
+ previewIframeRef,
55
+ activeCompPathRef,
56
+ }: UseManifestPersistenceParams) {
57
+ const [, setStudioMotionRevision] = useState(0);
58
+
59
+ const domEditSaveTimestampRef = useRef(0);
60
+ const domTextCommitVersionRef = useRef(0);
61
+ const domEditSaveQueueRef = useRef(Promise.resolve());
62
+ const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
63
+ emptyStudioManualEditManifest(),
64
+ );
65
+ const studioManualEditRevisionRef = useRef(0);
66
+ const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
67
+ const studioMotionRevisionRef = useRef(0);
68
+ const applyStudioManualEditsToPreviewRef = useRef<
69
+ (
70
+ iframe?: HTMLIFrameElement | null,
71
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
72
+ ) => Promise<void>
73
+ >(async () => {});
74
+ const applyStudioMotionToPreviewRef = useRef<
75
+ (
76
+ iframe?: HTMLIFrameElement | null,
77
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
78
+ ) => Promise<void>
79
+ >(async () => {});
80
+ const studioManualEditProjectRef = useRef<string | null>(projectId);
81
+
82
+ // Keep a ref to the latest projectId so async save callbacks always read the
83
+ // current value, even when the callback was captured in a stale closure.
84
+ const projectIdRef = useRef(projectId);
85
+ projectIdRef.current = projectId;
86
+
87
+ // ── Queue / drain helpers ──
88
+
89
+ const queueDomEditSave = useCallback((save: () => Promise<void>) => {
90
+ const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
91
+ domEditSaveQueueRef.current = queuedSave.then(
92
+ () => undefined,
93
+ () => undefined,
94
+ );
95
+ return queuedSave;
96
+ }, []);
97
+
98
+ const waitForPendingDomEditSaves = useCallback(async () => {
99
+ await domEditSaveQueueRef.current.catch(() => undefined);
100
+ }, []);
101
+
102
+ // ── Apply manual edits ──
103
+
104
+ const applyCurrentStudioManualEditsToPreview = useCallback(
105
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
106
+ if (!iframe) return;
107
+ let doc: Document | null = null;
108
+ try {
109
+ doc = iframe.contentDocument;
110
+ } catch {
111
+ return;
112
+ }
113
+ if (!doc) return;
114
+ const previewDoc = doc;
115
+
116
+ const applyManifest = () => {
117
+ applyStudioManualEditManifest(
118
+ previewDoc,
119
+ studioManualEditManifestRef.current,
120
+ activeCompPathRef.current,
121
+ );
122
+ };
123
+ const applyAndInstallSeekHooks = () => {
124
+ applyManifest();
125
+ if (iframe.contentWindow) {
126
+ installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
127
+ }
128
+ };
129
+
130
+ const win = iframe.contentWindow;
131
+ applyAndInstallSeekHooks();
132
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
133
+ win?.setTimeout?.(applyAndInstallSeekHooks, 80);
134
+ win?.setTimeout?.(applyAndInstallSeekHooks, 250);
135
+ win?.setTimeout?.(applyAndInstallSeekHooks, 500);
136
+ win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
137
+ win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
138
+ },
139
+ [activeCompPathRef, previewIframeRef],
140
+ );
141
+
142
+ const applyStudioManualEditsToPreview = useCallback(
143
+ async (
144
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
145
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
146
+ ) => {
147
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
148
+ if (!readFromDiskFirst) {
149
+ applyCurrentStudioManualEditsToPreview(iframe);
150
+ return;
151
+ }
152
+ const readRevision = studioManualEditRevisionRef.current;
153
+ let content: string;
154
+ try {
155
+ content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
156
+ } catch (error) {
157
+ const message =
158
+ error instanceof Error ? error.message : "Failed to read manual edit manifest";
159
+ showToast(message);
160
+ applyCurrentStudioManualEditsToPreview(iframe);
161
+ return;
162
+ }
163
+ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
164
+ studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
165
+ if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
166
+ }
167
+ applyCurrentStudioManualEditsToPreview(iframe);
168
+ },
169
+ [applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
170
+ );
171
+ applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
172
+
173
+ // ── Apply motion ──
174
+
175
+ const applyCurrentStudioMotionToPreview = useCallback(
176
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
177
+ if (!iframe) return;
178
+ let doc: Document | null = null;
179
+ try {
180
+ doc = iframe.contentDocument;
181
+ } catch {
182
+ return;
183
+ }
184
+ if (!doc) return;
185
+ const previewDoc = doc;
186
+
187
+ const applyManifest = () => {
188
+ applyStudioMotionManifest(
189
+ previewDoc,
190
+ studioMotionManifestRef.current,
191
+ activeCompPathRef.current,
192
+ );
193
+ };
194
+ const applyAndInstallSeekHooks = () => {
195
+ applyManifest();
196
+ if (iframe.contentWindow) {
197
+ installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
198
+ }
199
+ };
200
+
201
+ const win = iframe.contentWindow;
202
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
203
+ win?.setTimeout?.(applyAndInstallSeekHooks, 120);
204
+ },
205
+ [activeCompPathRef, previewIframeRef],
206
+ );
207
+
208
+ const applyStudioMotionToPreview = useCallback(
209
+ async (
210
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
211
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
212
+ ) => {
213
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
214
+ if (!readFromDiskFirst) {
215
+ applyCurrentStudioMotionToPreview(iframe);
216
+ return;
217
+ }
218
+ const readRevision = studioMotionRevisionRef.current;
219
+ let content: string;
220
+ try {
221
+ content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
222
+ } catch (error) {
223
+ const message = error instanceof Error ? error.message : "Failed to read motion manifest";
224
+ showToast(message);
225
+ applyCurrentStudioMotionToPreview(iframe);
226
+ return;
227
+ }
228
+ if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
229
+ studioMotionManifestRef.current = parseStudioMotionManifest(content);
230
+ if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
231
+ setStudioMotionRevision((revision) => revision + 1);
232
+ }
233
+ applyCurrentStudioMotionToPreview(iframe);
234
+ },
235
+ [applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
236
+ );
237
+ applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
238
+
239
+ // ── Optimistic commits ──
240
+
241
+ const commitStudioManualEditManifestOptimistically = useCallback(
242
+ (
243
+ updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
244
+ options: { label: string; coalesceKey: string },
245
+ ) => {
246
+ const previousManifest = studioManualEditManifestRef.current;
247
+ const nextManifest = updateManifest(previousManifest);
248
+ const previousContent = serializeStudioManualEditManifest(previousManifest);
249
+ const nextContent = serializeStudioManualEditManifest(nextManifest);
250
+ if (nextContent === previousContent) {
251
+ return;
252
+ }
253
+
254
+ const revision = studioManualEditRevisionRef.current + 1;
255
+ studioManualEditRevisionRef.current = revision;
256
+ studioManualEditManifestRef.current = nextManifest;
257
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
258
+
259
+ const save = async () => {
260
+ const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
261
+ const diskManifest = parseStudioManualEditManifest(originalContent);
262
+ const nextDiskManifest = updateManifest(diskManifest);
263
+ const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
264
+ if (nextDiskContent === originalContent) {
265
+ return;
266
+ }
267
+
268
+ const pid = projectIdRef.current;
269
+ if (!pid) throw new Error("No active project");
270
+ domEditSaveTimestampRef.current = Date.now();
271
+ await saveProjectFilesWithHistory({
272
+ projectId: pid,
273
+ label: options.label,
274
+ kind: "manual",
275
+ coalesceKey: options.coalesceKey,
276
+ files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
277
+ readFile: async () => originalContent,
278
+ writeFile: writeProjectFile,
279
+ recordEdit,
280
+ });
281
+ domEditSaveTimestampRef.current = Date.now();
282
+
283
+ if (studioManualEditRevisionRef.current === revision) {
284
+ studioManualEditManifestRef.current = nextDiskManifest;
285
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
286
+ }
287
+ };
288
+
289
+ void queueDomEditSave(save).catch((error) => {
290
+ if (studioManualEditRevisionRef.current === revision) {
291
+ studioManualEditRevisionRef.current += 1;
292
+ studioManualEditManifestRef.current = previousManifest;
293
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
294
+ }
295
+ const message = error instanceof Error ? error.message : "Failed to save manual edit";
296
+ showToast(message);
297
+ });
298
+ },
299
+ [
300
+ applyCurrentStudioManualEditsToPreview,
301
+ recordEdit,
302
+ queueDomEditSave,
303
+ readOptionalProjectFile,
304
+ showToast,
305
+ writeProjectFile,
306
+ previewIframeRef,
307
+ ],
308
+ );
309
+
310
+ const commitStudioMotionManifestOptimistically = useCallback(
311
+ (
312
+ updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
313
+ options: { label: string; coalesceKey: string },
314
+ ) => {
315
+ const previousManifest = studioMotionManifestRef.current;
316
+ const nextManifest = updateManifest(previousManifest);
317
+ const previousContent = serializeStudioMotionManifest(previousManifest);
318
+ const nextContent = serializeStudioMotionManifest(nextManifest);
319
+ if (nextContent === previousContent) {
320
+ return;
321
+ }
322
+
323
+ const revision = studioMotionRevisionRef.current + 1;
324
+ studioMotionRevisionRef.current = revision;
325
+ studioMotionManifestRef.current = nextManifest;
326
+ setStudioMotionRevision((current) => current + 1);
327
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
328
+
329
+ const save = async () => {
330
+ const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
331
+ const diskManifest = parseStudioMotionManifest(originalContent);
332
+ const nextDiskManifest = updateManifest(diskManifest);
333
+ const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
334
+ if (nextDiskContent === originalContent) {
335
+ return;
336
+ }
337
+
338
+ const pid = projectIdRef.current;
339
+ if (!pid) throw new Error("No active project");
340
+ domEditSaveTimestampRef.current = Date.now();
341
+ await saveProjectFilesWithHistory({
342
+ projectId: pid,
343
+ label: options.label,
344
+ kind: "motion",
345
+ coalesceKey: options.coalesceKey,
346
+ files: { [STUDIO_MOTION_PATH]: nextDiskContent },
347
+ readFile: async () => originalContent,
348
+ writeFile: writeProjectFile,
349
+ recordEdit,
350
+ });
351
+ domEditSaveTimestampRef.current = Date.now();
352
+
353
+ if (studioMotionRevisionRef.current === revision) {
354
+ studioMotionManifestRef.current = nextDiskManifest;
355
+ setStudioMotionRevision((current) => current + 1);
356
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
357
+ }
358
+ };
359
+
360
+ void queueDomEditSave(save).catch((error) => {
361
+ if (studioMotionRevisionRef.current === revision) {
362
+ studioMotionRevisionRef.current += 1;
363
+ studioMotionManifestRef.current = previousManifest;
364
+ setStudioMotionRevision((current) => current + 1);
365
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
366
+ }
367
+ const message = error instanceof Error ? error.message : "Failed to save motion edit";
368
+ showToast(message);
369
+ });
370
+ },
371
+ [
372
+ applyCurrentStudioMotionToPreview,
373
+ recordEdit,
374
+ queueDomEditSave,
375
+ readOptionalProjectFile,
376
+ showToast,
377
+ writeProjectFile,
378
+ previewIframeRef,
379
+ ],
380
+ );
381
+
382
+ // ── Sync preview after undo/redo ──
383
+
384
+ const syncHistoryPreviewAfterApply = useCallback(
385
+ async (paths: string[] | undefined) => {
386
+ const changedPaths = paths ?? [];
387
+ const manualManifestOnly =
388
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
389
+ const motionManifestOnly =
390
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
391
+
392
+ if (manualManifestOnly) {
393
+ await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
394
+ return;
395
+ }
396
+ if (motionManifestOnly) {
397
+ await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
398
+ return;
399
+ }
400
+
401
+ // Reload the iframe in-place rather than recreating the Player component.
402
+ // This preserves the <hyperframes-player> web component and its shader
403
+ // transition cache — only the iframe document reloads, so transitions that
404
+ // weren't touched by the undo/redo don't need to rebuild from scratch.
405
+ const iframe = previewIframeRef.current;
406
+ if (iframe?.contentWindow) {
407
+ try {
408
+ iframe.contentWindow.location.reload();
409
+ return;
410
+ } catch {
411
+ // Cross-origin or detached — fall through to full refresh
412
+ }
413
+ }
414
+ },
415
+ [applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
416
+ );
417
+
418
+ // ── Reset manifests when project changes ──
419
+
420
+ // eslint-disable-next-line no-restricted-syntax
421
+ useEffect(() => {
422
+ const previousProjectId = studioManualEditProjectRef.current;
423
+ studioManualEditProjectRef.current = projectId;
424
+ if (!previousProjectId || previousProjectId === projectId) return;
425
+ studioManualEditManifestRef.current = emptyStudioManualEditManifest();
426
+ studioManualEditRevisionRef.current += 1;
427
+ studioMotionManifestRef.current = emptyStudioMotionManifest();
428
+ studioMotionRevisionRef.current += 1;
429
+ setStudioMotionRevision((revision) => revision + 1);
430
+ }, [projectId]);
431
+
432
+ // ── Listen for external file changes (HMR / SSE) ──
433
+ // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
434
+ // Suppress file-change events that echo back from a recent DOM edit save —
435
+ // those changes are already applied to the iframe DOM and a full reload
436
+ // would flash the preview.
437
+ useMountEffect(() => {
438
+ const handler = (payload?: unknown) => {
439
+ const changedPath = readStudioFileChangePath(payload);
440
+ const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
441
+ if (isStudioManualEditManifestPath(changedPath)) {
442
+ if (!recentDomEditSave) {
443
+ void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
444
+ forceFromDisk: true,
445
+ });
446
+ }
447
+ return;
448
+ }
449
+ if (isStudioMotionManifestPath(changedPath)) {
450
+ if (!recentDomEditSave) {
451
+ void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
452
+ forceFromDisk: true,
453
+ });
454
+ }
455
+ return;
456
+ }
457
+ // Non-manifest file changes are not handled here — the caller is
458
+ // responsible for triggering a preview refresh via onExternalFileChange
459
+ // if needed. This hook only suppresses echoes and handles manifest reloads.
460
+ };
461
+ if (import.meta.hot) {
462
+ import.meta.hot.on("hf:file-change", handler);
463
+ return () => import.meta.hot?.off?.("hf:file-change", handler);
464
+ }
465
+ // SSE fallback for embedded studio server
466
+ const es = new EventSource("/api/events");
467
+ es.addEventListener("file-change", handler);
468
+ return () => es.close();
469
+ });
470
+
471
+ return {
472
+ domEditSaveTimestampRef,
473
+ domTextCommitVersionRef,
474
+ domEditSaveQueueRef,
475
+ studioManualEditManifestRef,
476
+ studioManualEditRevisionRef,
477
+ studioMotionManifestRef,
478
+ studioMotionRevisionRef,
479
+ applyStudioManualEditsToPreviewRef,
480
+ applyStudioMotionToPreviewRef,
481
+ studioManualEditProjectRef,
482
+ queueDomEditSave,
483
+ waitForPendingDomEditSaves,
484
+ applyCurrentStudioManualEditsToPreview,
485
+ applyStudioManualEditsToPreview,
486
+ applyCurrentStudioMotionToPreview,
487
+ applyStudioMotionToPreview,
488
+ commitStudioManualEditManifestOptimistically,
489
+ commitStudioMotionManifestOptimistically,
490
+ syncHistoryPreviewAfterApply,
491
+ };
492
+ }
@@ -0,0 +1,68 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import type { RightPanelTab } from "../utils/studioHelpers";
3
+
4
+ export function usePanelLayout() {
5
+ const [leftWidth, setLeftWidth] = useState(240);
6
+ const [rightWidth, setRightWidth] = useState(400);
7
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
8
+ const [rightCollapsed, setRightCollapsed] = useState(true);
9
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
10
+ const panelDragRef = useRef<{
11
+ side: "left" | "right";
12
+ startX: number;
13
+ startW: number;
14
+ } | null>(null);
15
+
16
+ const toggleLeftSidebar = useCallback(() => {
17
+ setLeftCollapsed((collapsed) => !collapsed);
18
+ }, []);
19
+
20
+ const handlePanelResizeStart = useCallback(
21
+ (side: "left" | "right", e: React.PointerEvent) => {
22
+ e.preventDefault();
23
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
24
+ panelDragRef.current = {
25
+ side,
26
+ startX: e.clientX,
27
+ startW: side === "left" ? leftWidth : rightWidth,
28
+ };
29
+ },
30
+ [leftWidth, rightWidth],
31
+ );
32
+
33
+ const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
34
+ const drag = panelDragRef.current;
35
+ if (!drag) return;
36
+ const delta = e.clientX - drag.startX;
37
+ const maxLeft = Math.floor(window.innerWidth * 0.5);
38
+ const newW = Math.max(
39
+ 160,
40
+ Math.min(
41
+ drag.side === "left" ? maxLeft : 600,
42
+ drag.startW + (drag.side === "left" ? delta : -delta),
43
+ ),
44
+ );
45
+ if (drag.side === "left") setLeftWidth(newW);
46
+ else setRightWidth(newW);
47
+ }, []);
48
+
49
+ const handlePanelResizeEnd = useCallback(() => {
50
+ panelDragRef.current = null;
51
+ }, []);
52
+
53
+ return {
54
+ leftWidth,
55
+ setLeftWidth,
56
+ rightWidth,
57
+ leftCollapsed,
58
+ setLeftCollapsed,
59
+ rightCollapsed,
60
+ setRightCollapsed,
61
+ rightPanelTab,
62
+ setRightPanelTab,
63
+ toggleLeftSidebar,
64
+ handlePanelResizeStart,
65
+ handlePanelResizeMove,
66
+ handlePanelResizeEnd,
67
+ };
68
+ }