@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
package/src/App.tsx CHANGED
@@ -1,3891 +1,360 @@
1
- import {
2
- useState,
3
- useCallback,
4
- useRef,
5
- useEffect,
6
- useMemo,
7
- type CSSProperties,
8
- type MouseEvent,
9
- type ReactNode,
10
- } from "react";
1
+ import { useState, useCallback, useRef, useMemo } from "react";
11
2
  import { useMountEffect } from "./hooks/useMountEffect";
12
- import { NLELayout } from "./components/nle/NLELayout";
13
- import { SourceEditor } from "./components/editor/SourceEditor";
14
- import { LeftSidebar } from "./components/sidebar/LeftSidebar";
15
- import { RenderQueue } from "./components/renders/RenderQueue";
3
+ import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
16
4
  import { useRenderQueue } from "./components/renders/useRenderQueue";
17
- import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
18
- import { AudioWaveform } from "./player/components/AudioWaveform";
19
- import type { TimelineElement } from "./player";
20
- import { LintModal } from "./components/LintModal";
21
- import type { LintFinding } from "./components/LintModal";
22
- import { MediaPreview } from "./components/MediaPreview";
23
- import { RotateCcw, RotateCw } from "./icons/SystemIcons";
24
- import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
25
- import {
26
- buildTimelineAssetId,
27
- buildTimelineAssetInsertHtml,
28
- buildTimelineFileDropPlacements,
29
- getTimelineAssetKind,
30
- insertTimelineAssetIntoSource,
31
- resolveTimelineAssetInitialGeometry,
32
- resolveTimelineAssetSrc,
33
- type TimelineAssetKind,
34
- } from "./utils/timelineAssetDrop";
35
- import { CaptionOverlay } from "./captions/components/CaptionOverlay";
36
- import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
37
- import { CaptionTimeline } from "./captions/components/CaptionTimeline";
38
- import { useCaptionStore } from "./captions/store";
39
- import { useCaptionSync } from "./captions/hooks/useCaptionSync";
40
- import { parseCaptionComposition } from "./captions/parser";
41
- import { copyTextToClipboard } from "./utils/clipboard";
42
- import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
43
- import {
44
- applyPatchByTarget,
45
- readAttributeByTarget,
46
- readTagSnippetByTarget,
47
- type PatchOperation,
48
- } from "./utils/sourcePatcher";
49
- import {
50
- buildTrackZIndexMap,
51
- formatTimelineAttributeNumber,
52
- } from "./player/components/timelineEditing";
53
- import {
54
- getNextTimelineZoomPercent,
55
- getTimelineZoomPercent,
56
- } from "./player/components/timelineZoom";
57
- import {
58
- getTimelineToggleTitle,
59
- shouldHandleTimelineToggleHotkey,
60
- } from "./utils/timelineDiscovery";
61
- import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
62
- import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
63
- import { Camera } from "./icons/SystemIcons";
64
- import { PropertyPanel } from "./components/editor/PropertyPanel";
65
- import { MotionPanel } from "./components/editor/MotionPanel";
66
- import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
67
- import {
68
- fontFamilyFromAssetPath,
69
- importedFontFaceCss,
70
- type ImportedFontAsset,
71
- } from "./components/editor/fontAssets";
72
- import {
73
- DomEditOverlay,
74
- type DomEditGroupPathOffsetCommit,
75
- } from "./components/editor/DomEditOverlay";
76
- import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
77
- import {
78
- STUDIO_INSPECTOR_PANELS_ENABLED,
79
- STUDIO_MANUAL_EDITING_DISABLED_TITLE,
80
- STUDIO_MOTION_PANEL_ENABLED,
81
- STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
82
- STUDIO_PREVIEW_SELECTION_ENABLED,
83
- STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
84
- } from "./components/editor/manualEditingAvailability";
85
- import {
86
- buildDomEditStylePatchOperation,
87
- buildDomEditTextPatchOperation,
88
- buildElementAgentPrompt,
89
- collectDomEditLayerItems,
90
- countDomEditChildLayers,
91
- findElementForSelection,
92
- findElementForTimelineElement,
93
- getDomEditLayerKey,
94
- getDomEditTargetKey,
95
- isLargeRasterDomEditSelection,
96
- isTextEditableSelection,
97
- resolveVisualDomEditSelectionTarget,
98
- serializeDomEditTextFields,
99
- resolveDomEditSelection,
100
- type DomEditViewport,
101
- type DomEditLayerItem,
102
- type DomEditTextField,
103
- type DomEditSelection,
104
- buildDefaultDomEditTextField,
105
- } from "./components/editor/domEditing";
106
- import {
107
- STUDIO_MANUAL_EDITS_PATH,
108
- applyStudioManualEditManifest,
109
- emptyStudioManualEditManifest,
110
- installStudioManualEditSeekReapply,
111
- isStudioManualEditManifestPath,
112
- parseStudioManualEditManifest,
113
- readStudioFileChangePath,
114
- removeStudioManualEditsForSelection,
115
- serializeStudioManualEditManifest,
116
- type StudioManualEditManifest,
117
- upsertStudioBoxSizeEdit,
118
- upsertStudioPathOffsetEdit,
119
- upsertStudioRotationEdit,
120
- } from "./components/editor/manualEdits";
121
- import {
122
- STUDIO_MOTION_PATH,
123
- applyStudioMotionManifest,
124
- emptyStudioMotionManifest,
125
- getStudioMotionForSelection,
126
- installStudioMotionSeekReapply,
127
- isStudioMotionManifestPath,
128
- parseStudioMotionManifest,
129
- removeStudioMotionForSelection,
130
- serializeStudioMotionManifest,
131
- type StudioGsapMotion,
132
- type StudioMotionManifest,
133
- upsertStudioGsapMotion,
134
- } from "./components/editor/studioMotion";
135
- import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
136
- import {
137
- canInspectTimelineElement,
138
- getTimelineElementKey,
139
- getTimelineLayerVisibilityInPreview,
140
- isTimelineElementActiveAtTime,
141
- isTimelineLayerVisibleInPreview,
142
- shouldShowTimelineInspectorBounds,
143
- } from "./utils/timelineInspector";
144
-
145
- interface EditingFile {
146
- path: string;
147
- content: string | null;
148
- }
149
-
150
- interface AppToast {
151
- message: string;
152
- tone: "error" | "info";
153
- }
154
-
155
- function getTimelineElementLabel(element: TimelineElement): string {
156
- return element.label || element.id || element.tag;
157
- }
158
-
159
- type RightPanelTab = "design" | "motion" | "renders";
160
-
161
- const GENERIC_FONT_FAMILIES = new Set([
162
- "inherit",
163
- "initial",
164
- "revert",
165
- "revert-layer",
166
- "serif",
167
- "sans-serif",
168
- "monospace",
169
- "cursive",
170
- "fantasy",
171
- "system-ui",
172
- "ui-sans-serif",
173
- "ui-serif",
174
- "ui-monospace",
175
- "ui-rounded",
176
- "emoji",
177
- "math",
178
- "fangsong",
179
- ]);
180
-
181
- function primaryFontFamilyFromCss(value: string): string {
182
- const first = value.split(",")[0] ?? "";
183
- return first.trim().replace(/^["']|["']$/g, "");
184
- }
185
-
186
- function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
187
- const family = primaryFontFamilyFromCss(fontFamilyValue);
188
- if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
189
-
190
- const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
191
- if (doc.getElementById(id)) return;
192
-
193
- const link = doc.createElement("link");
194
- link.id = id;
195
- link.rel = "stylesheet";
196
- link.href = googleFontStylesheetUrl(family);
197
- doc.head.appendChild(link);
198
- }
199
-
200
- function primaryFontFamilyValue(value: string): string {
201
- return (
202
- value
203
- .split(",")[0]
204
- ?.trim()
205
- .replace(/^["']|["']$/g, "")
206
- .trim() ?? ""
207
- );
208
- }
209
-
210
- function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
211
- const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
212
- if (doc.getElementById(id)) return;
213
- const style = doc.createElement("style");
214
- style.id = id;
215
- style.textContent = importedFontFaceCss(asset);
216
- doc.head.appendChild(style);
217
- }
218
-
219
- function normalizeProjectAssetPath(value: string): string {
220
- const trimmed = value.trim();
221
- const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
222
- return decodeURIComponent(maybeUrl)
223
- .replace(/\\/g, "/")
224
- .replace(/^\.?\//, "");
225
- }
226
-
227
- function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
228
- const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
229
- const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
230
-
231
- fromParts.pop();
232
-
233
- while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
234
- fromParts.shift();
235
- targetParts.shift();
236
- }
237
-
238
- return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
239
- }
240
-
241
- function isAbsoluteFilePath(value: string): boolean {
242
- return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
243
- }
244
-
245
- function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
246
- const trimmedSource = sourceFile.trim();
247
- if (!trimmedSource) return undefined;
248
-
249
- const normalizedSource = trimmedSource.replace(/\\/g, "/");
250
- if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
251
-
252
- const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
253
- if (!normalizedRoot) return undefined;
254
-
255
- return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
256
- }
257
-
258
- function ensureImportedFontFace(
259
- html: string,
260
- asset: ImportedFontAsset,
261
- sourceFile: string,
262
- ): string {
263
- const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
264
- if (html.includes(css)) return html;
265
-
266
- const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
267
- const styleMatch = styleRe.exec(html);
268
- if (styleMatch) {
269
- const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
270
- return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
271
- }
272
-
273
- const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
274
- if (/<\/head>/i.test(html)) {
275
- return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
276
- }
277
- return `${styleTag}\n${html}`;
278
- }
279
- function normalizeDomEditStyleValue(property: string, value: string): string {
280
- const trimmed = value.trim();
281
- if (!trimmed) return trimmed;
282
-
283
- if (
284
- ["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
285
- /^-?\d+(\.\d+)?$/.test(trimmed)
286
- ) {
287
- return `${trimmed}px`;
288
- }
289
-
290
- return trimmed;
291
- }
292
-
293
- function isImageBackgroundValue(value: string): boolean {
294
- return /^url\(/i.test(value.trim());
295
- }
296
-
297
- function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
298
- if (!target || typeof target !== "object") return null;
299
- const maybeNode = target as {
300
- nodeType?: number;
301
- parentElement?: Element | null;
302
- };
303
- if (maybeNode.nodeType === 1) return target as HTMLElement;
304
- if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
305
- return maybeNode.parentElement as HTMLElement;
306
- }
307
- return null;
308
- }
309
-
310
- function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
311
- const el = getEventTargetElement(target);
312
- if (!el) return false;
313
- return Boolean(
314
- el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
315
- );
316
- }
317
-
318
- function getHistoryShortcutLabel(action: "undo" | "redo"): string {
319
- const isMac =
320
- typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
321
- const modifier = isMac ? "Cmd" : "Ctrl";
322
- return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
323
- }
324
-
325
- function findMatchingTimelineElementId(
326
- selection: Pick<
327
- DomEditSelection,
328
- "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
329
- >,
330
- elements: TimelineElement[],
331
- ): string | null {
332
- const selectionSourceFile = selection.sourceFile || "index.html";
333
- for (const element of elements) {
334
- const elementSourceFile = element.sourceFile || "index.html";
335
- if (
336
- selection.id &&
337
- element.domId === selection.id &&
338
- elementSourceFile === selectionSourceFile
339
- ) {
340
- return element.key ?? element.id;
341
- }
342
- if (
343
- selection.isCompositionHost &&
344
- selection.compositionSrc &&
345
- element.compositionSrc === selection.compositionSrc
346
- ) {
347
- return element.key ?? element.id;
348
- }
349
- if (
350
- selection.selector &&
351
- element.selector === selection.selector &&
352
- (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
353
- (element.sourceFile ?? "index.html") === selection.sourceFile
354
- ) {
355
- return element.key ?? element.id;
356
- }
357
- }
358
-
359
- return null;
360
- }
361
-
362
- function isManualGeometryStyleProperty(property: string): boolean {
363
- return property === "left" || property === "top" || property === "width" || property === "height";
364
- }
365
-
366
- interface PreviewLocalPointer {
367
- x: number;
368
- y: number;
369
- viewport: DomEditViewport;
370
- }
371
-
372
- interface AgentModalAnchorPoint {
373
- x: number;
374
- y: number;
375
- }
376
-
377
- function resolvePreviewLocalPointer(
378
- iframe: HTMLIFrameElement,
379
- doc: Document,
380
- win: Window,
381
- clientX: number,
382
- clientY: number,
383
- ): PreviewLocalPointer | null {
384
- const iframeRect = iframe.getBoundingClientRect();
385
- const root =
386
- doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
387
- const rootRect = root?.getBoundingClientRect();
388
- const rootWidth = rootRect?.width || win.innerWidth;
389
- const rootHeight = rootRect?.height || win.innerHeight;
390
- if (!rootWidth || !rootHeight) return null;
391
-
392
- const scaleX = iframeRect.width / rootWidth;
393
- const scaleY = iframeRect.height / rootHeight;
394
- return {
395
- x: (clientX - iframeRect.left) / scaleX,
396
- y: (clientY - iframeRect.top) / scaleY,
397
- viewport: { width: rootWidth, height: rootHeight },
398
- };
399
- }
400
-
401
- function getPreviewLocalPointer(
402
- iframe: HTMLIFrameElement,
403
- clientX: number,
404
- clientY: number,
405
- ): PreviewLocalPointer | null {
406
- let doc: Document | null = null;
407
- let win: Window | null = null;
408
- try {
409
- doc = iframe.contentDocument;
410
- win = iframe.contentWindow;
411
- } catch {
412
- return null;
413
- }
414
- if (!doc || !win) return null;
415
-
416
- return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
417
- }
418
-
419
- function getPreviewTargetFromPointer(
420
- iframe: HTMLIFrameElement,
421
- clientX: number,
422
- clientY: number,
423
- activeCompositionPath: string | null,
424
- ): HTMLElement | null {
425
- let doc: Document | null = null;
426
- let win: Window | null = null;
427
- try {
428
- doc = iframe.contentDocument;
429
- win = iframe.contentWindow;
430
- } catch {
431
- return null;
432
- }
433
- if (!doc || !win) return null;
434
-
435
- const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
436
- if (!localPointer) return null;
437
-
438
- if (typeof doc.elementsFromPoint === "function") {
439
- const visualTarget = resolveVisualDomEditSelectionTarget(
440
- doc.elementsFromPoint(localPointer.x, localPointer.y),
441
- {
442
- activeCompositionPath,
443
- },
444
- );
445
- if (visualTarget) return visualTarget;
446
- }
447
-
448
- return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
449
- }
450
-
451
- function buildRasterClickSelectionContext(
452
- selection: DomEditSelection,
453
- localPointer: PreviewLocalPointer,
454
- ): string {
455
- return [
456
- "The user clicked a large raster/background element in the Studio preview.",
457
- `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
458
- localPointer.viewport.width,
459
- )}x${Math.round(localPointer.viewport.height)} composition.`,
460
- `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
461
- "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
462
- "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
463
- ].join("\n");
464
- }
465
-
466
- function domEditSelectionsTargetSame(
467
- a: DomEditSelection | null,
468
- b: DomEditSelection | null,
469
- ): boolean {
470
- if (a === b) return true;
471
- if (!a || !b) return false;
472
- return getDomEditTargetKey(a) === getDomEditTargetKey(b);
473
- }
474
-
475
- function domEditSelectionInGroup(
476
- group: DomEditSelection[],
477
- selection: DomEditSelection | null,
478
- ): boolean {
479
- if (!selection) return false;
480
- return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
481
- }
482
-
483
- function toggleDomEditGroupSelection(
484
- group: DomEditSelection[],
485
- selection: DomEditSelection,
486
- ): DomEditSelection[] {
487
- if (domEditSelectionInGroup(group, selection)) {
488
- return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
489
- }
490
- return [...group, selection];
491
- }
492
-
493
- function replaceDomEditGroupSelection(
494
- group: DomEditSelection[],
495
- selection: DomEditSelection,
496
- ): DomEditSelection[] {
497
- let replaced = false;
498
- const nextGroup = group.map((entry) => {
499
- if (!domEditSelectionsTargetSame(entry, selection)) return entry;
500
- replaced = true;
501
- return selection;
502
- });
503
- return replaced ? nextGroup : [...group, selection];
504
- }
505
-
506
- function seedDomEditGroupWithSelection(
507
- group: DomEditSelection[],
508
- selection: DomEditSelection | null,
509
- ): DomEditSelection[] {
510
- if (!selection || domEditSelectionInGroup(group, selection)) return group;
511
- return [selection, ...group];
512
- }
513
-
514
- function objectLike(value: unknown): object | null {
515
- return value && (typeof value === "object" || typeof value === "function") ? value : null;
516
- }
517
-
518
- function callPlaybackMethod(target: object | null, key: string): void {
519
- const method = target ? Reflect.get(target, key) : null;
520
- if (typeof method !== "function") return;
521
- try {
522
- method.call(target);
523
- } catch {
524
- // Best-effort playback freeze; drag should still work if playback control is unavailable.
525
- }
526
- }
527
-
528
- function readPlaybackTime(target: object | null, key: string): number | null {
529
- const method = target ? Reflect.get(target, key) : null;
530
- if (typeof method !== "function") return null;
531
- try {
532
- const value = method.call(target);
533
- return typeof value === "number" && Number.isFinite(value) ? value : null;
534
- } catch {
535
- return null;
536
- }
537
- }
538
-
539
- interface PreviewPlayerCompat {
540
- getTime: () => number;
541
- renderSeek: (timeSeconds: number) => void;
542
- }
543
-
544
- function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
545
- const player = objectLike(win ? Reflect.get(win, "__player") : null);
546
- if (!player) return null;
547
- const getTime = Reflect.get(player, "getTime");
548
- const renderSeek = Reflect.get(player, "renderSeek");
549
- if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
550
- return {
551
- getTime: () => {
552
- const value = getTime.call(player);
553
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
554
- },
555
- renderSeek: (timeSeconds: number) => {
556
- renderSeek.call(player, timeSeconds);
557
- },
558
- };
559
- }
560
-
561
- function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
562
- const player = getPreviewPlayer(iframe?.contentWindow);
563
- if (!player) return false;
564
- const nextTime = Math.max(0, timeSeconds);
565
- player.renderSeek(nextTime);
566
- usePlayerStore.getState().setCurrentTime(nextTime);
567
- liveTime.notify(nextTime);
568
- return true;
569
- }
570
-
571
- function parseFiniteSeconds(value: string | null): number | null {
572
- if (value == null || value.trim() === "") return null;
573
- const parsed = Number.parseFloat(value);
574
- return Number.isFinite(parsed) ? parsed : null;
575
- }
576
-
577
- function resolveLayerVisibleSeekTime(
578
- layerElement: HTMLElement,
579
- timelineElement: TimelineElement | null,
580
- player: PreviewPlayerCompat | null,
581
- ): number | null {
582
- if (!timelineElement || !player) return null;
583
- const originalTime = player.getTime();
584
-
585
- const clipStart = Math.max(0, timelineElement.start);
586
- const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
587
- const authoredStart = parseFiniteSeconds(
588
- layerElement.getAttribute("data-start") ??
589
- layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
590
- null,
591
- );
592
- const preferredTime =
593
- authoredStart == null
594
- ? clipStart
595
- : Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
596
- const candidates = [preferredTime, clipStart];
597
- const duration = clipEnd - clipStart;
598
- if (duration > 0) {
599
- const maxSamples = 24;
600
- const frameStep = 1 / 24;
601
- const step = Math.max(frameStep, duration / maxSamples);
602
- for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
603
- candidates.push(Math.min(clipEnd, time));
604
- }
605
- }
606
- candidates.push(clipEnd);
607
-
608
- let lastTried = preferredTime;
609
- let clearestVisibleTime: number | null = null;
610
- let clearestVisibleOpacity = 0;
611
- let resolvedTime: number | null = null;
612
- const seen = new Set<string>();
613
- try {
614
- for (const candidate of candidates) {
615
- const time = Math.min(clipEnd, Math.max(clipStart, candidate));
616
- const key = time.toFixed(4);
617
- if (seen.has(key)) continue;
618
- seen.add(key);
619
- lastTried = time;
620
- player.renderSeek(time);
621
- const visibility = getTimelineLayerVisibilityInPreview(layerElement);
622
- if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
623
- clearestVisibleTime = time;
624
- clearestVisibleOpacity = visibility.compositeOpacity;
625
- }
626
- if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
627
- resolvedTime = time;
628
- break;
629
- }
630
- }
631
- } finally {
632
- player.renderSeek(originalTime);
633
- }
634
-
635
- return resolvedTime ?? clearestVisibleTime ?? lastTried;
636
- }
637
-
638
- function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
639
- const win = iframe?.contentWindow;
640
- if (!win) return null;
641
-
642
- try {
643
- let pausedTime: number | null = null;
644
- const player = objectLike(Reflect.get(win, "__player"));
645
- pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
646
- callPlaybackMethod(player, "pause");
647
-
648
- const timeline = objectLike(Reflect.get(win, "__timeline"));
649
- pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
650
- callPlaybackMethod(timeline, "pause");
651
-
652
- const timelines = objectLike(Reflect.get(win, "__timelines"));
653
- if (timelines) {
654
- for (const value of Object.values(timelines)) {
655
- const timelineRecord = objectLike(value);
656
- pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
657
- callPlaybackMethod(timelineRecord, "pause");
658
- }
659
- }
660
-
661
- return pausedTime;
662
- } catch {
663
- return null;
664
- }
665
- }
666
-
667
- // ── Ask Agent Modal ──
668
-
669
- function clampNumber(value: number, min: number, max: number): number {
670
- if (max < min) return min;
671
- return Math.min(Math.max(value, min), max);
672
- }
673
-
674
- function getAgentModalPositionStyle(
675
- anchorPoint: AgentModalAnchorPoint | null,
676
- ): CSSProperties | undefined {
677
- if (!anchorPoint || typeof window === "undefined") return undefined;
678
-
679
- const modalWidth = 480;
680
- const estimatedModalHeight = 270;
681
- const margin = 16;
682
- const left = clampNumber(
683
- anchorPoint.x,
684
- margin + modalWidth / 2,
685
- window.innerWidth - margin - modalWidth / 2,
686
- );
687
- const top = clampNumber(
688
- anchorPoint.y + 12,
689
- margin,
690
- window.innerHeight - margin - estimatedModalHeight,
691
- );
692
-
693
- return { left, top, transform: "translateX(-50%)" };
694
- }
695
-
696
- function AskAgentModal({
697
- selectionLabel,
698
- anchorPoint = null,
699
- onSubmit,
700
- onClose,
701
- }: {
702
- selectionLabel: string;
703
- anchorPoint?: AgentModalAnchorPoint | null;
704
- onSubmit: (instruction: string) => void;
705
- onClose: () => void;
706
- }) {
707
- const [value, setValue] = useState("");
708
- const inputRef = useRef<HTMLTextAreaElement>(null);
709
- const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
710
-
711
- useMountEffect(() => {
712
- requestAnimationFrame(() => inputRef.current?.focus());
713
- });
714
-
715
- const handleSubmit = () => {
716
- if (!value.trim()) return;
717
- onSubmit(value.trim());
718
- };
719
-
720
- return (
721
- <div
722
- className={
723
- anchorPoint
724
- ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
725
- : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
726
- }
727
- onClick={onClose}
728
- >
729
- <div
730
- className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
731
- anchorPoint ? "fixed" : ""
732
- }`}
733
- style={modalPositionStyle}
734
- onClick={(e) => e.stopPropagation()}
735
- >
736
- <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
737
- <div>
738
- <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
739
- <p className="text-xs text-neutral-500 mt-0.5">
740
- {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
741
- </p>
742
- </div>
743
- <button
744
- className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
745
- onClick={onClose}
746
- >
747
- <svg
748
- width="14"
749
- height="14"
750
- viewBox="0 0 24 24"
751
- fill="none"
752
- stroke="currentColor"
753
- strokeWidth="2"
754
- strokeLinecap="round"
755
- >
756
- <line x1="18" y1="6" x2="6" y2="18" />
757
- <line x1="6" y1="6" x2="18" y2="18" />
758
- </svg>
759
- </button>
760
- </div>
761
- <div className="px-5 py-4">
762
- <textarea
763
- ref={inputRef}
764
- className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
765
- placeholder="Describe what you want to change…"
766
- value={value}
767
- onChange={(e) => setValue(e.target.value)}
768
- onKeyDown={(e) => {
769
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
770
- if (e.key === "Escape") onClose();
771
- }}
772
- />
773
- </div>
774
- <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
775
- <span className="text-[11px] text-neutral-600">
776
- {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
777
- </span>
778
- <button
779
- className="px-4 py-1.5 rounded-lg bg-studio-accent/90 text-xs font-medium text-neutral-950 hover:bg-studio-accent disabled:opacity-40 disabled:cursor-not-allowed"
780
- disabled={!value.trim()}
781
- onClick={handleSubmit}
782
- >
783
- Copy prompt
784
- </button>
785
- </div>
786
- </div>
787
- </div>
788
- );
789
- }
790
-
791
- const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
792
- image: 3,
793
- video: 5,
794
- audio: 5,
795
- };
796
-
797
- function collectHtmlIds(source: string): string[] {
798
- return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
799
- }
800
-
801
- async function resolveDroppedAssetDuration(
802
- projectId: string,
803
- assetPath: string,
804
- kind: TimelineAssetKind,
805
- ): Promise<number> {
806
- if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
807
-
808
- const media = document.createElement(kind === "video" ? "video" : "audio");
809
- media.preload = "metadata";
810
- media.src = `/api/projects/${projectId}/preview/${assetPath}`;
811
-
812
- const duration = await new Promise<number>((resolve) => {
813
- const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
814
- const finalize = (value: number) => {
815
- window.clearTimeout(timeout);
816
- resolve(value);
817
- };
818
-
819
- media.addEventListener(
820
- "loadedmetadata",
821
- () => {
822
- const raw = Number(media.duration);
823
- finalize(
824
- Number.isFinite(raw) && raw > 0
825
- ? Math.round(raw * 100) / 100
826
- : DEFAULT_TIMELINE_ASSET_DURATION[kind],
827
- );
828
- },
829
- { once: true },
830
- );
831
- media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
832
- once: true,
833
- });
834
- });
835
-
836
- media.src = "";
837
- media.load();
838
- return duration;
839
- }
840
-
841
- // ── Main App ──
842
-
843
- export function StudioApp() {
844
- const [projectId, setProjectId] = useState<string | null>(null);
845
- const [resolving, setResolving] = useState(true);
846
-
847
- useMountEffect(() => {
848
- const hashProjectId = parseProjectIdFromHash(window.location.hash);
849
- if (hashProjectId) {
850
- setProjectId(hashProjectId);
851
- setResolving(false);
852
- return;
853
- }
854
- // No hash — auto-select first available project
855
- fetch("/api/projects")
856
- .then((r) => r.json())
857
- .then((data) => {
858
- const first = (data.projects ?? [])[0];
859
- if (first) {
860
- setProjectId(first.id);
861
- window.location.hash = buildProjectHash(first.id);
862
- }
863
- })
864
- .catch(() => {})
865
- .finally(() => setResolving(false));
866
- });
867
-
868
- const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
869
- const [projectDir, setProjectDir] = useState<string | null>(null);
870
- const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
871
- const [fileTree, setFileTree] = useState<string[]>([]);
872
- const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
873
- const renderQueue = useRenderQueue(projectId);
874
- const captionEditMode = useCaptionStore((s) => s.isEditMode);
875
- const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
876
- const captionSync = useCaptionSync(projectId);
877
-
878
- // Resizable and collapsible panel widths
879
- const [leftWidth, setLeftWidth] = useState(240);
880
- const [rightWidth, setRightWidth] = useState(400);
881
- const [leftCollapsed, setLeftCollapsed] = useState(false);
882
- const [rightCollapsed, setRightCollapsed] = useState(true);
883
- const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
884
- const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
885
- const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
886
- const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
887
- const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
888
- const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
889
- string | undefined
890
- >();
891
- const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
892
- null,
893
- );
894
- const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
895
- const [agentModalOpen, setAgentModalOpen] = useState(false);
896
- const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
897
- const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
898
- const [compositionLoading, setCompositionLoading] = useState(true);
899
- const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
900
- const refreshPreviewDocumentVersion = useCallback(() => {
901
- setPreviewDocumentVersion((version) => version + 1);
902
- window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
903
- window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
904
- }, []);
905
- // Auto-enter caption edit mode when the iframe contains .caption-group elements.
906
- // This is a subscription to external events (postMessage from runtime) — useEffect
907
- // is appropriate here. The runtime fires "state"/"timeline" messages after all
908
- // compositions load, which triggers caption detection.
909
- // eslint-disable-next-line no-restricted-syntax
910
- useEffect(() => {
911
- if (!projectId) return;
912
-
913
- let activating = false;
914
-
915
- const tryActivateCaptions = () => {
916
- if (useCaptionStore.getState().isEditMode || activating) {
917
- return;
918
- }
919
-
920
- const iframe = previewIframeRef.current;
921
- let doc: Document | null = null;
922
- let win: Window | null = null;
923
- try {
924
- doc = iframe?.contentDocument ?? null;
925
- win = iframe?.contentWindow ?? null;
926
- } catch {
927
- return;
928
- }
929
- if (!doc || !win) return;
930
-
931
- const groups = doc.querySelectorAll(".caption-group");
932
- if (groups.length === 0) return;
933
-
934
- // Find the captions composition source path.
935
- // The runtime strips data-composition-src after loading, so also check
936
- // data-composition-file (set by the bundler) and the compIdToSrc map.
937
- let captionSrcPath: string | null = null;
938
-
939
- // Strategy 1: data-composition-src or data-composition-file attributes
940
- const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
941
- for (const host of compHosts) {
942
- const src =
943
- host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
944
- if (src && src.includes("captions")) {
945
- captionSrcPath = src;
946
- break;
947
- }
948
- }
949
-
950
- // Strategy 2: compIdToSrc map (built from raw index.html before runtime strips attrs)
951
- if (!captionSrcPath) {
952
- for (const [id, src] of compIdToSrc) {
953
- if (id.includes("caption") || src.includes("caption")) {
954
- captionSrcPath = src;
955
- break;
956
- }
957
- }
958
- }
959
-
960
- // Strategy 3: activeCompPath if viewing captions directly
961
- if (!captionSrcPath && activeCompPath?.includes("captions")) {
962
- captionSrcPath = activeCompPath;
963
- }
964
-
965
- // Strategy 4: find composition element with "caption" in its ID
966
- if (!captionSrcPath) {
967
- const captionComp = doc.querySelector('[data-composition-id*="caption"]');
968
- if (captionComp) {
969
- const compId = captionComp.getAttribute("data-composition-id") || "";
970
- captionSrcPath = compIdToSrc.get(compId) || null;
971
- }
972
- }
973
-
974
- if (!captionSrcPath) return;
975
-
976
- activating = true;
977
- const srcPath = captionSrcPath;
978
- fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
979
- .then((r) => r.json())
980
- .then((data: { content?: string }) => {
981
- if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
982
- const root = doc.querySelector("[data-composition-id]");
983
- const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
984
- const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
985
- const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
986
- const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
987
- if (!model) return;
988
- const store = useCaptionStore.getState();
989
- store.setModel(model);
990
- store.setSourceFilePath(srcPath);
991
- store.setEditMode(true);
992
- captionSync.loadOverrides();
993
- })
994
- .catch(() => {})
995
- .finally(() => {
996
- activating = false;
997
- });
998
- };
999
-
1000
- // Listen for runtime messages that signal composition loading is complete
1001
- const handleMessage = (e: MessageEvent) => {
1002
- const data = e.data;
1003
- if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
1004
- tryActivateCaptions();
1005
- }
1006
- };
1007
-
1008
- window.addEventListener("message", handleMessage);
1009
- // Try immediately in case compositions are already loaded
1010
- tryActivateCaptions();
1011
-
1012
- return () => {
1013
- window.removeEventListener("message", handleMessage);
1014
- };
1015
- }, [activeCompPath, projectId, compIdToSrc, captionSync]);
1016
-
1017
- // Auto-expand right panel when a caption word is selected
1018
- // eslint-disable-next-line no-restricted-syntax
1019
- useEffect(() => {
1020
- if (captionEditMode) {
1021
- setRightCollapsed(!captionHasSelection);
1022
- }
1023
- }, [captionHasSelection, captionEditMode]);
1024
- const [globalDragOver, setGlobalDragOver] = useState(false);
1025
- const [appToast, setAppToast] = useState<AppToast | null>(null);
1026
- const [timelineVisible, setTimelineVisible] = useState(true);
1027
- const [captureFrameTime, setCaptureFrameTime] = useState(0);
1028
- const dragCounterRef = useRef(0);
1029
- const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1030
- const lastBlockedTimelineToastAtRef = useRef(0);
1031
- const lastBlockedDomMoveToastAtRef = useRef(0);
1032
- const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
1033
- const previewHotkeyWindowRef = useRef<Window | null>(null);
1034
- const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
1035
- const panelDragRef = useRef<{
1036
- side: "left" | "right";
1037
- startX: number;
1038
- startW: number;
1039
- } | null>(null);
1040
-
1041
- // Derive active preview URL from composition path (for drilled-down thumbnails)
1042
- const activePreviewUrl = activeCompPath
1043
- ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
1044
- : null;
1045
- const isMasterView = !activeCompPath || activeCompPath === "index.html";
1046
- const zoomMode = usePlayerStore((s) => s.zoomMode);
1047
- const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
1048
- const setZoomMode = usePlayerStore((s) => s.setZoomMode);
1049
- const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
1050
- const currentTime = usePlayerStore((s) => s.currentTime);
1051
- const timelineElements = usePlayerStore((s) => s.elements);
1052
- const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
1053
- const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
1054
- const timelineDuration = usePlayerStore((s) => s.duration);
1055
- const effectiveTimelineDuration = useMemo(() => {
1056
- const maxEnd =
1057
- timelineElements.length > 0
1058
- ? Math.max(...timelineElements.map((element) => element.start + element.duration))
1059
- : 0;
1060
- return Math.max(timelineDuration, maxEnd);
1061
- }, [timelineDuration, timelineElements]);
1062
- const displayedTimelineZoomPercent = useMemo(
1063
- () => getTimelineZoomPercent(zoomMode, manualZoomPercent),
1064
- [zoomMode, manualZoomPercent],
1065
- );
1066
- const toggleTimelineVisibility = useCallback(() => {
1067
- setTimelineVisible((visible) => !visible);
1068
- }, []);
1069
- const toggleLeftSidebar = useCallback(() => {
1070
- setLeftCollapsed((collapsed) => !collapsed);
1071
- }, []);
1072
- const refreshCaptureFrameTime = useCallback(() => {
1073
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
1074
- }, []);
1075
-
1076
- useMountEffect(() => {
1077
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
1078
- return liveTime.subscribe(setCaptureFrameTime);
1079
- });
1080
-
1081
- const captureFrameHref = projectId
1082
- ? buildFrameCaptureUrl({
1083
- projectId,
1084
- compositionPath: activeCompPath,
1085
- currentTime: captureFrameTime,
1086
- })
1087
- : "#";
1088
- const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
1089
- useMountEffect(() => () => {
1090
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
1091
- });
1092
- const handleTimelineToggleHotkey = useCallback(
1093
- (event: KeyboardEvent) => {
1094
- if (!shouldHandleTimelineToggleHotkey(event)) return;
1095
- event.preventDefault();
1096
- toggleTimelineVisibility();
1097
- },
1098
- [toggleTimelineVisibility],
1099
- );
1100
-
1101
- useMountEffect(() => {
1102
- window.addEventListener("keydown", handleTimelineToggleHotkey);
1103
- return () => {
1104
- window.removeEventListener("keydown", handleTimelineToggleHotkey);
1105
- };
1106
- });
1107
-
1108
- const syncPreviewTimelineHotkey = useCallback(
1109
- (iframe: HTMLIFrameElement | null) => {
1110
- const nextWindow = iframe?.contentWindow ?? null;
1111
- if (previewHotkeyWindowRef.current === nextWindow) return;
1112
- if (previewHotkeyWindowRef.current) {
1113
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1114
- }
1115
- previewHotkeyWindowRef.current = nextWindow;
1116
- nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
1117
- },
1118
- [handleTimelineToggleHotkey],
1119
- );
1120
-
1121
- useEffect(
1122
- () => () => {
1123
- if (previewHotkeyWindowRef.current) {
1124
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1125
- previewHotkeyWindowRef.current = null;
1126
- }
1127
- },
1128
- [handleTimelineToggleHotkey],
1129
- );
1130
-
1131
- const renderClipContent = useCallback(
1132
- (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
1133
- const pid = projectIdRef.current;
1134
- if (!pid) return null;
1135
-
1136
- // Resolve composition source path using the compIdToSrc map
1137
- let compSrc = el.compositionSrc;
1138
- if (compSrc && compIdToSrc.size > 0) {
1139
- const resolved =
1140
- compIdToSrc.get(el.id) ||
1141
- compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
1142
- if (resolved) compSrc = resolved;
1143
- }
1144
-
1145
- // Composition clips — always use the comp's own preview URL for thumbnails.
1146
- // This renders the composition in isolation so we get clean frames
1147
- // instead of capturing the master at a time when the comp is fading in.
1148
- if (compSrc) {
1149
- return (
1150
- <CompositionThumbnail
1151
- previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
1152
- label={getTimelineElementLabel(el)}
1153
- labelColor={style.label}
1154
- accentColor={style.clip}
1155
- seekTime={0}
1156
- duration={el.duration}
1157
- />
1158
- );
1159
- }
1160
-
1161
- // When drilled into a composition, render all inner elements via
1162
- // CompositionThumbnail at their start time — most accurate visual.
1163
- if (activePreviewUrl && el.duration > 0) {
1164
- return (
1165
- <CompositionThumbnail
1166
- previewUrl={activePreviewUrl}
1167
- label={getTimelineElementLabel(el)}
1168
- labelColor={style.label}
1169
- accentColor={style.clip}
1170
- selector={el.selector}
1171
- selectorIndex={el.selectorIndex}
1172
- seekTime={el.start}
1173
- duration={el.duration}
1174
- />
1175
- );
1176
- }
1177
-
1178
- const htmlPreviewEligible =
1179
- el.duration > 0 &&
1180
- effectiveTimelineDuration > 0 &&
1181
- el.duration < effectiveTimelineDuration * 0.92 &&
1182
- !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
1183
-
1184
- // Audio clips — waveform visualization
1185
- if (el.tag === "audio") {
1186
- const previewBase = `/api/projects/${pid}/preview/`;
1187
- const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
1188
- const srcRelative = el.src
1189
- ? previewIdx !== -1
1190
- ? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
1191
- : el.src.startsWith("http")
1192
- ? null
1193
- : el.src
1194
- : null;
1195
- const audioUrl = srcRelative
1196
- ? `/api/projects/${pid}/preview/${srcRelative}`
1197
- : (el.src ?? "");
1198
- const waveformUrl = srcRelative
1199
- ? `/api/projects/${pid}/waveform/${srcRelative}`
1200
- : undefined;
1201
- return (
1202
- <AudioWaveform
1203
- audioUrl={audioUrl}
1204
- waveformUrl={waveformUrl}
1205
- label={getTimelineElementLabel(el)}
1206
- labelColor={style.label}
1207
- />
1208
- );
1209
- }
1210
-
1211
- if ((el.tag === "video" || el.tag === "img") && el.src) {
1212
- const mediaSrc = el.src.startsWith("http")
1213
- ? el.src
1214
- : `/api/projects/${pid}/preview/${el.src}`;
1215
- return (
1216
- <VideoThumbnail
1217
- videoSrc={mediaSrc}
1218
- label={getTimelineElementLabel(el)}
1219
- labelColor={style.label}
1220
- duration={el.duration}
1221
- />
1222
- );
1223
- }
1224
-
1225
- if (htmlPreviewEligible) {
1226
- return (
1227
- <CompositionThumbnail
1228
- previewUrl={`/api/projects/${pid}/preview`}
1229
- label={getTimelineElementLabel(el)}
1230
- labelColor={style.label}
1231
- accentColor={style.clip}
1232
- selector={el.selector}
1233
- selectorIndex={el.selectorIndex}
1234
- seekTime={el.start}
1235
- duration={el.duration}
1236
- />
1237
- );
1238
- }
1239
-
1240
- return null;
1241
- },
1242
- [compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
1243
- );
1244
- const timelineToolbar = (
1245
- <div className="border-b border-neutral-800/40 bg-neutral-950/96">
1246
- <div className="flex items-center justify-between px-3 py-2">
1247
- <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
1248
- Timeline
1249
- </div>
1250
- <div className="flex items-center gap-1">
1251
- <button
1252
- type="button"
1253
- onClick={() => setZoomMode("fit")}
1254
- className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
1255
- zoomMode === "fit"
1256
- ? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
1257
- : "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
1258
- }`}
1259
- title="Fit timeline to width"
1260
- >
1261
- Fit
1262
- </button>
1263
- <button
1264
- type="button"
1265
- onClick={() => {
1266
- setZoomMode("manual");
1267
- setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
1268
- }}
1269
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
1270
- title="Zoom out"
1271
- >
1272
- -
1273
- </button>
1274
- <div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
1275
- {`${displayedTimelineZoomPercent}%`}
1276
- </div>
1277
- <button
1278
- type="button"
1279
- onClick={() => {
1280
- setZoomMode("manual");
1281
- setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
1282
- }}
1283
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
1284
- title="Zoom in"
1285
- >
1286
- +
1287
- </button>
1288
- <button
1289
- type="button"
1290
- onClick={toggleTimelineVisibility}
1291
- className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
1292
- title={getTimelineToggleTitle(true)}
1293
- aria-label="Hide timeline editor"
1294
- >
1295
- <svg
1296
- width="14"
1297
- height="14"
1298
- viewBox="0 0 24 24"
1299
- fill="none"
1300
- stroke="currentColor"
1301
- strokeWidth="1.8"
1302
- strokeLinecap="round"
1303
- strokeLinejoin="round"
1304
- aria-hidden="true"
1305
- >
1306
- <path d="M5 7h14" />
1307
- <path d="m8 11 4 4 4-4" />
1308
- </svg>
1309
- </button>
1310
- </div>
1311
- </div>
1312
- </div>
1313
- );
1314
- const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
1315
- const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
1316
- const [linting, setLinting] = useState(false);
1317
- const [refreshKey, setRefreshKey] = useState(0);
1318
- const [, setStudioMotionRevision] = useState(0);
1319
- const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1320
- const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1321
- const projectIdRef = useRef(projectId);
1322
- const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
1323
- const consoleErrorsRef = useRef<LintFinding[]>([]);
1324
- const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1325
- const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
1326
- const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
1327
- const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
1328
- const domEditSaveTimestampRef = useRef(0);
1329
- const domTextCommitVersionRef = useRef(0);
1330
- const domEditSaveQueueRef = useRef(Promise.resolve());
1331
- const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
1332
- emptyStudioManualEditManifest(),
1333
- );
1334
- const studioManualEditRevisionRef = useRef(0);
1335
- const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
1336
- const studioMotionRevisionRef = useRef(0);
1337
- const applyStudioManualEditsToPreviewRef = useRef<
1338
- (
1339
- iframe?: HTMLIFrameElement | null,
1340
- options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1341
- ) => Promise<void>
1342
- >(async () => {});
1343
- const applyStudioMotionToPreviewRef = useRef<
1344
- (
1345
- iframe?: HTMLIFrameElement | null,
1346
- options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1347
- ) => Promise<void>
1348
- >(async () => {});
1349
- const studioManualEditProjectRef = useRef<string | null>(projectId);
1350
- const activeCompPathRef = useRef(activeCompPath);
1351
- activeCompPathRef.current = activeCompPath;
1352
-
1353
- const queueDomEditSave = useCallback((save: () => Promise<void>) => {
1354
- const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
1355
- domEditSaveQueueRef.current = queuedSave.then(
1356
- () => undefined,
1357
- () => undefined,
1358
- );
1359
- return queuedSave;
1360
- }, []);
1361
-
1362
- const waitForPendingDomEditSaves = useCallback(async () => {
1363
- await domEditSaveQueueRef.current.catch(() => undefined);
1364
- }, []);
1365
-
1366
- // Listen for external file changes (user editing HTML outside the editor).
1367
- // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
1368
- // Suppress file-change events that echo back from a recent DOM edit save —
1369
- // those changes are already applied to the iframe DOM and a full reload
1370
- // would flash the preview.
1371
- useMountEffect(() => {
1372
- const handler = (payload?: unknown) => {
1373
- const changedPath = readStudioFileChangePath(payload);
1374
- const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
1375
- if (isStudioManualEditManifestPath(changedPath)) {
1376
- if (!recentDomEditSave) {
1377
- void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
1378
- forceFromDisk: true,
1379
- });
1380
- }
1381
- return;
1382
- }
1383
- if (isStudioMotionManifestPath(changedPath)) {
1384
- if (!recentDomEditSave) {
1385
- void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
1386
- forceFromDisk: true,
1387
- });
1388
- }
1389
- return;
1390
- }
1391
- if (recentDomEditSave) return;
1392
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1393
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
1394
- };
1395
- if (import.meta.hot) {
1396
- import.meta.hot.on("hf:file-change", handler);
1397
- return () => import.meta.hot?.off?.("hf:file-change", handler);
1398
- }
1399
- // SSE fallback for embedded studio server
1400
- const es = new EventSource("/api/events");
1401
- es.addEventListener("file-change", handler);
1402
- return () => es.close();
1403
- });
1404
- projectIdRef.current = projectId;
1405
- domEditSelectionRef.current = domEditSelection;
1406
- domEditGroupSelectionsRef.current = domEditGroupSelections;
1407
- domEditHoverSelectionRef.current = domEditHoverSelection;
1408
-
1409
- // eslint-disable-next-line no-restricted-syntax
1410
- useEffect(() => {
1411
- const previousProjectId = studioManualEditProjectRef.current;
1412
- studioManualEditProjectRef.current = projectId;
1413
- if (!previousProjectId || previousProjectId === projectId) return;
1414
- studioManualEditManifestRef.current = emptyStudioManualEditManifest();
1415
- studioManualEditRevisionRef.current += 1;
1416
- studioMotionManifestRef.current = emptyStudioMotionManifest();
1417
- studioMotionRevisionRef.current += 1;
1418
- setStudioMotionRevision((revision) => revision + 1);
1419
- }, [projectId]);
1420
-
1421
- // Load file tree when projectId changes.
1422
- // Note: This is one of the few places where useEffect with deps is acceptable —
1423
- // it's data fetching tied to a prop change. Ideally this would use a data-fetching
1424
- // library (useQuery/useSWR) or the parent component would own the fetch.
1425
- // eslint-disable-next-line no-restricted-syntax
1426
- useEffect(() => {
1427
- if (!projectId) return;
1428
- let cancelled = false;
1429
- fetch(`/api/projects/${projectId}`)
1430
- .then((r) => r.json())
1431
- .then((data: { files?: string[]; dir?: string }) => {
1432
- if (!cancelled && data.files) setFileTree(data.files);
1433
- if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
1434
- })
1435
- .catch(() => {
1436
- if (!cancelled) setProjectDir(null);
1437
- });
1438
- return () => {
1439
- cancelled = true;
1440
- };
1441
- }, [projectId]);
1442
-
1443
- const handleFileSelect = useCallback((path: string) => {
1444
- const pid = projectIdRef.current;
1445
- if (!pid) return;
1446
- // Expand left panel to 50vw when opening a file in Code tab
1447
- setLeftWidth((prev) => Math.max(prev, Math.floor(window.innerWidth * 0.5)));
1448
- // Skip fetching binary content for media files — just set the path for preview
1449
- if (isMediaFile(path)) {
1450
- setEditingFile({ path, content: null });
1451
- return;
1452
- }
1453
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
1454
- .then((r) => r.json())
1455
- .then((data: { content?: string }) => {
1456
- if (data.content != null) {
1457
- setEditingFile({ path, content: data.content });
1458
- }
1459
- })
1460
- .catch(() => {});
1461
- }, []);
1462
-
1463
- const editingPathRef = useRef(editingFile?.path);
1464
- editingPathRef.current = editingFile?.path;
1465
- const editHistory = usePersistentEditHistory({ projectId });
1466
-
1467
- const readProjectFile = useCallback(async (path: string): Promise<string> => {
1468
- const pid = projectIdRef.current;
1469
- if (!pid) throw new Error("No active project");
1470
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
1471
- if (!response.ok) throw new Error(`Failed to read ${path}`);
1472
- const data = (await response.json()) as { content?: string };
1473
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
1474
- return data.content;
1475
- }, []);
1476
-
1477
- const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
1478
- const pid = projectIdRef.current;
1479
- if (!pid) throw new Error("No active project");
1480
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
1481
- method: "PUT",
1482
- headers: { "Content-Type": "text/plain" },
1483
- body: content,
1484
- });
1485
- if (!response.ok) throw new Error(`Failed to save ${path}`);
1486
- if (editingPathRef.current === path) {
1487
- setEditingFile({ path, content });
1488
- }
1489
- }, []);
1490
-
1491
- const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
1492
- const pid = projectIdRef.current;
1493
- if (!pid) throw new Error("No active project");
1494
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
1495
- if (response.status === 404) return "";
1496
- if (!response.ok) throw new Error(`Failed to read ${path}`);
1497
- const data = (await response.json()) as { content?: string };
1498
- return typeof data.content === "string" ? data.content : "";
1499
- }, []);
1500
-
1501
- const handleContentChange = useCallback(
1502
- (content: string) => {
1503
- const pid = projectIdRef.current;
1504
- if (!pid) return;
1505
- const path = editingPathRef.current;
1506
- if (!path) return;
1507
-
1508
- // Debounce the server write (600ms)
1509
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1510
- saveTimerRef.current = setTimeout(() => {
1511
- saveProjectFilesWithHistory({
1512
- projectId: pid,
1513
- label: "Edit source",
1514
- kind: "source",
1515
- coalesceKey: `source:${path}`,
1516
- files: { [path]: content },
1517
- readFile: readProjectFile,
1518
- writeFile: writeProjectFile,
1519
- recordEdit: editHistory.recordEdit,
1520
- })
1521
- .then(() => {
1522
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1523
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
1524
- })
1525
- .catch(() => {});
1526
- }, 600);
1527
- },
1528
- [editHistory.recordEdit, readProjectFile, writeProjectFile],
1529
- );
1530
-
1531
- const handleTimelineElementMove = useCallback(
1532
- async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
1533
- const pid = projectIdRef.current;
1534
- if (!pid) throw new Error("No active project");
1535
-
1536
- const targetPath = element.sourceFile || activeCompPath || "index.html";
1537
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1538
- if (!response.ok) {
1539
- throw new Error(`Failed to read ${targetPath}`);
1540
- }
1541
-
1542
- const data = (await response.json()) as { content?: string };
1543
- const originalContent = data.content;
1544
- if (typeof originalContent !== "string") {
1545
- throw new Error(`Missing file contents for ${targetPath}`);
1546
- }
1547
-
1548
- const patchTarget = element.domId
1549
- ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
1550
- : element.selector
1551
- ? { selector: element.selector, selectorIndex: element.selectorIndex }
1552
- : null;
1553
- if (!patchTarget) {
1554
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
1555
- }
1556
-
1557
- const resolvedTargetPath = targetPath || "index.html";
1558
- const relevantElements = timelineElements
1559
- .map((timelineElement) =>
1560
- (timelineElement.key ?? timelineElement.id) === (element.key ?? element.id)
1561
- ? { ...timelineElement, start: updates.start, track: updates.track }
1562
- : timelineElement,
1563
- )
1564
- .filter(
1565
- (timelineElement) =>
1566
- (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
1567
- );
1568
- const trackZIndices = buildTrackZIndexMap(
1569
- relevantElements.map((timelineElement) => timelineElement.track),
1570
- );
1571
-
1572
- let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
1573
- type: "attribute",
1574
- property: "start",
1575
- value: formatTimelineAttributeNumber(updates.start),
1576
- });
1577
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
1578
- type: "attribute",
1579
- property: "track-index",
1580
- value: String(updates.track),
1581
- });
1582
- for (const timelineElement of relevantElements) {
1583
- const elementTarget = timelineElement.domId
1584
- ? {
1585
- id: timelineElement.domId,
1586
- selector: timelineElement.selector,
1587
- selectorIndex: timelineElement.selectorIndex,
1588
- }
1589
- : timelineElement.selector
1590
- ? {
1591
- selector: timelineElement.selector,
1592
- selectorIndex: timelineElement.selectorIndex,
1593
- }
1594
- : null;
1595
- if (!elementTarget) continue;
1596
- const nextZIndex = trackZIndices.get(timelineElement.track);
1597
- if (nextZIndex == null) continue;
1598
- patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
1599
- type: "inline-style",
1600
- property: "z-index",
1601
- value: String(nextZIndex),
1602
- });
1603
- }
1604
-
1605
- if (patchedContent === originalContent) {
1606
- throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1607
- }
1608
-
1609
- await saveProjectFilesWithHistory({
1610
- projectId: pid,
1611
- label: "Move timeline clip",
1612
- kind: "timeline",
1613
- files: { [targetPath]: patchedContent },
1614
- readFile: async () => originalContent,
1615
- writeFile: writeProjectFile,
1616
- recordEdit: editHistory.recordEdit,
1617
- });
1618
-
1619
- setRefreshKey((k) => k + 1);
1620
- },
1621
- [activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
1622
- );
1623
-
1624
- const handleTimelineElementResize = useCallback(
1625
- async (
1626
- element: TimelineElement,
1627
- updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
1628
- ) => {
1629
- const pid = projectIdRef.current;
1630
- if (!pid) throw new Error("No active project");
1631
-
1632
- const targetPath = element.sourceFile || activeCompPath || "index.html";
1633
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1634
- if (!response.ok) {
1635
- throw new Error(`Failed to read ${targetPath}`);
1636
- }
1637
-
1638
- const data = (await response.json()) as { content?: string };
1639
- const originalContent = data.content;
1640
- if (typeof originalContent !== "string") {
1641
- throw new Error(`Missing file contents for ${targetPath}`);
1642
- }
1643
-
1644
- const patchTarget = element.domId
1645
- ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
1646
- : element.selector
1647
- ? { selector: element.selector, selectorIndex: element.selectorIndex }
1648
- : null;
1649
- if (!patchTarget) {
1650
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
1651
- }
1652
-
1653
- const playbackStartAttrName =
1654
- element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
1655
- const currentPlaybackStartValue =
1656
- readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
1657
- readAttributeByTarget(originalContent, patchTarget, "media-start");
1658
- const currentPlaybackStart =
1659
- currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
1660
- const trimDelta = updates.start - element.start;
1661
- const fallbackPlaybackStart =
1662
- updates.playbackStart == null &&
1663
- trimDelta !== 0 &&
1664
- Number.isFinite(currentPlaybackStart) &&
1665
- currentPlaybackStart != null
1666
- ? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
1667
- : undefined;
1668
- const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
1669
-
1670
- let patchedContent = originalContent;
1671
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
1672
- type: "attribute",
1673
- property: "start",
1674
- value: formatTimelineAttributeNumber(updates.start),
1675
- });
1676
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
1677
- type: "attribute",
1678
- property: "duration",
1679
- value: formatTimelineAttributeNumber(updates.duration),
1680
- });
1681
- if (nextPlaybackStart != null) {
1682
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
1683
- type: "attribute",
1684
- property: playbackStartAttrName,
1685
- value: formatTimelineAttributeNumber(nextPlaybackStart),
1686
- });
1687
- }
1688
-
1689
- if (patchedContent === originalContent) {
1690
- throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1691
- }
1692
-
1693
- await saveProjectFilesWithHistory({
1694
- projectId: pid,
1695
- label: "Resize timeline clip",
1696
- kind: "timeline",
1697
- files: { [targetPath]: patchedContent },
1698
- readFile: async () => originalContent,
1699
- writeFile: writeProjectFile,
1700
- recordEdit: editHistory.recordEdit,
1701
- });
1702
-
1703
- setRefreshKey((k) => k + 1);
1704
- },
1705
- [activeCompPath, editHistory.recordEdit, writeProjectFile],
1706
- );
1707
-
1708
- const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
1709
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
1710
- setAppToast({ message, tone });
1711
- toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
1712
- }, []);
1713
-
1714
- const handleCaptureFrameClick = useCallback(
1715
- async (event: MouseEvent<HTMLAnchorElement>) => {
1716
- if (!projectId) return;
1717
- event.preventDefault();
1718
-
1719
- const currentTime = usePlayerStore.getState().currentTime;
1720
- setCaptureFrameTime(currentTime);
1721
- await waitForPendingDomEditSaves();
1722
- const href = buildFrameCaptureUrl({
1723
- projectId,
1724
- compositionPath: activeCompPath,
1725
- currentTime,
1726
- });
1727
- const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
1728
-
1729
- try {
1730
- const response = await fetch(href, { cache: "no-store" });
1731
- if (!response.ok) {
1732
- throw new Error(`Capture failed (${response.status})`);
1733
- }
1734
- const blob = await response.blob();
1735
- const blobUrl = URL.createObjectURL(blob);
1736
- const link = document.createElement("a");
1737
- link.href = blobUrl;
1738
- link.download = filename;
1739
- document.body.appendChild(link);
1740
- link.click();
1741
- link.remove();
1742
- setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
1743
- } catch (err) {
1744
- const message = err instanceof Error ? err.message : "Capture failed";
1745
- showToast(message);
1746
- }
1747
- },
1748
- [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
1749
- );
1750
-
1751
- const handleTimelineElementDelete = useCallback(
1752
- async (element: TimelineElement) => {
1753
- const pid = projectIdRef.current;
1754
- if (!pid) throw new Error("No active project");
1755
-
1756
- const targetPath = element.sourceFile || activeCompPath || "index.html";
1757
- try {
1758
- const response = await fetch(
1759
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1760
- );
1761
- if (!response.ok) {
1762
- throw new Error(`Failed to read ${targetPath}`);
1763
- }
1764
-
1765
- const data = (await response.json()) as { content?: string };
1766
- const originalContent = data.content;
1767
- if (typeof originalContent !== "string") {
1768
- throw new Error(`Missing file contents for ${targetPath}`);
1769
- }
1770
-
1771
- const patchTarget = element.domId
1772
- ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
1773
- : element.selector
1774
- ? { selector: element.selector, selectorIndex: element.selectorIndex }
1775
- : null;
1776
- if (!patchTarget) {
1777
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
1778
- }
1779
-
1780
- const resolvedTargetPath = targetPath || "index.html";
1781
- const remainingElements = timelineElements.filter(
1782
- (timelineElement) =>
1783
- (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id) &&
1784
- (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
1785
- );
1786
- const trackZIndices = buildTrackZIndexMap(
1787
- remainingElements.map((timelineElement) => timelineElement.track),
1788
- );
1789
-
1790
- const removeResponse = await fetch(
1791
- `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
1792
- {
1793
- method: "POST",
1794
- headers: { "Content-Type": "application/json" },
1795
- body: JSON.stringify({ target: patchTarget }),
1796
- },
1797
- );
1798
- if (!removeResponse.ok) {
1799
- throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
1800
- }
1801
-
1802
- const removeData = (await removeResponse.json()) as {
1803
- changed?: boolean;
1804
- content?: string;
1805
- };
1806
- let patchedContent =
1807
- typeof removeData.content === "string" ? removeData.content : originalContent;
1808
- for (const timelineElement of remainingElements) {
1809
- const elementTarget = timelineElement.domId
1810
- ? {
1811
- id: timelineElement.domId,
1812
- selector: timelineElement.selector,
1813
- selectorIndex: timelineElement.selectorIndex,
1814
- }
1815
- : timelineElement.selector
1816
- ? {
1817
- selector: timelineElement.selector,
1818
- selectorIndex: timelineElement.selectorIndex,
1819
- }
1820
- : null;
1821
- if (!elementTarget) continue;
1822
- const nextZIndex = trackZIndices.get(timelineElement.track);
1823
- if (nextZIndex == null) continue;
1824
- patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
1825
- type: "inline-style",
1826
- property: "z-index",
1827
- value: String(nextZIndex),
1828
- });
1829
- }
1830
-
1831
- await saveProjectFilesWithHistory({
1832
- projectId: pid,
1833
- label: "Delete timeline clip",
1834
- kind: "timeline",
1835
- files: { [targetPath]: patchedContent },
1836
- readFile: async () => originalContent,
1837
- writeFile: writeProjectFile,
1838
- recordEdit: editHistory.recordEdit,
1839
- });
1840
-
1841
- usePlayerStore
1842
- .getState()
1843
- .setElements(
1844
- timelineElements.filter(
1845
- (timelineElement) =>
1846
- (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
1847
- ),
1848
- );
1849
- usePlayerStore.getState().setSelectedElementId(null);
1850
- setRefreshKey((k) => k + 1);
1851
- } catch (error) {
1852
- const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
1853
- showToast(message);
1854
- }
1855
- },
1856
- [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1857
- );
1858
-
1859
- const handleBlockedTimelineEdit = useCallback(
1860
- (_element: TimelineElement) => {
1861
- const now = Date.now();
1862
- if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
1863
- lastBlockedTimelineToastAtRef.current = now;
1864
- showToast("This clip can’t be moved or resized from the timeline yet.", "info");
1865
- },
1866
- [showToast],
1867
- );
1868
-
1869
- const handleBlockedDomMove = useCallback(
1870
- (selection: DomEditSelection) => {
1871
- const now = Date.now();
1872
- if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1873
- lastBlockedDomMoveToastAtRef.current = now;
1874
- showToast(
1875
- selection.capabilities.reasonIfDisabled ??
1876
- "This element can’t be adjusted directly from the preview.",
1877
- "info",
1878
- );
1879
- },
1880
- [showToast],
1881
- );
1882
-
1883
- const applyDomSelection = useCallback(
1884
- (
1885
- selection: DomEditSelection | null,
1886
- options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1887
- ) => {
1888
- setAgentPromptTagSnippet(undefined);
1889
- setAgentPromptSelectionContext(undefined);
1890
- setAgentModalAnchorPoint(null);
1891
- setCopiedAgentPrompt(false);
1892
- if (!selection) {
1893
- domEditSelectionRef.current = null;
1894
- domEditGroupSelectionsRef.current = [];
1895
- setDomEditSelection(null);
1896
- setDomEditGroupSelections([]);
1897
- setSelectedTimelineElementId(null);
1898
- return;
1899
- }
1900
- if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
1901
- domEditSelectionRef.current = null;
1902
- domEditGroupSelectionsRef.current = [];
1903
- setDomEditSelection(null);
1904
- setDomEditGroupSelections([]);
1905
- setSelectedTimelineElementId(null);
1906
- return;
1907
- }
1908
-
1909
- const isAdditiveSelection = Boolean(options?.additive);
1910
- const currentSelection = domEditSelectionRef.current;
1911
- const previousGroup = domEditGroupSelectionsRef.current;
1912
- const currentGroup = isAdditiveSelection
1913
- ? seedDomEditGroupWithSelection(previousGroup, currentSelection)
1914
- : previousGroup;
1915
- const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
1916
- const nextGroup = options?.preserveGroup
1917
- ? replaceDomEditGroupSelection(currentGroup, selection)
1918
- : isAdditiveSelection
1919
- ? toggleDomEditGroupSelection(currentGroup, selection)
1920
- : [selection];
1921
- const nextSelection = options?.preserveGroup
1922
- ? selection
1923
- : isAdditiveSelection && wasInGroup
1924
- ? domEditSelectionsTargetSame(currentSelection, selection)
1925
- ? (nextGroup[0] ?? null)
1926
- : domEditSelectionInGroup(nextGroup, currentSelection)
1927
- ? currentSelection
1928
- : (nextGroup[0] ?? null)
1929
- : selection;
1930
-
1931
- domEditSelectionRef.current = nextSelection;
1932
- domEditGroupSelectionsRef.current = nextGroup;
1933
- setDomEditSelection(nextSelection);
1934
- setDomEditGroupSelections(nextGroup);
1935
-
1936
- if (nextSelection) {
1937
- if (options?.revealPanel !== false) {
1938
- setRightCollapsed(false);
1939
- setRightPanelTab("design");
1940
- }
1941
- const nextSelectedTimelineId = findMatchingTimelineElementId(
1942
- nextSelection,
1943
- timelineElements,
1944
- );
1945
- setSelectedTimelineElementId(nextSelectedTimelineId);
1946
- return;
1947
- }
1948
-
1949
- setSelectedTimelineElementId(null);
1950
- },
1951
- [setSelectedTimelineElementId, timelineElements],
1952
- );
1953
-
1954
- const clearDomSelection = useCallback(() => {
1955
- applyDomSelection(null, { revealPanel: false });
1956
- }, [applyDomSelection]);
1957
-
1958
- const readHistoryProjectFile = useCallback(
1959
- async (path: string): Promise<string> => {
1960
- return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
1961
- ? readOptionalProjectFile(path)
1962
- : readProjectFile(path);
1963
- },
1964
- [readOptionalProjectFile, readProjectFile],
1965
- );
1966
-
1967
- const writeHistoryProjectFile = useCallback(
1968
- async (path: string, content: string): Promise<void> => {
1969
- await writeProjectFile(path, content);
1970
- if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
1971
- domEditSaveTimestampRef.current = Date.now();
1972
- }
1973
- },
1974
- [writeProjectFile],
1975
- );
1976
-
1977
- const applyCurrentStudioManualEditsToPreview = useCallback(
1978
- (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
1979
- if (!iframe) return;
1980
- let doc: Document | null = null;
1981
- try {
1982
- doc = iframe.contentDocument;
1983
- } catch {
1984
- return;
1985
- }
1986
- if (!doc) return;
1987
- const previewDoc = doc;
1988
-
1989
- const applyManifest = () => {
1990
- applyStudioManualEditManifest(
1991
- previewDoc,
1992
- studioManualEditManifestRef.current,
1993
- activeCompPathRef.current,
1994
- );
1995
- };
1996
- const applyAndInstallSeekHooks = () => {
1997
- applyManifest();
1998
- if (iframe.contentWindow) {
1999
- installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
2000
- }
2001
- };
2002
-
2003
- const win = iframe.contentWindow;
2004
- applyAndInstallSeekHooks();
2005
- win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
2006
- win?.setTimeout?.(applyAndInstallSeekHooks, 80);
2007
- win?.setTimeout?.(applyAndInstallSeekHooks, 250);
2008
- win?.setTimeout?.(applyAndInstallSeekHooks, 500);
2009
- win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
2010
- win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
2011
- },
2012
- [],
2013
- );
2014
-
2015
- const applyStudioManualEditsToPreview = useCallback(
2016
- async (
2017
- iframe: HTMLIFrameElement | null = previewIframeRef.current,
2018
- options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
2019
- ) => {
2020
- const readRevision = studioManualEditRevisionRef.current;
2021
- const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
2022
- if (!readFromDiskFirst) {
2023
- applyCurrentStudioManualEditsToPreview(iframe);
2024
- }
2025
- let content: string;
2026
- try {
2027
- content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
2028
- } catch (error) {
2029
- const message =
2030
- error instanceof Error ? error.message : "Failed to read manual edit manifest";
2031
- showToast(message);
2032
- if (readFromDiskFirst) {
2033
- applyCurrentStudioManualEditsToPreview(iframe);
2034
- }
2035
- return;
2036
- }
2037
- if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
2038
- studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
2039
- if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
2040
- applyCurrentStudioManualEditsToPreview(iframe);
2041
- return;
2042
- }
2043
- if (readFromDiskFirst) {
2044
- applyCurrentStudioManualEditsToPreview(iframe);
2045
- }
2046
- },
2047
- [applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
2048
- );
2049
- applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
2050
-
2051
- const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
2052
- (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
2053
- applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
2054
- [applyStudioManualEditsToPreview],
2055
- );
2056
-
2057
- const applyCurrentStudioMotionToPreview = useCallback(
2058
- (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
2059
- if (!iframe) return;
2060
- let doc: Document | null = null;
2061
- try {
2062
- doc = iframe.contentDocument;
2063
- } catch {
2064
- return;
2065
- }
2066
- if (!doc) return;
2067
- const previewDoc = doc;
2068
-
2069
- const applyManifest = () => {
2070
- applyStudioMotionManifest(
2071
- previewDoc,
2072
- studioMotionManifestRef.current,
2073
- activeCompPathRef.current,
2074
- );
2075
- };
2076
- const applyAndInstallSeekHooks = () => {
2077
- applyManifest();
2078
- if (iframe.contentWindow) {
2079
- installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
2080
- }
2081
- };
2082
-
2083
- const win = iframe.contentWindow;
2084
- win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
2085
- win?.setTimeout?.(applyAndInstallSeekHooks, 120);
2086
- },
2087
- [],
2088
- );
2089
-
2090
- const applyStudioMotionToPreview = useCallback(
2091
- async (
2092
- iframe: HTMLIFrameElement | null = previewIframeRef.current,
2093
- options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
2094
- ) => {
2095
- const readRevision = studioMotionRevisionRef.current;
2096
- const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
2097
- if (!readFromDiskFirst) {
2098
- applyCurrentStudioMotionToPreview(iframe);
2099
- }
2100
- let content: string;
2101
- try {
2102
- content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
2103
- } catch (error) {
2104
- const message = error instanceof Error ? error.message : "Failed to read motion manifest";
2105
- showToast(message);
2106
- if (readFromDiskFirst) {
2107
- applyCurrentStudioMotionToPreview(iframe);
2108
- }
2109
- return;
2110
- }
2111
- if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
2112
- studioMotionManifestRef.current = parseStudioMotionManifest(content);
2113
- if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
2114
- setStudioMotionRevision((revision) => revision + 1);
2115
- applyCurrentStudioMotionToPreview(iframe);
2116
- return;
2117
- }
2118
- if (readFromDiskFirst) {
2119
- applyCurrentStudioMotionToPreview(iframe);
2120
- }
2121
- },
2122
- [applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
2123
- );
2124
- applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
2125
-
2126
- const applyStudioMotionToPreviewAfterRefresh = useCallback(
2127
- (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
2128
- applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
2129
- [applyStudioMotionToPreview],
2130
- );
2131
-
2132
- const commitStudioManualEditManifestOptimistically = useCallback(
2133
- (
2134
- updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
2135
- options: { label: string; coalesceKey: string },
2136
- ) => {
2137
- const previousManifest = studioManualEditManifestRef.current;
2138
- const nextManifest = updateManifest(previousManifest);
2139
- const previousContent = serializeStudioManualEditManifest(previousManifest);
2140
- const nextContent = serializeStudioManualEditManifest(nextManifest);
2141
- if (nextContent === previousContent) {
2142
- return;
2143
- }
2144
-
2145
- const revision = studioManualEditRevisionRef.current + 1;
2146
- studioManualEditRevisionRef.current = revision;
2147
- studioManualEditManifestRef.current = nextManifest;
2148
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2149
-
2150
- const save = async () => {
2151
- const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
2152
- const diskManifest = parseStudioManualEditManifest(originalContent);
2153
- const nextDiskManifest = updateManifest(diskManifest);
2154
- const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
2155
- if (nextDiskContent === originalContent) {
2156
- return;
2157
- }
2158
-
2159
- const pid = projectIdRef.current;
2160
- if (!pid) throw new Error("No active project");
2161
- domEditSaveTimestampRef.current = Date.now();
2162
- await saveProjectFilesWithHistory({
2163
- projectId: pid,
2164
- label: options.label,
2165
- kind: "manual",
2166
- coalesceKey: options.coalesceKey,
2167
- files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
2168
- readFile: async () => originalContent,
2169
- writeFile: writeProjectFile,
2170
- recordEdit: editHistory.recordEdit,
2171
- });
2172
- domEditSaveTimestampRef.current = Date.now();
2173
-
2174
- if (studioManualEditRevisionRef.current === revision) {
2175
- studioManualEditManifestRef.current = nextDiskManifest;
2176
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2177
- }
2178
- };
2179
-
2180
- void queueDomEditSave(save).catch((error) => {
2181
- if (studioManualEditRevisionRef.current === revision) {
2182
- studioManualEditRevisionRef.current += 1;
2183
- studioManualEditManifestRef.current = previousManifest;
2184
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2185
- }
2186
- const message = error instanceof Error ? error.message : "Failed to save manual edit";
2187
- showToast(message);
2188
- });
2189
- },
2190
- [
2191
- applyCurrentStudioManualEditsToPreview,
2192
- editHistory.recordEdit,
2193
- queueDomEditSave,
2194
- readOptionalProjectFile,
2195
- showToast,
2196
- writeProjectFile,
2197
- ],
2198
- );
2199
-
2200
- const commitStudioMotionManifestOptimistically = useCallback(
2201
- (
2202
- updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
2203
- options: { label: string; coalesceKey: string },
2204
- ) => {
2205
- const previousManifest = studioMotionManifestRef.current;
2206
- const nextManifest = updateManifest(previousManifest);
2207
- const previousContent = serializeStudioMotionManifest(previousManifest);
2208
- const nextContent = serializeStudioMotionManifest(nextManifest);
2209
- if (nextContent === previousContent) {
2210
- return;
2211
- }
2212
-
2213
- const revision = studioMotionRevisionRef.current + 1;
2214
- studioMotionRevisionRef.current = revision;
2215
- studioMotionManifestRef.current = nextManifest;
2216
- setStudioMotionRevision((current) => current + 1);
2217
- applyCurrentStudioMotionToPreview(previewIframeRef.current);
2218
-
2219
- const save = async () => {
2220
- const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
2221
- const diskManifest = parseStudioMotionManifest(originalContent);
2222
- const nextDiskManifest = updateManifest(diskManifest);
2223
- const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
2224
- if (nextDiskContent === originalContent) {
2225
- return;
2226
- }
2227
-
2228
- const pid = projectIdRef.current;
2229
- if (!pid) throw new Error("No active project");
2230
- domEditSaveTimestampRef.current = Date.now();
2231
- await saveProjectFilesWithHistory({
2232
- projectId: pid,
2233
- label: options.label,
2234
- kind: "motion",
2235
- coalesceKey: options.coalesceKey,
2236
- files: { [STUDIO_MOTION_PATH]: nextDiskContent },
2237
- readFile: async () => originalContent,
2238
- writeFile: writeProjectFile,
2239
- recordEdit: editHistory.recordEdit,
2240
- });
2241
- domEditSaveTimestampRef.current = Date.now();
2242
-
2243
- if (studioMotionRevisionRef.current === revision) {
2244
- studioMotionManifestRef.current = nextDiskManifest;
2245
- setStudioMotionRevision((current) => current + 1);
2246
- applyCurrentStudioMotionToPreview(previewIframeRef.current);
2247
- }
2248
- };
2249
-
2250
- void queueDomEditSave(save).catch((error) => {
2251
- if (studioMotionRevisionRef.current === revision) {
2252
- studioMotionRevisionRef.current += 1;
2253
- studioMotionManifestRef.current = previousManifest;
2254
- setStudioMotionRevision((current) => current + 1);
2255
- applyCurrentStudioMotionToPreview(previewIframeRef.current);
2256
- }
2257
- const message = error instanceof Error ? error.message : "Failed to save motion edit";
2258
- showToast(message);
2259
- });
2260
- },
2261
- [
2262
- applyCurrentStudioMotionToPreview,
2263
- editHistory.recordEdit,
2264
- queueDomEditSave,
2265
- readOptionalProjectFile,
2266
- showToast,
2267
- writeProjectFile,
2268
- ],
2269
- );
2270
-
2271
- const syncHistoryPreviewAfterApply = useCallback(
2272
- async (paths: string[] | undefined) => {
2273
- const changedPaths = paths ?? [];
2274
- const manualManifestOnly =
2275
- changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
2276
- const motionManifestOnly =
2277
- changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
2278
-
2279
- if (manualManifestOnly) {
2280
- await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
2281
- return;
2282
- }
2283
- if (motionManifestOnly) {
2284
- await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
2285
- return;
2286
- }
2287
-
2288
- setRefreshKey((key) => key + 1);
2289
- },
2290
- [applyStudioManualEditsToPreview, applyStudioMotionToPreview],
2291
- );
2292
-
2293
- const handleUndo = useCallback(async () => {
2294
- await waitForPendingDomEditSaves();
2295
- const result = await editHistory.undo({
2296
- readFile: readHistoryProjectFile,
2297
- writeFile: writeHistoryProjectFile,
2298
- });
2299
- if (!result.ok && result.reason === "content-mismatch") {
2300
- showToast("File changed outside Studio. Undo history was not applied.", "info");
2301
- return;
2302
- }
2303
- if (result.ok && result.label) {
2304
- clearDomSelection();
2305
- await syncHistoryPreviewAfterApply(result.paths);
2306
- showToast(`Undid ${result.label}`, "info");
2307
- }
2308
- }, [
2309
- clearDomSelection,
2310
- editHistory,
2311
- readHistoryProjectFile,
2312
- showToast,
2313
- syncHistoryPreviewAfterApply,
2314
- waitForPendingDomEditSaves,
2315
- writeHistoryProjectFile,
2316
- ]);
2317
-
2318
- const handleRedo = useCallback(async () => {
2319
- await waitForPendingDomEditSaves();
2320
- const result = await editHistory.redo({
2321
- readFile: readHistoryProjectFile,
2322
- writeFile: writeHistoryProjectFile,
2323
- });
2324
- if (!result.ok && result.reason === "content-mismatch") {
2325
- showToast("File changed outside Studio. Redo history was not applied.", "info");
2326
- return;
2327
- }
2328
- if (result.ok && result.label) {
2329
- clearDomSelection();
2330
- await syncHistoryPreviewAfterApply(result.paths);
2331
- showToast(`Redid ${result.label}`, "info");
2332
- }
2333
- }, [
2334
- clearDomSelection,
2335
- editHistory,
2336
- readHistoryProjectFile,
2337
- showToast,
2338
- syncHistoryPreviewAfterApply,
2339
- waitForPendingDomEditSaves,
2340
- writeHistoryProjectFile,
2341
- ]);
2342
-
2343
- const handleUndoRef = useRef(handleUndo);
2344
- const handleRedoRef = useRef(handleRedo);
2345
- handleUndoRef.current = handleUndo;
2346
- handleRedoRef.current = handleRedo;
2347
-
2348
- const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
2349
- if (!(event.metaKey || event.ctrlKey)) return;
2350
- if (shouldIgnoreHistoryShortcut(event.target)) return;
2351
- const key = event.key.toLowerCase();
2352
- if (key === "z" && !event.shiftKey) {
2353
- event.preventDefault();
2354
- void handleUndoRef.current();
2355
- return;
2356
- }
2357
- if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
2358
- event.preventDefault();
2359
- void handleRedoRef.current();
2360
- }
2361
- }, []);
2362
-
2363
- // eslint-disable-next-line no-restricted-syntax
2364
- useEffect(() => {
2365
- window.addEventListener("keydown", handleHistoryHotkey, true);
2366
- return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
2367
- }, [handleHistoryHotkey]);
2368
-
2369
- const syncPreviewHistoryHotkey = useCallback(
2370
- (iframe: HTMLIFrameElement | null) => {
2371
- previewHistoryHotkeyCleanupRef.current?.();
2372
- previewHistoryHotkeyCleanupRef.current = null;
2373
-
2374
- const win = iframe?.contentWindow ?? null;
2375
- let doc: Document | null = null;
2376
- try {
2377
- doc = iframe?.contentDocument ?? null;
2378
- } catch {
2379
- doc = null;
2380
- }
2381
- if (!win && !doc) return;
2382
-
2383
- win?.addEventListener("keydown", handleHistoryHotkey, true);
2384
- doc?.addEventListener("keydown", handleHistoryHotkey, true);
2385
- previewHistoryHotkeyCleanupRef.current = () => {
2386
- win?.removeEventListener("keydown", handleHistoryHotkey, true);
2387
- doc?.removeEventListener("keydown", handleHistoryHotkey, true);
2388
- };
2389
- },
2390
- [handleHistoryHotkey],
2391
- );
2392
-
2393
- useEffect(
2394
- () => () => {
2395
- previewHistoryHotkeyCleanupRef.current?.();
2396
- previewHistoryHotkeyCleanupRef.current = null;
2397
- },
2398
- [],
2399
- );
2400
-
2401
- const buildDomSelectionFromTarget = useCallback(
2402
- (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
2403
- return resolveDomEditSelection(target, {
2404
- activeCompositionPath: activeCompPath,
2405
- isMasterView,
2406
- preferClipAncestor: options?.preferClipAncestor,
2407
- });
2408
- },
2409
- [activeCompPath, isMasterView],
2410
- );
2411
-
2412
- const resolveDomSelectionFromPreviewPoint = useCallback(
2413
- (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2414
- const iframe = previewIframeRef.current;
2415
- if (!iframe || captionEditMode) return null;
2416
- const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2417
- if (!target) return null;
2418
- return buildDomSelectionFromTarget(target, {
2419
- preferClipAncestor: options?.preferClipAncestor,
2420
- });
2421
- },
2422
- [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2423
- );
2424
-
2425
- const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
2426
- if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
2427
- domEditHoverSelectionRef.current = selection;
2428
- setDomEditHoverSelection(selection);
2429
- }, []);
2430
-
2431
- const buildDomSelectionForTimelineElement = useCallback(
2432
- (element: TimelineElement): DomEditSelection | null => {
2433
- const iframe = previewIframeRef.current;
2434
- let doc: Document | null = null;
2435
- try {
2436
- doc = iframe?.contentDocument ?? null;
2437
- } catch {
2438
- return null;
2439
- }
2440
- if (!doc) return null;
2441
-
2442
- const targetElement = findElementForTimelineElement(doc, element, {
2443
- activeCompositionPath: activeCompPath,
2444
- compIdToSrc,
2445
- isMasterView,
2446
- });
2447
- return targetElement
2448
- ? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
2449
- : null;
2450
- },
2451
- [activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
2452
- );
2453
-
2454
- const inspectedTimelineElement = useMemo(
2455
- () =>
2456
- timelineElements.find(
2457
- (element) => getTimelineElementKey(element) === inspectedTimelineElementId,
2458
- ) ?? null,
2459
- [inspectedTimelineElementId, timelineElements],
2460
- );
2461
-
2462
- const timelineLayerChildCounts = useMemo(() => {
2463
- void previewDocumentVersion;
2464
- const counts = new Map<string, number>();
2465
- if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
2466
-
2467
- const key = getTimelineElementKey(inspectedTimelineElement);
2468
- if (key) {
2469
- const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2470
- const count = countDomEditChildLayers(selection?.element, {
2471
- activeCompositionPath: activeCompPath,
2472
- isMasterView,
2473
- });
2474
- if (count > 0) counts.set(key, count);
2475
- }
2476
-
2477
- return counts;
2478
- }, [
2479
- activeCompPath,
2480
- buildDomSelectionForTimelineElement,
2481
- inspectedTimelineElement,
2482
- isMasterView,
2483
- previewDocumentVersion,
2484
- ]);
2485
-
2486
- const inspectedTimelineLayers = useMemo(() => {
2487
- void previewDocumentVersion;
2488
- if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
2489
- const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2490
- return collectDomEditLayerItems(selection?.element, {
2491
- activeCompositionPath: activeCompPath,
2492
- isMasterView,
2493
- });
2494
- }, [
2495
- activeCompPath,
2496
- buildDomSelectionForTimelineElement,
2497
- inspectedTimelineElement,
2498
- isMasterView,
2499
- previewDocumentVersion,
2500
- ]);
2501
-
2502
- const selectedTimelineLayerKey = useMemo(
2503
- () => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
2504
- [domEditSelection],
2505
- );
2506
-
2507
- const handleTimelineElementSelect = useCallback(
2508
- (element: TimelineElement | null) => {
2509
- if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2510
- if (!element) {
2511
- applyDomSelection(null, { revealPanel: false });
2512
- setInspectedTimelineElementId(null);
2513
- return;
2514
- }
2515
-
2516
- const selection = buildDomSelectionForTimelineElement(element);
2517
- if (selection) applyDomSelection(selection);
2518
-
2519
- const key = getTimelineElementKey(element);
2520
- if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2521
- setInspectedTimelineElementId(key);
2522
- setLeftCollapsed(false);
2523
-
2524
- const iframe = previewIframeRef.current;
2525
- if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2526
- seekStudioPreview(iframe, element.start);
2527
- }
2528
- } else {
2529
- setInspectedTimelineElementId(null);
2530
- }
2531
- },
2532
- [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2533
- );
2534
-
2535
- const handleTimelineElementInspect = useCallback(
2536
- (element: TimelineElement) => {
2537
- if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
2538
- if (!canInspectTimelineElement(element)) {
2539
- showToast("Audio clips do not have visual layers.", "info");
2540
- return;
2541
- }
2542
-
2543
- const key = getTimelineElementKey(element);
2544
- if (!key) return;
2545
- setInspectedTimelineElementId((current) => (current === key ? null : key));
2546
- setLeftCollapsed(false);
2547
-
2548
- const iframe = previewIframeRef.current;
2549
- if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2550
- seekStudioPreview(iframe, element.start);
2551
- }
2552
-
2553
- const selection = buildDomSelectionForTimelineElement(element);
2554
- if (selection) applyDomSelection(selection);
2555
- },
2556
- [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2557
- );
2558
-
2559
- const handleTimelineLayerSelect = useCallback(
2560
- (layer: DomEditLayerItem) => {
2561
- if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2562
-
2563
- const iframe = previewIframeRef.current;
2564
- const player = getPreviewPlayer(iframe?.contentWindow);
2565
- const visibleTime = resolveLayerVisibleSeekTime(
2566
- layer.element,
2567
- inspectedTimelineElement,
2568
- player,
2569
- );
2570
- if (visibleTime != null) {
2571
- seekStudioPreview(iframe, visibleTime);
2572
- }
2573
-
2574
- const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
2575
- if (!selection) {
2576
- showToast("Studio could not resolve this nested layer.", "error");
2577
- return;
2578
- }
2579
-
2580
- applyDomSelection(selection);
2581
- requestAnimationFrame(refreshPreviewDocumentVersion);
2582
- },
2583
- [
2584
- applyDomSelection,
2585
- buildDomSelectionFromTarget,
2586
- inspectedTimelineElement,
2587
- refreshPreviewDocumentVersion,
2588
- showToast,
2589
- ],
2590
- );
2591
-
2592
- const handleTimelineLayerPanelClose = useCallback(() => {
2593
- setInspectedTimelineElementId(null);
2594
- }, []);
2595
-
2596
- const preloadAgentPromptSnippet = useCallback(
2597
- async (selection: DomEditSelection) => {
2598
- const pid = projectIdRef.current;
2599
- if (!pid) return;
2600
-
2601
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
2602
- try {
2603
- const response = await fetch(
2604
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
2605
- );
2606
- if (!response.ok) return;
2607
-
2608
- const data = (await response.json()) as { content?: string };
2609
- const html = data.content;
2610
- const tagSnippet =
2611
- typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
2612
-
2613
- setAgentPromptTagSnippet((current) => {
2614
- if (domEditSelectionRef.current !== selection) return current;
2615
- return tagSnippet;
2616
- });
2617
- } catch {
2618
- // Runtime outerHTML is still available as a synchronous copy fallback.
2619
- }
2620
- },
2621
- [activeCompPath],
2622
- );
2623
-
2624
- const resolveImportedFontAsset = useCallback(
2625
- (fontFamilyValue: string): ImportedFontAsset | null => {
2626
- const family = primaryFontFamilyValue(fontFamilyValue);
2627
- if (!family) return null;
2628
- const imported = importedFontAssetsRef.current.find(
2629
- (font) => font.family.toLowerCase() === family.toLowerCase(),
2630
- );
2631
- if (imported) return imported;
2632
- const asset = fileTree.find(
2633
- (path) =>
2634
- FONT_EXT.test(path) &&
2635
- fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
2636
- );
2637
- if (!asset) return null;
2638
- return {
2639
- family: fontFamilyFromAssetPath(asset),
2640
- path: asset,
2641
- url: `/api/projects/${projectId}/preview/${asset}`,
2642
- };
2643
- },
2644
- [fileTree, projectId],
2645
- );
2646
-
2647
- const persistDomEditOperations = useCallback(
2648
- async (
2649
- selection: DomEditSelection,
2650
- operations: Parameters<typeof applyPatchByTarget>[2][],
2651
- options?: {
2652
- label?: string;
2653
- coalesceKey?: string;
2654
- skipRefresh?: boolean;
2655
- prepareContent?: (html: string, sourceFile: string) => string;
2656
- shouldSave?: () => boolean;
2657
- },
2658
- ) => {
2659
- const pid = projectIdRef.current;
2660
- if (!pid) throw new Error("No active project");
2661
- if (options?.shouldSave && !options.shouldSave()) return;
2662
-
2663
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
2664
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
2665
- if (!response.ok) {
2666
- throw new Error(`Failed to read ${targetPath}`);
2667
- }
2668
-
2669
- const data = (await response.json()) as { content?: string };
2670
- const originalContent = data.content;
2671
- if (typeof originalContent !== "string") {
2672
- throw new Error(`Missing file contents for ${targetPath}`);
2673
- }
2674
-
2675
- let patchedContent = originalContent;
2676
- for (const operation of operations) {
2677
- patchedContent = applyPatchByTarget(patchedContent, selection, operation);
2678
- }
2679
- if (options?.prepareContent) {
2680
- patchedContent = options.prepareContent(patchedContent, targetPath);
2681
- }
2682
- if (options?.shouldSave && !options.shouldSave()) return;
2683
-
2684
- if (patchedContent === originalContent) {
2685
- throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
2686
- }
2687
-
2688
- await saveProjectFilesWithHistory({
2689
- projectId: pid,
2690
- label: options?.label ?? "Edit layer",
2691
- kind: "manual",
2692
- coalesceKey: options?.coalesceKey,
2693
- files: { [targetPath]: patchedContent },
2694
- readFile: async () => originalContent,
2695
- writeFile: writeProjectFile,
2696
- recordEdit: editHistory.recordEdit,
2697
- });
2698
-
2699
- if (options?.skipRefresh) {
2700
- domEditSaveTimestampRef.current = Date.now();
2701
- } else {
2702
- setRefreshKey((k) => k + 1);
2703
- }
2704
- },
2705
- [activeCompPath, editHistory.recordEdit, writeProjectFile],
2706
- );
2707
-
2708
- const refreshDomEditSelectionFromPreview = useCallback(
2709
- (selection: DomEditSelection) => {
2710
- const iframe = previewIframeRef.current;
2711
- let doc: Document | null = null;
2712
- try {
2713
- doc = iframe?.contentDocument ?? null;
2714
- } catch {
2715
- return;
2716
- }
2717
- if (!doc) return;
2718
-
2719
- const element = findElementForSelection(doc, selection, activeCompPath);
2720
- if (!element) return;
2721
-
2722
- const nextSelection = buildDomSelectionFromTarget(element);
2723
- if (nextSelection) {
2724
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2725
- }
2726
- },
2727
- [activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
2728
- );
2729
-
2730
- const refreshDomEditGroupSelectionsFromPreview = useCallback(
2731
- (selections: DomEditSelection[]) => {
2732
- const iframe = previewIframeRef.current;
2733
- let doc: Document | null = null;
2734
- try {
2735
- doc = iframe?.contentDocument ?? null;
2736
- } catch {
2737
- return;
2738
- }
2739
- if (!doc) return;
2740
-
2741
- const nextGroup: DomEditSelection[] = [];
2742
- for (const selection of selections) {
2743
- const element = findElementForSelection(doc, selection, activeCompPath);
2744
- if (!element) continue;
2745
- const nextSelection = buildDomSelectionFromTarget(element);
2746
- if (nextSelection) nextGroup.push(nextSelection);
2747
- }
2748
- if (nextGroup.length === 0) return;
2749
-
2750
- const currentSelection = domEditSelectionRef.current;
2751
- const nextSelection =
2752
- nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
2753
- nextGroup[0] ??
2754
- null;
2755
-
2756
- setAgentPromptTagSnippet(undefined);
2757
- setCopiedAgentPrompt(false);
2758
- domEditSelectionRef.current = nextSelection;
2759
- domEditGroupSelectionsRef.current = nextGroup;
2760
- setDomEditSelection(nextSelection);
2761
- setDomEditGroupSelections(nextGroup);
2762
-
2763
- if (nextSelection) {
2764
- setSelectedTimelineElementId(
2765
- findMatchingTimelineElementId(nextSelection, timelineElements),
2766
- );
2767
- } else {
2768
- setSelectedTimelineElementId(null);
2769
- }
2770
- },
2771
- [activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
2772
- );
2773
-
2774
- const handleDomManualDragStart = useCallback(() => {
2775
- const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
2776
- const playerStore = usePlayerStore.getState();
2777
- playerStore.setIsPlaying(false);
2778
- if (pausedTime != null) {
2779
- playerStore.setCurrentTime(pausedTime);
2780
- liveTime.notify(pausedTime);
2781
- }
2782
- }, []);
2783
-
2784
- const handleDomPathOffsetCommit = useCallback(
2785
- (selection: DomEditSelection, next: { x: number; y: number }) => {
2786
- commitStudioManualEditManifestOptimistically(
2787
- (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
2788
- {
2789
- label: "Move layer",
2790
- coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
2791
- },
2792
- );
2793
- refreshDomEditSelectionFromPreview(selection);
2794
- },
2795
- [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2796
- );
2797
-
2798
- const handleDomGroupPathOffsetCommit = useCallback(
2799
- (updates: DomEditGroupPathOffsetCommit[]) => {
2800
- if (updates.length === 0) return;
2801
- const coalesceKey = updates
2802
- .map((update) => getDomEditTargetKey(update.selection))
2803
- .sort()
2804
- .join(":");
2805
- commitStudioManualEditManifestOptimistically(
2806
- (manifest) =>
2807
- updates.reduce(
2808
- (nextManifest, update) =>
2809
- upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
2810
- manifest,
2811
- ),
2812
- {
2813
- label: `Move ${updates.length} layers`,
2814
- coalesceKey: `group-path-offset:${coalesceKey}`,
2815
- },
2816
- );
2817
- refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
2818
- },
2819
- [commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
2820
- );
2821
-
2822
- const handleDomBoxSizeCommit = useCallback(
2823
- (selection: DomEditSelection, next: { width: number; height: number }) => {
2824
- commitStudioManualEditManifestOptimistically(
2825
- (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
2826
- {
2827
- label: "Resize layer box",
2828
- coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
2829
- },
2830
- );
2831
- refreshDomEditSelectionFromPreview(selection);
2832
- },
2833
- [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2834
- );
2835
-
2836
- const handleDomRotationCommit = useCallback(
2837
- (selection: DomEditSelection, next: { angle: number }) => {
2838
- commitStudioManualEditManifestOptimistically(
2839
- (manifest) => upsertStudioRotationEdit(manifest, selection, next),
2840
- {
2841
- label: "Rotate layer",
2842
- coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
2843
- },
2844
- );
2845
- refreshDomEditSelectionFromPreview(selection);
2846
- },
2847
- [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2848
- );
2849
-
2850
- const handleDomManualEditsReset = useCallback(
2851
- (selection: DomEditSelection) => {
2852
- commitStudioManualEditManifestOptimistically(
2853
- (manifest) => removeStudioManualEditsForSelection(manifest, selection),
2854
- {
2855
- label: "Reset layer edits",
2856
- coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
2857
- },
2858
- );
2859
- applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2860
- refreshDomEditSelectionFromPreview(selection);
2861
- },
2862
- [
2863
- applyCurrentStudioManualEditsToPreview,
2864
- commitStudioManualEditManifestOptimistically,
2865
- refreshDomEditSelectionFromPreview,
2866
- ],
2867
- );
2868
-
2869
- const handleDomMotionCommit = useCallback(
2870
- (
2871
- selection: DomEditSelection,
2872
- motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
2873
- ) => {
2874
- commitStudioMotionManifestOptimistically(
2875
- (manifest) => upsertStudioGsapMotion(manifest, selection, motion),
2876
- {
2877
- label: "Set GSAP motion",
2878
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
2879
- },
2880
- );
2881
- refreshDomEditSelectionFromPreview(selection);
2882
- },
2883
- [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
2884
- );
2885
-
2886
- const handleDomMotionClear = useCallback(
2887
- (selection: DomEditSelection) => {
2888
- commitStudioMotionManifestOptimistically(
2889
- (manifest) => removeStudioMotionForSelection(manifest, selection),
2890
- {
2891
- label: "Clear GSAP motion",
2892
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
2893
- },
2894
- );
2895
- applyCurrentStudioMotionToPreview(previewIframeRef.current);
2896
- refreshDomEditSelectionFromPreview(selection);
2897
- },
2898
- [
2899
- applyCurrentStudioMotionToPreview,
2900
- commitStudioMotionManifestOptimistically,
2901
- refreshDomEditSelectionFromPreview,
2902
- ],
2903
- );
2904
-
2905
- const handleDomStyleCommit = useCallback(
2906
- async (property: string, value: string) => {
2907
- if (!domEditSelection) return;
2908
- if (isManualGeometryStyleProperty(property)) return;
2909
- if (!domEditSelection.capabilities.canEditStyles) return;
2910
- const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
2911
- const iframe = previewIframeRef.current;
2912
- const doc = iframe?.contentDocument;
2913
- if (doc) {
2914
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
2915
- if (el) {
2916
- el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
2917
- if (property === "font-family") {
2918
- injectPreviewGoogleFont(doc, value);
2919
- if (importedFont) injectPreviewImportedFont(doc, importedFont);
2920
- }
2921
- if (property === "background-image" && isImageBackgroundValue(value)) {
2922
- el.style.setProperty("background-position", "center");
2923
- el.style.setProperty("background-repeat", "no-repeat");
2924
- el.style.setProperty("background-size", "contain");
2925
- }
2926
- }
2927
- }
2928
- const operations: PatchOperation[] = [
2929
- buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
2930
- ];
2931
- if (property === "background-image" && isImageBackgroundValue(value)) {
2932
- operations.push(
2933
- buildDomEditStylePatchOperation("background-position", "center"),
2934
- buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
2935
- buildDomEditStylePatchOperation("background-size", "contain"),
2936
- );
2937
- }
2938
- try {
2939
- await persistDomEditOperations(domEditSelection, operations, {
2940
- label: "Edit layer style",
2941
- skipRefresh: true,
2942
- prepareContent: importedFont
2943
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2944
- : undefined,
2945
- });
2946
- } catch (err) {
2947
- console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
2948
- }
2949
- refreshDomEditSelectionFromPreview(domEditSelection);
2950
- },
2951
- [
2952
- activeCompPath,
2953
- domEditSelection,
2954
- persistDomEditOperations,
2955
- refreshDomEditSelectionFromPreview,
2956
- resolveImportedFontAsset,
2957
- ],
2958
- );
2959
-
2960
- const handleDomTextCommit = useCallback(
2961
- async (value: string, fieldKey?: string) => {
2962
- if (!domEditSelection) return;
2963
- if (!isTextEditableSelection(domEditSelection)) return;
2964
- const commitVersion = domTextCommitVersionRef.current + 1;
2965
- domTextCommitVersionRef.current = commitVersion;
2966
- const nextTextFields =
2967
- domEditSelection.textFields.length > 0
2968
- ? domEditSelection.textFields.map((field) =>
2969
- field.key === fieldKey ? { ...field, value } : field,
2970
- )
2971
- : [];
2972
- const nextContent =
2973
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
2974
- ? serializeDomEditTextFields(nextTextFields)
2975
- : value;
2976
- const iframe = previewIframeRef.current;
2977
- const doc = iframe?.contentDocument;
2978
- if (doc) {
2979
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
2980
- if (el) {
2981
- if (
2982
- nextTextFields.length > 1 ||
2983
- nextTextFields.some((field) => field.source === "child")
2984
- ) {
2985
- el.innerHTML = nextContent;
2986
- } else {
2987
- el.textContent = value;
2988
- }
2989
- }
2990
- }
2991
- await persistDomEditOperations(
2992
- domEditSelection,
2993
- [buildDomEditTextPatchOperation(nextContent)],
2994
- {
2995
- label: "Edit text",
2996
- skipRefresh: true,
2997
- shouldSave: () => domTextCommitVersionRef.current === commitVersion,
2998
- },
2999
- );
3000
- if (domTextCommitVersionRef.current !== commitVersion) return;
3001
-
3002
- if (doc) {
3003
- const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
3004
- if (refreshed) {
3005
- const nextSelection = buildDomSelectionFromTarget(refreshed);
3006
- if (nextSelection) {
3007
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3008
- }
3009
- }
3010
- }
3011
- },
3012
- [
3013
- activeCompPath,
3014
- applyDomSelection,
3015
- buildDomSelectionFromTarget,
3016
- domEditSelection,
3017
- persistDomEditOperations,
3018
- ],
3019
- );
3020
-
3021
- const commitDomTextFields = useCallback(
3022
- async (
3023
- selection: DomEditSelection,
3024
- nextTextFields: DomEditTextField[],
3025
- options?: { importedFont?: ImportedFontAsset | null },
3026
- ) => {
3027
- const nextContent =
3028
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
3029
- ? serializeDomEditTextFields(nextTextFields)
3030
- : (nextTextFields[0]?.value ?? "");
5
+ import { usePlayerStore } from "./player";
6
+ import { LintModal } from "./components/LintModal";
7
+ import { useCaptionStore } from "./captions/store";
8
+ import { useCaptionSync } from "./captions/hooks/useCaptionSync";
9
+ import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
10
+ import { usePanelLayout } from "./hooks/usePanelLayout";
11
+ import { useFileManager } from "./hooks/useFileManager";
12
+ import { useManifestPersistence } from "./hooks/useManifestPersistence";
13
+ import { useTimelineEditing } from "./hooks/useTimelineEditing";
14
+ import { useDomEditSession } from "./hooks/useDomEditSession";
15
+ import { useAppHotkeys } from "./hooks/useAppHotkeys";
16
+ import { useCaptionDetection } from "./hooks/useCaptionDetection";
17
+ import { useRenderClipContent } from "./hooks/useRenderClipContent";
18
+ import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
19
+ import { useFrameCapture } from "./hooks/useFrameCapture";
20
+ import { useLintModal } from "./hooks/useLintModal";
21
+ import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
22
+ import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
23
+ import {
24
+ STUDIO_INSPECTOR_PANELS_ENABLED,
25
+ STUDIO_MOTION_PANEL_ENABLED,
26
+ } from "./components/editor/manualEditingAvailability";
27
+ import { getStudioMotionForSelection } from "./components/editor/studioMotion";
28
+ import { getTimelineElementKey, isTimelineElementActiveAtTime } from "./utils/timelineInspector";
29
+ import type { DomEditSelection } from "./components/editor/domEditing";
30
+ import type { AppToast } from "./utils/studioHelpers";
31
+ import { AskAgentModal } from "./components/AskAgentModal";
32
+ import { StudioHeader } from "./components/StudioHeader";
33
+ import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
34
+ import { StudioPreviewArea } from "./components/StudioPreviewArea";
35
+ import { StudioRightPanel } from "./components/StudioRightPanel";
36
+ import { TimelineToolbar } from "./components/TimelineToolbar";
37
+ import { StudioProvider, type StudioContextValue } from "./contexts/StudioContext";
38
+ import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
39
+ import { FileManagerProvider } from "./contexts/FileManagerContext";
40
+ import { DomEditProvider } from "./contexts/DomEditContext";
3031
41
 
3032
- const iframe = previewIframeRef.current;
3033
- const doc = iframe?.contentDocument;
3034
- if (doc) {
3035
- const el = findElementForSelection(doc, selection, activeCompPath);
3036
- if (el) {
3037
- if (
3038
- nextTextFields.length > 1 ||
3039
- nextTextFields.some((field) => field.source === "child")
3040
- ) {
3041
- el.innerHTML = nextContent;
3042
- } else {
3043
- el.textContent = nextContent;
3044
- }
42
+ export function StudioApp() {
43
+ const [projectId, setProjectId] = useState<string | null>(null);
44
+ const [resolving, setResolving] = useState(true);
45
+ useMountEffect(() => {
46
+ const hashProjectId = parseProjectIdFromHash(window.location.hash);
47
+ if (hashProjectId) {
48
+ setProjectId(hashProjectId);
49
+ setResolving(false);
50
+ return;
51
+ }
52
+ fetch("/api/projects")
53
+ .then((r) => r.json())
54
+ .then((data) => {
55
+ const first = (data.projects ?? [])[0];
56
+ if (first) {
57
+ setProjectId(first.id);
58
+ window.location.hash = buildProjectHash(first.id);
3045
59
  }
3046
- }
60
+ })
61
+ .catch(() => {})
62
+ .finally(() => setResolving(false));
63
+ });
3047
64
 
3048
- const importedFont = options?.importedFont ?? null;
3049
- await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
3050
- label: "Edit text",
3051
- skipRefresh: true,
3052
- prepareContent: importedFont
3053
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
3054
- : undefined,
3055
- });
65
+ const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
66
+ const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
67
+ const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
68
+ const [compositionLoading, setCompositionLoading] = useState(true);
69
+ const [refreshKey, setRefreshKey] = useState(0);
70
+ const [, setPreviewDocumentVersion] = useState(0);
3056
71
 
3057
- if (doc) {
3058
- const refreshed = findElementForSelection(doc, selection, activeCompPath);
3059
- if (refreshed) {
3060
- const nextSelection = buildDomSelectionFromTarget(refreshed);
3061
- if (nextSelection) {
3062
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3063
- }
3064
- }
3065
- }
3066
- },
3067
- [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
3068
- );
72
+ const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
73
+ const activeCompPathRef = useRef(activeCompPath);
74
+ activeCompPathRef.current = activeCompPath;
75
+ const leftSidebarRef = useRef<LeftSidebarHandle>(null);
76
+ const renderQueue = useRenderQueue(projectId);
77
+ const captionEditMode = useCaptionStore((s) => s.isEditMode);
78
+ const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
79
+ const captionSync = useCaptionSync(projectId);
80
+ const currentTime = usePlayerStore((s) => s.currentTime);
81
+ const timelineElements = usePlayerStore((s) => s.elements);
82
+ const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
83
+ const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
84
+ const timelineDuration = usePlayerStore((s) => s.duration);
85
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
86
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
87
+ const activePreviewUrl = activeCompPath
88
+ ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
89
+ : null;
90
+ const effectiveTimelineDuration = useMemo(() => {
91
+ const maxEnd =
92
+ timelineElements.length > 0
93
+ ? Math.max(...timelineElements.map((el) => el.start + el.duration))
94
+ : 0;
95
+ return Math.max(timelineDuration, maxEnd);
96
+ }, [timelineDuration, timelineElements]);
97
+ const refreshPreviewDocumentVersion = useCallback(() => {
98
+ setPreviewDocumentVersion((v) => v + 1);
99
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80);
100
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
101
+ }, []);
3069
102
 
3070
- const handleDomTextFieldStyleCommit = useCallback(
3071
- async (fieldKey: string, property: string, value: string) => {
3072
- if (!domEditSelection) return;
3073
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3074
- if (!field) return;
103
+ const [timelineVisible, setTimelineVisible] = useState(true);
104
+ const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
105
+ const [appToast, setAppToast] = useState<AppToast | null>(null);
106
+ const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
107
+ const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
108
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
109
+ setAppToast({ message, tone });
110
+ toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
111
+ }, []);
3075
112
 
3076
- if (field.source === "self") {
3077
- await handleDomStyleCommit(property, value);
3078
- return;
3079
- }
113
+ useMountEffect(() => () => {
114
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
115
+ });
3080
116
 
3081
- const normalizedValue = normalizeDomEditStyleValue(property, value);
3082
- const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
3083
- if (property === "font-family") {
3084
- const doc = previewIframeRef.current?.contentDocument;
3085
- if (doc) {
3086
- injectPreviewGoogleFont(doc, normalizedValue);
3087
- if (importedFont) injectPreviewImportedFont(doc, importedFont);
3088
- }
3089
- }
3090
- const nextTextFields = domEditSelection.textFields.map((entry) =>
3091
- entry.key === fieldKey
3092
- ? {
3093
- ...entry,
3094
- inlineStyles: {
3095
- ...entry.inlineStyles,
3096
- [property]: normalizedValue,
3097
- },
3098
- computedStyles: {
3099
- ...entry.computedStyles,
3100
- [property]: normalizedValue,
3101
- },
3102
- }
3103
- : entry,
3104
- );
117
+ const panelLayout = usePanelLayout();
118
+ const editHistory = usePersistentEditHistory({ projectId });
119
+ const domEditSaveTimestampRef = useRef(0);
120
+ const reloadPreview = useCallback(() => {
121
+ try {
122
+ previewIframeRef.current?.contentWindow?.location.reload();
123
+ } catch {
124
+ setRefreshKey((k) => k + 1);
125
+ }
126
+ }, []);
3105
127
 
3106
- await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
3107
- },
3108
- [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
3109
- );
128
+ const fileManager = useFileManager({
129
+ projectId,
130
+ showToast,
131
+ recordEdit: editHistory.recordEdit,
132
+ domEditSaveTimestampRef,
133
+ setRefreshKey,
134
+ });
3110
135
 
3111
- const handleDomAddTextField = useCallback(
3112
- async (afterFieldKey?: string) => {
3113
- if (!domEditSelection) return null;
3114
- if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
136
+ const manifestPersistence = useManifestPersistence({
137
+ projectId,
138
+ showToast,
139
+ readOptionalProjectFile: fileManager.readOptionalProjectFile,
140
+ writeProjectFile: fileManager.writeProjectFile,
141
+ recordEdit: editHistory.recordEdit,
142
+ previewIframeRef,
143
+ activeCompPathRef,
144
+ });
3115
145
 
3116
- const insertionIndex = domEditSelection.textFields.findIndex(
3117
- (field) => field.key === afterFieldKey,
3118
- );
3119
- const baseField =
3120
- domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
3121
- domEditSelection.textFields[0];
3122
- const nextField = buildDefaultDomEditTextField(baseField);
3123
- const nextTextFields = [...domEditSelection.textFields];
3124
- nextTextFields.splice(
3125
- insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
3126
- 0,
3127
- nextField,
3128
- );
146
+ const timelineEditing = useTimelineEditing({
147
+ projectId,
148
+ activeCompPath,
149
+ timelineElements,
150
+ showToast,
151
+ writeProjectFile: fileManager.writeProjectFile,
152
+ recordEdit: editHistory.recordEdit,
153
+ domEditSaveTimestampRef,
154
+ reloadPreview,
155
+ uploadProjectFiles: fileManager.uploadProjectFiles,
156
+ });
3129
157
 
3130
- await commitDomTextFields(domEditSelection, nextTextFields);
3131
- return nextField.key;
3132
- },
3133
- [commitDomTextFields, domEditSelection],
158
+ const clearDomSelectionRef = useRef<() => void>(() => {});
159
+ const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
160
+ const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
161
+ async () => {},
3134
162
  );
3135
163
 
3136
- const handleDomRemoveTextField = useCallback(
3137
- async (fieldKey: string) => {
3138
- if (!domEditSelection) return;
3139
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3140
- if (!field) return;
3141
-
3142
- if (field.source === "self") {
3143
- await handleDomTextCommit("", fieldKey);
3144
- return;
3145
- }
164
+ const appHotkeys = useAppHotkeys({
165
+ toggleTimelineVisibility,
166
+ handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
167
+ handleDomEditElementDelete: async (s: DomEditSelection) =>
168
+ handleDomEditElementDeleteRef.current(s),
169
+ domEditSelectionRef: domEditSelectionBridgeRef,
170
+ clearDomSelectionRef,
171
+ editHistory,
172
+ readOptionalProjectFile: fileManager.readOptionalProjectFile,
173
+ readProjectFile: fileManager.readProjectFile,
174
+ writeProjectFile: fileManager.writeProjectFile,
175
+ domEditSaveTimestampRef,
176
+ showToast,
177
+ syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
178
+ waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
179
+ leftSidebarRef,
180
+ });
3146
181
 
3147
- const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
3148
- await commitDomTextFields(domEditSelection, nextTextFields);
3149
- },
3150
- [commitDomTextFields, domEditSelection, handleDomTextCommit],
3151
- );
182
+ const domEditSession = useDomEditSession({
183
+ projectId,
184
+ activeCompPath,
185
+ isMasterView,
186
+ compIdToSrc,
187
+ captionEditMode,
188
+ compositionLoading,
189
+ previewIframeRef,
190
+ timelineElements,
191
+ currentTime,
192
+ setSelectedTimelineElementId,
193
+ setRightCollapsed: panelLayout.setRightCollapsed,
194
+ setRightPanelTab: panelLayout.setRightPanelTab,
195
+ showToast,
196
+ refreshPreviewDocumentVersion,
197
+ commitStudioManualEditManifestOptimistically:
198
+ manifestPersistence.commitStudioManualEditManifestOptimistically,
199
+ commitStudioMotionManifestOptimistically:
200
+ manifestPersistence.commitStudioMotionManifestOptimistically,
201
+ applyCurrentStudioManualEditsToPreview:
202
+ manifestPersistence.applyCurrentStudioManualEditsToPreview,
203
+ applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
204
+ readProjectFile: fileManager.readProjectFile,
205
+ writeProjectFile: fileManager.writeProjectFile,
206
+ domEditSaveTimestampRef,
207
+ editHistory: { recordEdit: editHistory.recordEdit },
208
+ fileTree: fileManager.fileTree,
209
+ importedFontAssetsRef: fileManager.importedFontAssetsRef,
210
+ projectDir: fileManager.projectDir,
211
+ projectIdRef: fileManager.projectIdRef,
212
+ previewIframe,
213
+ refreshKey,
214
+ rightPanelTab: panelLayout.rightPanelTab,
215
+ applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
216
+ applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef,
217
+ syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
218
+ reloadPreview,
219
+ setRefreshKey,
220
+ });
3152
221
 
3153
- const handleAskAgent = useCallback(() => {
3154
- if (!domEditSelection) return;
3155
- setAgentPromptTagSnippet(undefined);
3156
- setAgentPromptSelectionContext(undefined);
3157
- setAgentModalAnchorPoint(null);
3158
- void preloadAgentPromptSnippet(domEditSelection);
3159
- setAgentModalOpen(true);
3160
- }, [domEditSelection, preloadAgentPromptSnippet]);
222
+ domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
223
+ clearDomSelectionRef.current = domEditSession.clearDomSelection;
224
+ handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
3161
225
 
3162
- const handleAgentModalSubmit = useCallback(
3163
- async (userInstruction: string) => {
3164
- if (!domEditSelection) return;
226
+ useCaptionDetection({
227
+ projectId,
228
+ activeCompPath,
229
+ compIdToSrc,
230
+ captionEditMode,
231
+ captionHasSelection,
232
+ previewIframeRef,
233
+ captionSync,
234
+ setRightCollapsed: panelLayout.setRightCollapsed,
235
+ });
3165
236
 
3166
- const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
3167
- const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
3168
- const prompt = buildElementAgentPrompt({
3169
- selection: domEditSelection,
3170
- currentTime,
3171
- tagSnippet,
3172
- selectionContext: agentPromptSelectionContext,
3173
- userInstruction,
3174
- sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3175
- });
237
+ const renderClipContent = useRenderClipContent({
238
+ projectIdRef: fileManager.projectIdRef,
239
+ compIdToSrc,
240
+ activePreviewUrl,
241
+ effectiveTimelineDuration,
242
+ });
3176
243
 
3177
- const copied = await copyTextToClipboard(prompt);
3178
- if (!copied) {
3179
- showToast("Could not copy prompt to clipboard.", "error");
3180
- return;
3181
- }
244
+ const compositionDimensions = useCompositionDimensions();
245
+ const { lintModal, linting, handleLint, closeLintModal } = useLintModal(projectId);
246
+ const frameCapture = useFrameCapture({
247
+ projectId,
248
+ activeCompPath,
249
+ showToast,
250
+ waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
251
+ });
252
+ const {
253
+ consoleErrors,
254
+ setConsoleErrors,
255
+ resetErrors: resetConsoleErrors,
256
+ } = useConsoleErrorCapture(previewIframe);
3182
257
 
3183
- setAgentModalOpen(false);
3184
- setAgentPromptSelectionContext(undefined);
3185
- setAgentModalAnchorPoint(null);
3186
- if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3187
- setCopiedAgentPrompt(true);
3188
- copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3189
- },
3190
- [
3191
- activeCompPath,
3192
- agentPromptSelectionContext,
3193
- agentPromptTagSnippet,
3194
- currentTime,
3195
- domEditSelection,
3196
- projectDir,
3197
- showToast,
3198
- ],
3199
- );
258
+ const [globalDragOver, setGlobalDragOver] = useState(false);
259
+ const dragCounterRef = useRef(0);
3200
260
 
261
+ const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys;
3201
262
  const handlePreviewIframeRef = useCallback(
3202
263
  (iframe: HTMLIFrameElement | null) => {
3203
264
  previewIframeRef.current = iframe;
3204
265
  setPreviewIframe(iframe);
3205
266
  syncPreviewTimelineHotkey(iframe);
3206
267
  syncPreviewHistoryHotkey(iframe);
3207
- consoleErrorsRef.current = [];
3208
- setConsoleErrors(null);
268
+ resetConsoleErrors();
3209
269
  refreshPreviewDocumentVersion();
3210
270
  },
3211
- [refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
3212
- );
3213
-
3214
- const handlePreviewCanvasMouseDown = useCallback(
3215
- (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3216
- if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
3217
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3218
- preferClipAncestor: options?.preferClipAncestor ?? false,
3219
- });
3220
- if (!nextSelection) {
3221
- if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
3222
- return;
3223
- }
3224
- e.preventDefault();
3225
- e.stopPropagation();
3226
- const localPointer = previewIframeRef.current
3227
- ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3228
- : null;
3229
- applyDomSelection(nextSelection, { additive: e.shiftKey });
3230
- if (
3231
- !e.shiftKey &&
3232
- localPointer &&
3233
- isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3234
- ) {
3235
- setAgentPromptSelectionContext(
3236
- buildRasterClickSelectionContext(nextSelection, localPointer),
3237
- );
3238
- setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3239
- void preloadAgentPromptSnippet(nextSelection);
3240
- setAgentModalOpen(true);
3241
- }
3242
- },
3243
- [
3244
- applyDomSelection,
3245
- captionEditMode,
3246
- compositionLoading,
3247
- preloadAgentPromptSnippet,
3248
- resolveDomSelectionFromPreviewPoint,
3249
- ],
3250
- );
3251
-
3252
- const handlePreviewCanvasPointerMove = useCallback(
3253
- (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3254
- if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
3255
- updateDomEditHoverSelection(null);
3256
- return null;
3257
- }
3258
-
3259
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3260
- preferClipAncestor: options?.preferClipAncestor ?? false,
3261
- });
3262
- updateDomEditHoverSelection(nextSelection);
3263
- return nextSelection;
3264
- },
3265
271
  [
3266
- captionEditMode,
3267
- compositionLoading,
3268
- resolveDomSelectionFromPreviewPoint,
3269
- updateDomEditHoverSelection,
272
+ refreshPreviewDocumentVersion,
273
+ resetConsoleErrors,
274
+ syncPreviewHistoryHotkey,
275
+ syncPreviewTimelineHotkey,
3270
276
  ],
3271
277
  );
3272
278
 
3273
- const handlePreviewCanvasPointerLeave = useCallback(() => {
3274
- updateDomEditHoverSelection(null);
3275
- }, [updateDomEditHoverSelection]);
3276
-
3277
- // eslint-disable-next-line no-restricted-syntax
3278
- useEffect(() => {
3279
- if (captionEditMode) updateDomEditHoverSelection(null);
3280
- }, [captionEditMode, updateDomEditHoverSelection]);
3281
-
3282
- // eslint-disable-next-line no-restricted-syntax
3283
- useEffect(() => {
3284
- updateDomEditHoverSelection(null);
3285
- }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
3286
-
3287
- // eslint-disable-next-line no-restricted-syntax
3288
- useEffect(() => {
3289
- if (!domEditHoverSelection) return;
3290
- const hoverMatchesSelection = domEditSelectionsTargetSame(
3291
- domEditHoverSelection,
3292
- domEditSelection,
3293
- );
3294
- const hoverMatchesGroup = domEditSelectionInGroup(
3295
- domEditGroupSelections,
3296
- domEditHoverSelection,
3297
- );
3298
- if (!hoverMatchesSelection && !hoverMatchesGroup) return;
3299
- updateDomEditHoverSelection(null);
3300
- }, [
3301
- domEditGroupSelections,
3302
- domEditHoverSelection,
3303
- domEditSelection,
3304
- updateDomEditHoverSelection,
3305
- ]);
3306
-
3307
- // eslint-disable-next-line no-restricted-syntax
3308
- useEffect(() => {
3309
- if (!domEditHoverSelection) return;
3310
- if (domEditHoverSelection.element.isConnected) return;
3311
- updateDomEditHoverSelection(null);
3312
- }, [domEditHoverSelection, updateDomEditHoverSelection]);
3313
-
3314
- // eslint-disable-next-line no-restricted-syntax
3315
- useEffect(() => {
3316
- if (!previewIframe) return;
3317
-
3318
- const syncSelectionFromDocument = () => {
3319
- if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
3320
- const currentSelection = domEditSelectionRef.current;
3321
- if (!currentSelection) return;
3322
- let doc: Document | null = null;
3323
- try {
3324
- doc = previewIframe.contentDocument;
3325
- } catch {
3326
- return;
3327
- }
3328
- if (!doc) return;
3329
-
3330
- const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
3331
- if (!nextElement) {
3332
- applyDomSelection(null, { revealPanel: false });
3333
- return;
3334
- }
3335
-
3336
- const nextSelection = buildDomSelectionFromTarget(nextElement);
3337
- if (nextSelection) {
3338
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3339
- }
3340
- };
3341
-
3342
- const attachErrorCapture = () => {
3343
- try {
3344
- const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
3345
- if (!win) return;
3346
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
3347
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
3348
- const origError = win.console.error.bind(win.console);
3349
- win.console.error = function (...args: unknown[]) {
3350
- origError(...args);
3351
- const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
3352
- if (text.includes("favicon")) return;
3353
- consoleErrorsRef.current = [
3354
- ...consoleErrorsRef.current,
3355
- { severity: "error", message: text },
3356
- ];
3357
- setConsoleErrors([...consoleErrorsRef.current]);
3358
- };
3359
- win.addEventListener("error", (e: ErrorEvent) => {
3360
- const text = e.message || String(e);
3361
- consoleErrorsRef.current = [
3362
- ...consoleErrorsRef.current,
3363
- { severity: "error", message: text },
3364
- ];
3365
- setConsoleErrors([...consoleErrorsRef.current]);
3366
- });
3367
- } catch {
3368
- // same-origin only
3369
- }
3370
- };
3371
-
3372
- attachErrorCapture();
3373
- syncPreviewHistoryHotkey(previewIframe);
3374
- void (async () => {
3375
- await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3376
- await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3377
- })();
3378
- syncSelectionFromDocument();
3379
- refreshPreviewDocumentVersion();
3380
-
3381
- const handleLoad = () => {
3382
- consoleErrorsRef.current = [];
3383
- setConsoleErrors(null);
3384
- attachErrorCapture();
3385
- syncPreviewHistoryHotkey(previewIframe);
3386
- void (async () => {
3387
- await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3388
- await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3389
- })();
3390
- syncSelectionFromDocument();
3391
- refreshPreviewDocumentVersion();
3392
- };
3393
-
3394
- previewIframe.addEventListener("load", handleLoad);
3395
- return () => {
3396
- previewIframe.removeEventListener("load", handleLoad);
3397
- };
3398
- }, [
3399
- activeCompPath,
3400
- applyDomSelection,
3401
- applyStudioManualEditsToPreviewAfterRefresh,
3402
- applyStudioMotionToPreviewAfterRefresh,
3403
- buildDomSelectionFromTarget,
3404
- captionEditMode,
3405
- previewIframe,
3406
- refreshPreviewDocumentVersion,
3407
- syncPreviewHistoryHotkey,
3408
- ]);
3409
-
3410
- // eslint-disable-next-line no-restricted-syntax
3411
- useEffect(() => {
3412
- if (!captionEditMode) return;
3413
- applyDomSelection(null, { revealPanel: false });
3414
- }, [applyDomSelection, captionEditMode]);
3415
-
3416
- // eslint-disable-next-line no-restricted-syntax
3417
- useEffect(() => {
3418
- if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
3419
- updateDomEditHoverSelection(null);
3420
- applyDomSelection(null, { revealPanel: false });
3421
- if (rightPanelTab !== "renders") setRightPanelTab("renders");
3422
- }, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
3423
-
3424
- // eslint-disable-next-line no-restricted-syntax
3425
- useEffect(
3426
- () => () => {
3427
- if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3428
- },
3429
- [],
3430
- );
3431
-
3432
- const refreshFileTree = useCallback(async () => {
3433
- const pid = projectIdRef.current;
3434
- if (!pid) return;
3435
- const res = await fetch(`/api/projects/${pid}`);
3436
- const data = await res.json();
3437
- if (data.files) setFileTree(data.files);
3438
- }, []);
3439
-
3440
- const uploadProjectFiles = useCallback(
3441
- async (files: Iterable<File>, dir?: string): Promise<string[]> => {
3442
- const pid = projectIdRef.current;
3443
- const fileList = Array.from(files);
3444
- if (!pid || fileList.length === 0) return [];
3445
-
3446
- const formData = new FormData();
3447
- for (const file of fileList) {
3448
- formData.append("file", file);
3449
- }
3450
-
3451
- const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
3452
- try {
3453
- const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
3454
- method: "POST",
3455
- body: formData,
3456
- });
3457
- if (res.ok) {
3458
- const data = await res.json();
3459
- if (data.skipped?.length) {
3460
- showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
3461
- }
3462
- if (data.invalid?.length) {
3463
- const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
3464
- showToast(`Unsupported media skipped: ${names}`);
3465
- }
3466
- await refreshFileTree();
3467
- setRefreshKey((k) => k + 1);
3468
- return Array.isArray(data.files) ? data.files : [];
3469
- } else if (res.status === 413) {
3470
- showToast("Upload rejected: payload too large");
3471
- } else {
3472
- showToast(`Upload failed (${res.status})`);
3473
- }
3474
- } catch {
3475
- showToast("Upload failed: network error");
3476
- }
3477
- return [];
3478
- },
3479
- [refreshFileTree, showToast],
3480
- );
3481
-
3482
- const handleTimelineAssetDrop = useCallback(
3483
- async (
3484
- assetPath: string,
3485
- placement: Pick<TimelineElement, "start" | "track">,
3486
- durationOverride?: number,
3487
- ) => {
3488
- const pid = projectIdRef.current;
3489
- if (!pid) throw new Error("No active project");
3490
-
3491
- const kind = getTimelineAssetKind(assetPath);
3492
- if (!kind) {
3493
- showToast("Only image, video, and audio assets can be dropped onto the timeline.");
3494
- return;
3495
- }
3496
-
3497
- const targetPath = activeCompPath || "index.html";
3498
- try {
3499
- const response = await fetch(
3500
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
3501
- );
3502
- if (!response.ok) {
3503
- throw new Error(`Failed to read ${targetPath}`);
3504
- }
3505
-
3506
- const data = (await response.json()) as { content?: string };
3507
- const originalContent = data.content;
3508
- if (typeof originalContent !== "string") {
3509
- throw new Error(`Missing file contents for ${targetPath}`);
3510
- }
3511
-
3512
- const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
3513
- const duration =
3514
- Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
3515
- ? durationOverride
3516
- : await resolveDroppedAssetDuration(pid, assetPath, kind);
3517
- const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
3518
- const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
3519
- const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
3520
-
3521
- const resolvedTargetPath = targetPath || "index.html";
3522
- const relevantElements = timelineElements.filter(
3523
- (timelineElement) =>
3524
- (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
3525
- );
3526
- const trackZIndices = buildTrackZIndexMap([
3527
- ...relevantElements.map((timelineElement) => timelineElement.track),
3528
- placement.track,
3529
- ]);
3530
-
3531
- let patchedContent = originalContent;
3532
- for (const timelineElement of relevantElements) {
3533
- const elementTarget = timelineElement.domId
3534
- ? {
3535
- id: timelineElement.domId,
3536
- selector: timelineElement.selector,
3537
- selectorIndex: timelineElement.selectorIndex,
3538
- }
3539
- : timelineElement.selector
3540
- ? {
3541
- selector: timelineElement.selector,
3542
- selectorIndex: timelineElement.selectorIndex,
3543
- }
3544
- : null;
3545
- if (!elementTarget) continue;
3546
- const nextZIndex = trackZIndices.get(timelineElement.track);
3547
- if (nextZIndex == null) continue;
3548
- patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
3549
- type: "inline-style",
3550
- property: "z-index",
3551
- value: String(nextZIndex),
3552
- });
3553
- }
3554
-
3555
- patchedContent = insertTimelineAssetIntoSource(
3556
- patchedContent,
3557
- buildTimelineAssetInsertHtml({
3558
- id: newId,
3559
- assetPath: resolvedAssetSrc,
3560
- kind,
3561
- start: normalizedStart,
3562
- duration: normalizedDuration,
3563
- track: placement.track,
3564
- zIndex: trackZIndices.get(placement.track) ?? 1,
3565
- geometry: resolveTimelineAssetInitialGeometry(originalContent),
3566
- }),
3567
- );
3568
-
3569
- await saveProjectFilesWithHistory({
3570
- projectId: pid,
3571
- label: "Add timeline asset",
3572
- kind: "timeline",
3573
- files: { [targetPath]: patchedContent },
3574
- readFile: async () => originalContent,
3575
- writeFile: writeProjectFile,
3576
- recordEdit: editHistory.recordEdit,
3577
- });
3578
-
3579
- setRefreshKey((k) => k + 1);
3580
- } catch (error) {
3581
- const message =
3582
- error instanceof Error ? error.message : "Failed to drop asset onto timeline";
3583
- showToast(message);
3584
- }
3585
- },
3586
- [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
3587
- );
3588
-
3589
- const handleTimelineFileDrop = useCallback(
3590
- async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
3591
- const pid = projectIdRef.current;
3592
- if (!pid) return;
3593
- const uploaded = await uploadProjectFiles(files);
3594
- if (uploaded.length === 0) return;
3595
- const durations: number[] = [];
3596
- for (const assetPath of uploaded) {
3597
- const kind = getTimelineAssetKind(assetPath);
3598
- const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
3599
- durations.push(Number(formatTimelineAttributeNumber(duration)));
3600
- }
3601
- const placements = buildTimelineFileDropPlacements(
3602
- placement ?? { start: 0, track: 0 },
3603
- durations,
3604
- timelineElements
3605
- .filter(
3606
- (timelineElement) =>
3607
- (timelineElement.sourceFile || activeCompPath || "index.html") ===
3608
- (activeCompPath || "index.html"),
3609
- )
3610
- .map((timelineElement) => ({
3611
- start: timelineElement.start,
3612
- duration: timelineElement.duration,
3613
- track: timelineElement.track,
3614
- })),
3615
- );
3616
- for (const [index, assetPath] of uploaded.entries()) {
3617
- await handleTimelineAssetDrop(
3618
- assetPath,
3619
- placements[index] ?? placements[0],
3620
- durations[index],
3621
- );
3622
- }
3623
- },
3624
- [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
3625
- );
3626
-
3627
- // ── File Management Handlers ──
3628
-
3629
- const handleCreateFile = useCallback(
3630
- async (path: string) => {
3631
- const pid = projectIdRef.current;
3632
- if (!pid) return;
3633
- let content = "";
3634
- if (path.endsWith(".html")) {
3635
- content =
3636
- '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
3637
- }
3638
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
3639
- method: "POST",
3640
- headers: { "Content-Type": "text/plain" },
3641
- body: content,
3642
- });
3643
- if (res.ok) {
3644
- await refreshFileTree();
3645
- handleFileSelect(path);
3646
- } else {
3647
- const err = await res.json().catch(() => ({ error: "unknown" }));
3648
- console.error(`Create file failed: ${err.error}`);
3649
- }
3650
- },
3651
- [refreshFileTree, handleFileSelect],
3652
- );
3653
-
3654
- const handleCreateFolder = useCallback(
3655
- async (path: string) => {
3656
- const pid = projectIdRef.current;
3657
- if (!pid) return;
3658
- // Create a .gitkeep inside the folder so it appears in the tree
3659
- const res = await fetch(
3660
- `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
3661
- {
3662
- method: "POST",
3663
- headers: { "Content-Type": "text/plain" },
3664
- body: "",
3665
- },
3666
- );
3667
- if (res.ok) {
3668
- await refreshFileTree();
3669
- } else {
3670
- const err = await res.json().catch(() => ({ error: "unknown" }));
3671
- console.error(`Create folder failed: ${err.error}`);
3672
- }
3673
- },
3674
- [refreshFileTree],
3675
- );
3676
-
3677
- const handleDeleteFile = useCallback(
3678
- async (path: string) => {
3679
- const pid = projectIdRef.current;
3680
- if (!pid) return;
3681
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
3682
- method: "DELETE",
3683
- });
3684
- if (res.ok) {
3685
- if (editingPathRef.current === path) setEditingFile(null);
3686
- await refreshFileTree();
3687
- } else {
3688
- const err = await res.json().catch(() => ({ error: "unknown" }));
3689
- console.error(`Delete failed: ${err.error}`);
3690
- }
3691
- },
3692
- [refreshFileTree],
3693
- );
3694
-
3695
- const handleRenameFile = useCallback(
3696
- async (oldPath: string, newPath: string) => {
3697
- const pid = projectIdRef.current;
3698
- if (!pid) return;
3699
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
3700
- method: "PATCH",
3701
- headers: { "Content-Type": "application/json" },
3702
- body: JSON.stringify({ newPath }),
3703
- });
3704
- if (res.ok) {
3705
- if (editingPathRef.current === oldPath) {
3706
- handleFileSelect(newPath);
3707
- }
3708
- await refreshFileTree();
3709
- // Refresh preview — references in compositions may have been updated
3710
- setRefreshKey((k) => k + 1);
3711
- } else {
3712
- const err = await res.json().catch(() => ({ error: "unknown" }));
3713
- console.error(`Rename failed: ${err.error}`);
3714
- }
3715
- },
3716
- [refreshFileTree, handleFileSelect],
3717
- );
3718
-
3719
- const handleDuplicateFile = useCallback(
3720
- async (path: string) => {
3721
- const pid = projectIdRef.current;
3722
- if (!pid) return;
3723
- const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
3724
- method: "POST",
3725
- headers: { "Content-Type": "application/json" },
3726
- body: JSON.stringify({ path }),
3727
- });
3728
- if (res.ok) {
3729
- const data = await res.json();
3730
- await refreshFileTree();
3731
- if (data.path) handleFileSelect(data.path);
3732
- } else {
3733
- const err = await res.json().catch(() => ({ error: "unknown" }));
3734
- console.error(`Duplicate failed: ${err.error}`);
3735
- }
3736
- },
3737
- [refreshFileTree, handleFileSelect],
3738
- );
3739
-
3740
- const handleMoveFile = handleRenameFile;
3741
-
3742
- const handleImportFiles = useCallback(
3743
- async (files: FileList | File[], dir?: string) => {
3744
- return uploadProjectFiles(Array.from(files), dir);
3745
- },
3746
- [uploadProjectFiles],
3747
- );
3748
-
3749
- const handleImportFonts = useCallback(
3750
- async (files: FileList | File[]) => {
3751
- const uploaded = await uploadProjectFiles(
3752
- Array.from(files).filter((file) => FONT_EXT.test(file.name)),
3753
- "assets/fonts",
3754
- );
3755
- const pid = projectIdRef.current;
3756
- const imported = uploaded
3757
- .filter((asset) => FONT_EXT.test(asset))
3758
- .map((asset) => ({
3759
- family: fontFamilyFromAssetPath(asset),
3760
- path: asset,
3761
- url: `/api/projects/${pid}/preview/${asset}`,
3762
- }));
3763
- importedFontAssetsRef.current = [
3764
- ...imported,
3765
- ...importedFontAssetsRef.current.filter(
3766
- (existing) =>
3767
- !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
3768
- ),
3769
- ];
3770
- return imported;
3771
- },
3772
- [uploadProjectFiles],
3773
- );
3774
-
3775
- const handleLint = useCallback(async () => {
3776
- const pid = projectIdRef.current;
3777
- if (!pid) return;
3778
- setLinting(true);
3779
- try {
3780
- const res = await fetch(`/api/projects/${pid}/lint`);
3781
- const data = await res.json();
3782
- const findings: LintFinding[] = (data.findings ?? []).map(
3783
- (f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
3784
- severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
3785
- message: f.message ?? "",
3786
- file: f.file,
3787
- fixHint: f.fixHint,
3788
- }),
3789
- );
3790
- setLintModal(findings);
3791
- } catch (err) {
3792
- const msg = err instanceof Error ? err.message : String(err);
3793
- setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
3794
- } finally {
3795
- setLinting(false);
3796
- }
3797
- }, []);
3798
-
3799
- // Panel resize via pointer events (works for both left sidebar and right panel)
3800
- const handlePanelResizeStart = useCallback(
3801
- (side: "left" | "right", e: React.PointerEvent) => {
3802
- e.preventDefault();
3803
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
3804
- panelDragRef.current = {
3805
- side,
3806
- startX: e.clientX,
3807
- startW: side === "left" ? leftWidth : rightWidth,
3808
- };
279
+ const handleSelectComposition = useCallback(
280
+ (comp: string) => {
281
+ setActiveCompPath(comp === "index.html" || comp.startsWith("compositions/") ? comp : null);
282
+ fileManager.setEditingFile({ path: comp, content: null });
283
+ fetch(`/api/projects/${projectId}/files/${comp}`)
284
+ .then((r) => r.json())
285
+ .then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
286
+ .catch(() => {});
3809
287
  },
3810
- [leftWidth, rightWidth],
288
+ [projectId, fileManager],
3811
289
  );
3812
290
 
3813
- const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
3814
- const drag = panelDragRef.current;
3815
- if (!drag) return;
3816
- const delta = e.clientX - drag.startX;
3817
- const maxLeft = Math.floor(window.innerWidth * 0.5);
3818
- const newW = Math.max(
3819
- 160,
3820
- Math.min(
3821
- drag.side === "left" ? maxLeft : 600,
3822
- drag.startW + (drag.side === "left" ? delta : -delta),
3823
- ),
3824
- );
3825
- if (drag.side === "left") setLeftWidth(newW);
3826
- else setRightWidth(newW);
3827
- }, []);
3828
-
3829
- const handlePanelResizeEnd = useCallback(() => {
3830
- panelDragRef.current = null;
3831
- }, []);
3832
-
3833
- const compositions = useMemo(
3834
- () => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
3835
- [fileTree],
3836
- );
3837
- const assets = useMemo(
3838
- () =>
3839
- fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
3840
- [fileTree],
3841
- );
3842
- const fontAssets = useMemo<ImportedFontAsset[]>(
3843
- () =>
3844
- assets
3845
- .filter((asset) => FONT_EXT.test(asset))
3846
- .map((asset) => ({
3847
- family: fontFamilyFromAssetPath(asset),
3848
- path: asset,
3849
- url: `/api/projects/${projectId}/preview/${asset}`,
3850
- })),
3851
- [assets, projectId],
3852
- );
3853
291
  const selectedStudioMotion =
3854
- STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
3855
- ? getStudioMotionForSelection(studioMotionManifestRef.current, domEditSelection)
292
+ STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
293
+ ? getStudioMotionForSelection(
294
+ manifestPersistence.studioMotionManifestRef.current,
295
+ domEditSession.domEditSelection,
296
+ )
3856
297
  : null;
3857
298
  const selectedTimelineElement = useMemo(
3858
299
  () =>
3859
300
  selectedTimelineElementId
3860
- ? (timelineElements.find(
3861
- (element) => getTimelineElementKey(element) === selectedTimelineElementId,
3862
- ) ?? null)
301
+ ? (timelineElements.find((el) => getTimelineElementKey(el) === selectedTimelineElementId) ??
302
+ null)
3863
303
  : null,
3864
304
  [selectedTimelineElementId, timelineElements],
3865
305
  );
3866
- const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
306
+ const designPanelActive =
307
+ STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
3867
308
  const motionPanelActive =
3868
- STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
309
+ STUDIO_INSPECTOR_PANELS_ENABLED &&
310
+ STUDIO_MOTION_PANEL_ENABLED &&
311
+ panelLayout.rightPanelTab === "motion";
3869
312
  const inspectorPanelActive = designPanelActive || motionPanelActive;
3870
313
  const shouldShowSelectedDomBounds =
3871
314
  inspectorPanelActive &&
3872
- !rightCollapsed &&
315
+ !panelLayout.rightCollapsed &&
316
+ !isPlaying &&
3873
317
  (!selectedTimelineElement ||
3874
318
  isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
3875
319
  const inspectorButtonActive =
3876
- STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
3877
- const timelineLayerPanel =
3878
- STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED &&
3879
- inspectedTimelineElement &&
3880
- inspectedTimelineLayers.length > 0 ? (
3881
- <TimelineLayerPanel
3882
- clipLabel={getTimelineElementLabel(inspectedTimelineElement)}
3883
- layers={inspectedTimelineLayers}
3884
- selectedLayerKey={selectedTimelineLayerKey}
3885
- onSelectLayer={handleTimelineLayerSelect}
3886
- onClose={handleTimelineLayerPanelClose}
3887
- />
3888
- ) : null;
320
+ STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
321
+
322
+ // StudioProvider performs its own useMemo — no need for a second memo here.
323
+ const studioCtxValue: StudioContextValue = {
324
+ projectId: projectId!,
325
+ activeCompPath,
326
+ setActiveCompPath,
327
+ showToast,
328
+ previewIframeRef,
329
+ captionEditMode,
330
+ compositionLoading,
331
+ refreshKey,
332
+ setRefreshKey,
333
+ currentTime,
334
+ timelineElements,
335
+ isPlaying,
336
+ editHistory: {
337
+ canUndo: editHistory.canUndo,
338
+ canRedo: editHistory.canRedo,
339
+ undoLabel: editHistory.undoLabel,
340
+ redoLabel: editHistory.redoLabel,
341
+ },
342
+ handleUndo: appHotkeys.handleUndo,
343
+ handleRedo: appHotkeys.handleRedo,
344
+ renderQueue: {
345
+ jobs: renderQueue.jobs,
346
+ isRendering: renderQueue.isRendering,
347
+ deleteRender: renderQueue.deleteRender,
348
+ clearCompleted: renderQueue.clearCompleted,
349
+ startRender: renderQueue.startRender as (options: unknown) => Promise<void>,
350
+ },
351
+ compositionDimensions,
352
+ waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
353
+ handlePreviewIframeRef,
354
+ refreshPreviewDocumentVersion,
355
+ timelineVisible,
356
+ toggleTimelineVisibility,
357
+ };
3889
358
 
3890
359
  if (resolving || !projectId) {
3891
360
  return (
@@ -3895,481 +364,141 @@ export function StudioApp() {
3895
364
  );
3896
365
  }
3897
366
 
3898
- // At this point projectId is guaranteed non-null (narrowed by the guard above)
3899
-
367
+ const timelineToolbar = <TimelineToolbar toggleTimelineVisibility={toggleTimelineVisibility} />;
3900
368
  return (
3901
- <div
3902
- className="flex flex-col h-full w-full bg-neutral-950 relative"
3903
- onDragOver={(e) => {
3904
- if (!e.dataTransfer.types.includes("Files")) return;
3905
- e.preventDefault();
3906
- }}
3907
- onDragEnter={(e) => {
3908
- if (!e.dataTransfer.types.includes("Files")) return;
3909
- e.preventDefault();
3910
- dragCounterRef.current++;
3911
- setGlobalDragOver(true);
3912
- }}
3913
- onDragLeave={() => {
3914
- dragCounterRef.current--;
3915
- if (dragCounterRef.current === 0) setGlobalDragOver(false);
3916
- }}
3917
- onDrop={(e) => {
3918
- dragCounterRef.current = 0;
3919
- setGlobalDragOver(false);
3920
- // Skip if a child (e.g. AssetsTab) already handled the drop
3921
- if (e.defaultPrevented) return;
3922
- e.preventDefault();
3923
- if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files);
3924
- }}
3925
- >
3926
- {/* Header bar */}
3927
- <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
3928
- {/* Left: project name */}
3929
- <div className="flex items-center gap-2">
3930
- <span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
3931
- </div>
3932
- {/* Right: toolbar buttons */}
3933
- <div className="flex items-center gap-1.5">
3934
- <button
3935
- type="button"
3936
- onClick={() => void handleUndo()}
3937
- disabled={!editHistory.canUndo}
3938
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
3939
- editHistory.canUndo
3940
- ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
3941
- : "border-neutral-900 text-neutral-700"
3942
- }`}
3943
- title={
3944
- editHistory.undoLabel
3945
- ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
3946
- : `Undo (${getHistoryShortcutLabel("undo")})`
3947
- }
3948
- aria-label="Undo"
3949
- >
3950
- <RotateCcw size={14} />
3951
- </button>
3952
- <button
3953
- type="button"
3954
- onClick={() => void handleRedo()}
3955
- disabled={!editHistory.canRedo}
3956
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
3957
- editHistory.canRedo
3958
- ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
3959
- : "border-neutral-900 text-neutral-700"
3960
- }`}
3961
- title={
3962
- editHistory.redoLabel
3963
- ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
3964
- : `Redo (${getHistoryShortcutLabel("redo")})`
3965
- }
3966
- aria-label="Redo"
3967
- >
3968
- <RotateCw size={14} />
3969
- </button>
3970
- <a
3971
- href={captureFrameHref}
3972
- download={captureFrameFilename}
3973
- onClick={handleCaptureFrameClick}
3974
- onFocus={refreshCaptureFrameTime}
3975
- onPointerDown={refreshCaptureFrameTime}
3976
- className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
3977
- title="Capture current frame"
3978
- aria-label="Capture current frame"
3979
- >
3980
- <Camera size={14} />
3981
- <span>Capture</span>
3982
- </a>
3983
- <button
3984
- type="button"
3985
- onClick={() => {
3986
- if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
3987
- if (rightCollapsed || !inspectorPanelActive) {
3988
- setRightPanelTab("design");
3989
- setRightCollapsed(false);
3990
- return;
3991
- }
3992
- clearDomSelection();
3993
- setRightCollapsed(true);
3994
- }}
3995
- disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
3996
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
3997
- inspectorButtonActive
3998
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
3999
- : STUDIO_INSPECTOR_PANELS_ENABLED
4000
- ? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
4001
- : "cursor-not-allowed border-transparent text-neutral-700"
4002
- }`}
4003
- title={
4004
- STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
4005
- }
4006
- aria-label={
4007
- STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
4008
- }
4009
- >
4010
- <svg
4011
- width="12"
4012
- height="12"
4013
- viewBox="0 0 24 24"
4014
- fill="none"
4015
- stroke="currentColor"
4016
- strokeWidth="2"
369
+ <StudioProvider value={studioCtxValue}>
370
+ <PanelLayoutProvider value={panelLayout}>
371
+ <FileManagerProvider value={fileManager}>
372
+ <DomEditProvider value={domEditSession}>
373
+ <div
374
+ className="flex flex-col h-full w-full bg-neutral-950 relative"
375
+ onDragOver={(e) => {
376
+ if (!e.dataTransfer.types.includes("Files")) return;
377
+ e.preventDefault();
378
+ }}
379
+ onDragEnter={(e) => {
380
+ if (!e.dataTransfer.types.includes("Files")) return;
381
+ e.preventDefault();
382
+ dragCounterRef.current++;
383
+ setGlobalDragOver(true);
384
+ }}
385
+ onDragLeave={() => {
386
+ dragCounterRef.current--;
387
+ if (dragCounterRef.current === 0) setGlobalDragOver(false);
388
+ }}
389
+ onDrop={(e) => {
390
+ dragCounterRef.current = 0;
391
+ setGlobalDragOver(false);
392
+ if (e.defaultPrevented) return;
393
+ e.preventDefault();
394
+ if (e.dataTransfer.files.length)
395
+ fileManager.handleImportFiles(e.dataTransfer.files);
396
+ }}
4017
397
  >
4018
- <circle cx="12" cy="12" r="10" />
4019
- <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
4020
- </svg>
4021
- Inspector
4022
- </button>
4023
- </div>
4024
- </div>
398
+ <StudioHeader
399
+ captureFrameHref={frameCapture.captureFrameHref}
400
+ captureFrameFilename={frameCapture.captureFrameFilename}
401
+ handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
402
+ refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
403
+ inspectorButtonActive={inspectorButtonActive}
404
+ inspectorPanelActive={inspectorPanelActive}
405
+ />
406
+
407
+ <div className="flex flex-1 min-h-0">
408
+ <StudioLeftSidebar
409
+ leftSidebarRef={leftSidebarRef}
410
+ onSelectComposition={handleSelectComposition}
411
+ onLint={handleLint}
412
+ linting={linting}
413
+ />
414
+ <StudioPreviewArea
415
+ timelineToolbar={timelineToolbar}
416
+ renderClipContent={renderClipContent}
417
+ handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
418
+ handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
419
+ handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
420
+ handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
421
+ handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
422
+ handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
423
+ setCompIdToSrc={setCompIdToSrc}
424
+ setCompositionLoading={setCompositionLoading}
425
+ shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
426
+ />
4025
427
 
4026
- {/* Main content: sidebar + preview + right panel */}
4027
- <div className="flex flex-1 min-h-0">
4028
- {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
4029
- {leftCollapsed ? (
4030
- <div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
4031
- <button
4032
- type="button"
4033
- onClick={toggleLeftSidebar}
4034
- className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
4035
- title="Show sidebar"
4036
- aria-label="Show sidebar"
4037
- >
4038
- <svg
4039
- width="14"
4040
- height="14"
4041
- viewBox="0 0 24 24"
4042
- fill="none"
4043
- stroke="currentColor"
4044
- strokeWidth="1.5"
4045
- strokeLinecap="round"
4046
- strokeLinejoin="round"
4047
- aria-hidden="true"
4048
- >
4049
- <path d="M5 4v16" />
4050
- <path d="m10 7 5 5-5 5" />
4051
- </svg>
4052
- </button>
4053
- </div>
4054
- ) : (
4055
- <LeftSidebar
4056
- width={leftWidth}
4057
- projectId={projectId}
4058
- compositions={compositions}
4059
- assets={assets}
4060
- activeComposition={editingFile?.path ?? null}
4061
- onSelectComposition={(comp) => {
4062
- // Set active composition for preview drill-down
4063
- // Don't increment refreshKey — that reloads the master iframe and
4064
- // overrides the composition navigation. Let activeCompositionPath
4065
- // handle the preview change via the composition stack.
4066
- setActiveCompPath(
4067
- comp === "index.html" || comp.startsWith("compositions/") ? comp : null,
4068
- );
4069
- // Load file content for code editor
4070
- setEditingFile({ path: comp, content: null });
4071
- fetch(`/api/projects/${projectId}/files/${comp}`)
4072
- .then((r) => r.json())
4073
- .then((data) => setEditingFile({ path: comp, content: data.content }))
4074
- .catch(() => {});
4075
- }}
4076
- fileTree={fileTree}
4077
- editingFile={editingFile}
4078
- onSelectFile={handleFileSelect}
4079
- onCreateFile={handleCreateFile}
4080
- onCreateFolder={handleCreateFolder}
4081
- onDeleteFile={handleDeleteFile}
4082
- onRenameFile={handleRenameFile}
4083
- onDuplicateFile={handleDuplicateFile}
4084
- onMoveFile={handleMoveFile}
4085
- onImportFiles={handleImportFiles}
4086
- codeChildren={
4087
- editingFile ? (
4088
- isMediaFile(editingFile.path) ? (
4089
- <MediaPreview projectId={projectId ?? ""} filePath={editingFile.path} />
4090
- ) : (
4091
- <SourceEditor
4092
- content={editingFile.content ?? ""}
4093
- filePath={editingFile.path}
4094
- onChange={handleContentChange}
428
+ {!panelLayout.rightCollapsed && (
429
+ <StudioRightPanel
430
+ selectedStudioMotion={selectedStudioMotion}
431
+ designPanelActive={designPanelActive}
432
+ motionPanelActive={motionPanelActive}
4095
433
  />
4096
- )
4097
- ) : undefined
4098
- }
4099
- onLint={handleLint}
4100
- linting={linting}
4101
- onToggleCollapse={toggleLeftSidebar}
4102
- takeoverContent={timelineLayerPanel}
4103
- />
4104
- )}
434
+ )}
435
+ </div>
4105
436
 
4106
- {/* Left resize handle */}
4107
- {!leftCollapsed && (
4108
- <div
4109
- className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
4110
- style={{ touchAction: "none" }}
4111
- onPointerDown={(e) => handlePanelResizeStart("left", e)}
4112
- onPointerMove={handlePanelResizeMove}
4113
- onPointerUp={handlePanelResizeEnd}
4114
- >
4115
- <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
4116
- </div>
4117
- )}
437
+ {lintModal !== null && (
438
+ <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
439
+ )}
4118
440
 
4119
- {/* Center: Preview */}
4120
- <div className="flex-1 relative min-w-0">
4121
- <NLELayout
4122
- projectId={projectId}
4123
- refreshKey={refreshKey}
4124
- activeCompositionPath={activeCompPath}
4125
- timelineToolbar={timelineToolbar}
4126
- renderClipContent={renderClipContent}
4127
- onDeleteElement={handleTimelineElementDelete}
4128
- onAssetDrop={handleTimelineAssetDrop}
4129
- onFileDrop={handleTimelineFileDrop}
4130
- onMoveElement={handleTimelineElementMove}
4131
- onResizeElement={handleTimelineElementResize}
4132
- onBlockedEditAttempt={handleBlockedTimelineEdit}
4133
- onSelectTimelineElement={handleTimelineElementSelect}
4134
- onInspectTimelineElement={handleTimelineElementInspect}
4135
- inspectedTimelineElementId={inspectedTimelineElementId}
4136
- timelineLayerChildCounts={timelineLayerChildCounts}
4137
- onCompIdToSrcChange={setCompIdToSrc}
4138
- onCompositionLoadingChange={setCompositionLoading}
4139
- onCompositionChange={(compPath) => {
4140
- // Sync activeCompPath when user drills down via timeline double-click
4141
- // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
4142
- setActiveCompPath(compPath);
4143
- setInspectedTimelineElementId(null);
4144
- refreshPreviewDocumentVersion();
4145
- }}
4146
- onIframeRef={handlePreviewIframeRef}
4147
- previewOverlay={
4148
- captionEditMode ? (
4149
- <CaptionOverlay iframeRef={previewIframeRef} />
4150
- ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
4151
- <DomEditOverlay
4152
- iframeRef={previewIframeRef}
4153
- activeCompositionPath={activeCompPath}
4154
- hoverSelection={
4155
- STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
4156
- ? domEditHoverSelection
4157
- : null
4158
- }
4159
- selection={shouldShowSelectedDomBounds ? domEditSelection : null}
4160
- groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
4161
- allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
4162
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
4163
- onCanvasPointerMove={handlePreviewCanvasPointerMove}
4164
- onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
4165
- onSelectionChange={applyDomSelection}
4166
- onBlockedMove={handleBlockedDomMove}
4167
- onManualDragStart={handleDomManualDragStart}
4168
- onPathOffsetCommit={handleDomPathOffsetCommit}
4169
- onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
4170
- onBoxSizeCommit={handleDomBoxSizeCommit}
4171
- onRotationCommit={handleDomRotationCommit}
441
+ {consoleErrors !== null && consoleErrors.length > 0 && (
442
+ <LintModal
443
+ findings={consoleErrors}
444
+ projectId={projectId}
445
+ onClose={() => setConsoleErrors(null)}
4172
446
  />
4173
- ) : null
4174
- }
4175
- timelineFooter={
4176
- captionEditMode ? (
4177
- <div
4178
- className="border-t border-neutral-800/30 flex-shrink-0"
4179
- style={{ height: 60 }}
4180
- >
4181
- <div className="flex items-center gap-1.5 px-2 py-0.5">
4182
- <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
4183
- Captions
447
+ )}
448
+
449
+ {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
450
+ <AskAgentModal
451
+ selectionLabel={domEditSession.domEditSelection.label}
452
+ anchorPoint={domEditSession.agentModalAnchorPoint}
453
+ onSubmit={domEditSession.handleAgentModalSubmit}
454
+ onClose={() => {
455
+ domEditSession.setAgentModalOpen(false);
456
+ domEditSession.setAgentPromptSelectionContext(undefined);
457
+ domEditSession.setAgentModalAnchorPoint(null);
458
+ }}
459
+ />
460
+ )}
461
+
462
+ {globalDragOver && (
463
+ <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
464
+ <div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
465
+ <svg
466
+ width="32"
467
+ height="32"
468
+ viewBox="0 0 24 24"
469
+ fill="none"
470
+ stroke="currentColor"
471
+ strokeWidth="1.5"
472
+ strokeLinecap="round"
473
+ strokeLinejoin="round"
474
+ className="text-studio-accent"
475
+ >
476
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
477
+ <polyline points="7 10 12 15 17 10" />
478
+ <line x1="12" y1="15" x2="12" y2="3" />
479
+ </svg>
480
+ <span className="text-sm font-medium text-studio-accent">
481
+ Drop files to import into project
4184
482
  </span>
4185
483
  </div>
4186
- <CaptionTimeline pixelsPerSecond={100} />
4187
484
  </div>
4188
- ) : undefined
4189
- }
4190
- timelineVisible={timelineVisible}
4191
- onToggleTimeline={toggleTimelineVisibility}
4192
- />
4193
- </div>
485
+ )}
4194
486
 
4195
- {/* Right panel: Renders-only (resizable, collapsible via header Renders button) */}
4196
- {!rightCollapsed && (
4197
- <>
4198
- <div
4199
- className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
4200
- style={{ touchAction: "none" }}
4201
- onPointerDown={(e) => handlePanelResizeStart("right", e)}
4202
- onPointerMove={handlePanelResizeMove}
4203
- onPointerUp={handlePanelResizeEnd}
4204
- >
4205
- <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
4206
- </div>
4207
- <div
4208
- className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
4209
- style={{ width: rightWidth }}
4210
- >
4211
- {captionEditMode ? (
4212
- <CaptionPropertyPanel iframeRef={previewIframeRef} />
4213
- ) : (
4214
- <>
4215
- <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
4216
- {STUDIO_INSPECTOR_PANELS_ENABLED && (
4217
- <>
4218
- <button
4219
- type="button"
4220
- onClick={() => setRightPanelTab("design")}
4221
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4222
- rightPanelTab === "design"
4223
- ? "bg-neutral-800 text-white"
4224
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4225
- }`}
4226
- >
4227
- Design
4228
- </button>
4229
- {STUDIO_MOTION_PANEL_ENABLED && (
4230
- <button
4231
- type="button"
4232
- onClick={() => setRightPanelTab("motion")}
4233
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4234
- rightPanelTab === "motion"
4235
- ? "bg-neutral-800 text-white"
4236
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4237
- }`}
4238
- >
4239
- Motion
4240
- </button>
4241
- )}
4242
- </>
4243
- )}
4244
- <button
4245
- type="button"
4246
- onClick={() => setRightPanelTab("renders")}
4247
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4248
- rightPanelTab === "renders"
4249
- ? "bg-neutral-800 text-white"
4250
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4251
- }`}
4252
- >
4253
- {renderQueue.jobs.length > 0
4254
- ? `Renders (${renderQueue.jobs.length})`
4255
- : "Renders"}
4256
- </button>
4257
- </div>
4258
- <div className="min-h-0 flex-1">
4259
- {designPanelActive ? (
4260
- <PropertyPanel
4261
- projectId={projectId}
4262
- assets={assets}
4263
- element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4264
- multiSelectCount={domEditGroupSelections.length}
4265
- copiedAgentPrompt={copiedAgentPrompt}
4266
- onClearSelection={clearDomSelection}
4267
- onSetStyle={handleDomStyleCommit}
4268
- onSetManualOffset={handleDomPathOffsetCommit}
4269
- onSetManualSize={handleDomBoxSizeCommit}
4270
- onSetText={handleDomTextCommit}
4271
- onSetTextFieldStyle={handleDomTextFieldStyleCommit}
4272
- onAddTextField={handleDomAddTextField}
4273
- onRemoveTextField={handleDomRemoveTextField}
4274
- onResetManualEdits={handleDomManualEditsReset}
4275
- onAskAgent={handleAskAgent}
4276
- onImportAssets={handleImportFiles}
4277
- fontAssets={fontAssets}
4278
- onImportFonts={handleImportFonts}
4279
- />
4280
- ) : motionPanelActive ? (
4281
- <MotionPanel
4282
- element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4283
- motion={selectedStudioMotion}
4284
- onClearSelection={clearDomSelection}
4285
- onSetMotion={handleDomMotionCommit}
4286
- onClearMotion={handleDomMotionClear}
4287
- />
4288
- ) : (
4289
- <RenderQueue
4290
- jobs={renderQueue.jobs}
4291
- projectId={projectId}
4292
- onDelete={renderQueue.deleteRender}
4293
- onClearCompleted={renderQueue.clearCompleted}
4294
- onStartRender={async (format, quality, resolution, fps) => {
4295
- await waitForPendingDomEditSaves();
4296
- await renderQueue.startRender({ fps, quality, format, resolution });
4297
- }}
4298
- isRendering={renderQueue.isRendering}
4299
- />
4300
- )}
4301
- </div>
4302
- </>
487
+ {appToast && (
488
+ <div
489
+ className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
490
+ appToast.tone === "error"
491
+ ? "bg-red-900/90 border-red-700/50 text-red-200"
492
+ : "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
493
+ }`}
494
+ >
495
+ {appToast.message}
496
+ </div>
4303
497
  )}
4304
498
  </div>
4305
- </>
4306
- )}
4307
- </div>
4308
-
4309
- {/* Lint modal */}
4310
- {lintModal !== null && projectId && (
4311
- <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
4312
- )}
4313
-
4314
- {/* Console errors modal — auto-shows when composition has runtime errors */}
4315
- {consoleErrors !== null && consoleErrors.length > 0 && projectId && (
4316
- <LintModal
4317
- findings={consoleErrors}
4318
- projectId={projectId}
4319
- onClose={() => setConsoleErrors(null)}
4320
- />
4321
- )}
4322
-
4323
- {/* Ask agent modal */}
4324
- {agentModalOpen && domEditSelection && (
4325
- <AskAgentModal
4326
- selectionLabel={domEditSelection.label}
4327
- anchorPoint={agentModalAnchorPoint}
4328
- onSubmit={handleAgentModalSubmit}
4329
- onClose={() => {
4330
- setAgentModalOpen(false);
4331
- setAgentPromptSelectionContext(undefined);
4332
- setAgentModalAnchorPoint(null);
4333
- }}
4334
- />
4335
- )}
4336
-
4337
- {/* Global drag-drop overlay */}
4338
- {globalDragOver && (
4339
- <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
4340
- <div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
4341
- <svg
4342
- width="32"
4343
- height="32"
4344
- viewBox="0 0 24 24"
4345
- fill="none"
4346
- stroke="currentColor"
4347
- strokeWidth="1.5"
4348
- strokeLinecap="round"
4349
- strokeLinejoin="round"
4350
- className="text-studio-accent"
4351
- >
4352
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
4353
- <polyline points="7 10 12 15 17 10" />
4354
- <line x1="12" y1="15" x2="12" y2="3" />
4355
- </svg>
4356
- <span className="text-sm font-medium text-studio-accent">
4357
- Drop files to import into project
4358
- </span>
4359
- </div>
4360
- </div>
4361
- )}
4362
- {appToast && (
4363
- <div
4364
- className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
4365
- appToast.tone === "error"
4366
- ? "bg-red-900/90 border-red-700/50 text-red-200"
4367
- : "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
4368
- }`}
4369
- >
4370
- {appToast.message}
4371
- </div>
4372
- )}
4373
- </div>
499
+ </DomEditProvider>
500
+ </FileManagerProvider>
501
+ </PanelLayoutProvider>
502
+ </StudioProvider>
4374
503
  );
4375
504
  }