@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

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 (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
package/src/App.tsx CHANGED
@@ -4,13 +4,14 @@ import {
4
4
  useRef,
5
5
  useEffect,
6
6
  useMemo,
7
+ type CSSProperties,
7
8
  type MouseEvent,
8
9
  type ReactNode,
9
10
  } from "react";
10
11
  import { useMountEffect } from "./hooks/useMountEffect";
11
12
  import { NLELayout } from "./components/nle/NLELayout";
12
13
  import { SourceEditor } from "./components/editor/SourceEditor";
13
- import { LeftSidebar } from "./components/sidebar/LeftSidebar";
14
+ import { LeftSidebar, type LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
14
15
  import { RenderQueue } from "./components/renders/RenderQueue";
15
16
  import { useRenderQueue } from "./components/renders/useRenderQueue";
16
17
  import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
@@ -19,13 +20,15 @@ import type { TimelineElement } from "./player";
19
20
  import { LintModal } from "./components/LintModal";
20
21
  import type { LintFinding } from "./components/LintModal";
21
22
  import { MediaPreview } from "./components/MediaPreview";
22
- import { isMediaFile } from "./utils/mediaTypes";
23
+ import { RotateCcw, RotateCw } from "./icons/SystemIcons";
24
+ import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
23
25
  import {
24
26
  buildTimelineAssetId,
25
27
  buildTimelineAssetInsertHtml,
26
28
  buildTimelineFileDropPlacements,
27
29
  getTimelineAssetKind,
28
30
  insertTimelineAssetIntoSource,
31
+ resolveTimelineAssetInitialGeometry,
29
32
  resolveTimelineAssetSrc,
30
33
  type TimelineAssetKind,
31
34
  } from "./utils/timelineAssetDrop";
@@ -35,7 +38,14 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
35
38
  import { useCaptionStore } from "./captions/store";
36
39
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
37
40
  import { parseCaptionComposition } from "./captions/parser";
38
- import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
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";
39
49
  import {
40
50
  buildTrackZIndexMap,
41
51
  formatTimelineAttributeNumber,
@@ -46,11 +56,92 @@ import {
46
56
  } from "./player/components/timelineZoom";
47
57
  import {
48
58
  getTimelineToggleTitle,
59
+ isEditableTarget,
49
60
  shouldHandleTimelineToggleHotkey,
50
61
  } from "./utils/timelineDiscovery";
51
62
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
63
  import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
53
64
  import { Camera } from "./icons/SystemIcons";
65
+ import { PropertyPanel } from "./components/editor/PropertyPanel";
66
+ import { MotionPanel } from "./components/editor/MotionPanel";
67
+ import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
68
+ import {
69
+ fontFamilyFromAssetPath,
70
+ importedFontFaceCss,
71
+ type ImportedFontAsset,
72
+ } from "./components/editor/fontAssets";
73
+ import {
74
+ DomEditOverlay,
75
+ type DomEditGroupPathOffsetCommit,
76
+ } from "./components/editor/DomEditOverlay";
77
+ import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
78
+ import {
79
+ STUDIO_INSPECTOR_PANELS_ENABLED,
80
+ STUDIO_MANUAL_EDITING_DISABLED_TITLE,
81
+ STUDIO_MOTION_PANEL_ENABLED,
82
+ STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
83
+ STUDIO_PREVIEW_SELECTION_ENABLED,
84
+ STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
85
+ } from "./components/editor/manualEditingAvailability";
86
+ import {
87
+ buildDomEditStylePatchOperation,
88
+ buildDomEditTextPatchOperation,
89
+ buildElementAgentPrompt,
90
+ collectDomEditLayerItems,
91
+ countDomEditChildLayers,
92
+ findElementForSelection,
93
+ findElementForTimelineElement,
94
+ getDomEditLayerKey,
95
+ getDomEditTargetKey,
96
+ isLargeRasterDomEditSelection,
97
+ isTextEditableSelection,
98
+ resolveVisualDomEditSelectionTarget,
99
+ serializeDomEditTextFields,
100
+ resolveDomEditSelection,
101
+ type DomEditViewport,
102
+ type DomEditLayerItem,
103
+ type DomEditTextField,
104
+ type DomEditSelection,
105
+ buildDefaultDomEditTextField,
106
+ } from "./components/editor/domEditing";
107
+ import {
108
+ STUDIO_MANUAL_EDITS_PATH,
109
+ applyStudioManualEditManifest,
110
+ emptyStudioManualEditManifest,
111
+ installStudioManualEditSeekReapply,
112
+ isStudioManualEditManifestPath,
113
+ parseStudioManualEditManifest,
114
+ readStudioFileChangePath,
115
+ removeStudioManualEditsForSelection,
116
+ serializeStudioManualEditManifest,
117
+ type StudioManualEditManifest,
118
+ upsertStudioBoxSizeEdit,
119
+ upsertStudioPathOffsetEdit,
120
+ upsertStudioRotationEdit,
121
+ } from "./components/editor/manualEdits";
122
+ import {
123
+ STUDIO_MOTION_PATH,
124
+ applyStudioMotionManifest,
125
+ emptyStudioMotionManifest,
126
+ getStudioMotionForSelection,
127
+ installStudioMotionSeekReapply,
128
+ isStudioMotionManifestPath,
129
+ parseStudioMotionManifest,
130
+ removeStudioMotionForSelection,
131
+ serializeStudioMotionManifest,
132
+ type StudioGsapMotion,
133
+ type StudioMotionManifest,
134
+ upsertStudioGsapMotion,
135
+ } from "./components/editor/studioMotion";
136
+ import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
137
+ import {
138
+ canInspectTimelineElement,
139
+ getTimelineElementKey,
140
+ getTimelineLayerVisibilityInPreview,
141
+ isTimelineElementActiveAtTime,
142
+ isTimelineLayerVisibleInPreview,
143
+ shouldShowTimelineInspectorBounds,
144
+ } from "./utils/timelineInspector";
54
145
 
55
146
  interface EditingFile {
56
147
  path: string;
@@ -66,6 +157,638 @@ function getTimelineElementLabel(element: TimelineElement): string {
66
157
  return element.label || element.id || element.tag;
67
158
  }
68
159
 
160
+ type RightPanelTab = "design" | "motion" | "renders";
161
+
162
+ const GENERIC_FONT_FAMILIES = new Set([
163
+ "inherit",
164
+ "initial",
165
+ "revert",
166
+ "revert-layer",
167
+ "serif",
168
+ "sans-serif",
169
+ "monospace",
170
+ "cursive",
171
+ "fantasy",
172
+ "system-ui",
173
+ "ui-sans-serif",
174
+ "ui-serif",
175
+ "ui-monospace",
176
+ "ui-rounded",
177
+ "emoji",
178
+ "math",
179
+ "fangsong",
180
+ ]);
181
+
182
+ function primaryFontFamilyFromCss(value: string): string {
183
+ const first = value.split(",")[0] ?? "";
184
+ return first.trim().replace(/^["']|["']$/g, "");
185
+ }
186
+
187
+ function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
188
+ const family = primaryFontFamilyFromCss(fontFamilyValue);
189
+ if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
190
+
191
+ const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
192
+ if (doc.getElementById(id)) return;
193
+
194
+ const link = doc.createElement("link");
195
+ link.id = id;
196
+ link.rel = "stylesheet";
197
+ link.href = googleFontStylesheetUrl(family);
198
+ doc.head.appendChild(link);
199
+ }
200
+
201
+ function primaryFontFamilyValue(value: string): string {
202
+ return (
203
+ value
204
+ .split(",")[0]
205
+ ?.trim()
206
+ .replace(/^["']|["']$/g, "")
207
+ .trim() ?? ""
208
+ );
209
+ }
210
+
211
+ function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
212
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
213
+ if (doc.getElementById(id)) return;
214
+ const style = doc.createElement("style");
215
+ style.id = id;
216
+ style.textContent = importedFontFaceCss(asset);
217
+ doc.head.appendChild(style);
218
+ }
219
+
220
+ function normalizeProjectAssetPath(value: string): string {
221
+ const trimmed = value.trim();
222
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
223
+ return decodeURIComponent(maybeUrl)
224
+ .replace(/\\/g, "/")
225
+ .replace(/^\.?\//, "");
226
+ }
227
+
228
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
229
+ const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
230
+ const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
231
+
232
+ fromParts.pop();
233
+
234
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
235
+ fromParts.shift();
236
+ targetParts.shift();
237
+ }
238
+
239
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
240
+ }
241
+
242
+ function isAbsoluteFilePath(value: string): boolean {
243
+ return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
244
+ }
245
+
246
+ function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
247
+ const trimmedSource = sourceFile.trim();
248
+ if (!trimmedSource) return undefined;
249
+
250
+ const normalizedSource = trimmedSource.replace(/\\/g, "/");
251
+ if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
252
+
253
+ const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
254
+ if (!normalizedRoot) return undefined;
255
+
256
+ return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
257
+ }
258
+
259
+ function ensureImportedFontFace(
260
+ html: string,
261
+ asset: ImportedFontAsset,
262
+ sourceFile: string,
263
+ ): string {
264
+ const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
265
+ if (html.includes(css)) return html;
266
+
267
+ const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
268
+ const styleMatch = styleRe.exec(html);
269
+ if (styleMatch) {
270
+ const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
271
+ return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
272
+ }
273
+
274
+ const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
275
+ if (/<\/head>/i.test(html)) {
276
+ return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
277
+ }
278
+ return `${styleTag}\n${html}`;
279
+ }
280
+ function normalizeDomEditStyleValue(property: string, value: string): string {
281
+ const trimmed = value.trim();
282
+ if (!trimmed) return trimmed;
283
+
284
+ if (
285
+ ["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
286
+ /^-?\d+(\.\d+)?$/.test(trimmed)
287
+ ) {
288
+ return `${trimmed}px`;
289
+ }
290
+
291
+ return trimmed;
292
+ }
293
+
294
+ function isImageBackgroundValue(value: string): boolean {
295
+ return /^url\(/i.test(value.trim());
296
+ }
297
+
298
+ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
299
+ if (!target || typeof target !== "object") return null;
300
+ const maybeNode = target as {
301
+ nodeType?: number;
302
+ parentElement?: Element | null;
303
+ };
304
+ if (maybeNode.nodeType === 1) return target as HTMLElement;
305
+ if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
306
+ return maybeNode.parentElement as HTMLElement;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
312
+ const el = getEventTargetElement(target);
313
+ if (!el) return false;
314
+ return Boolean(
315
+ el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
316
+ );
317
+ }
318
+
319
+ function getHistoryShortcutLabel(action: "undo" | "redo"): string {
320
+ const isMac =
321
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
322
+ const modifier = isMac ? "Cmd" : "Ctrl";
323
+ return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
324
+ }
325
+
326
+ function findMatchingTimelineElementId(
327
+ selection: Pick<
328
+ DomEditSelection,
329
+ "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
330
+ >,
331
+ elements: TimelineElement[],
332
+ ): string | null {
333
+ const selectionSourceFile = selection.sourceFile || "index.html";
334
+ for (const element of elements) {
335
+ const elementSourceFile = element.sourceFile || "index.html";
336
+ if (
337
+ selection.id &&
338
+ element.domId === selection.id &&
339
+ elementSourceFile === selectionSourceFile
340
+ ) {
341
+ return element.key ?? element.id;
342
+ }
343
+ if (
344
+ selection.isCompositionHost &&
345
+ selection.compositionSrc &&
346
+ element.compositionSrc === selection.compositionSrc
347
+ ) {
348
+ return element.key ?? element.id;
349
+ }
350
+ if (
351
+ selection.selector &&
352
+ element.selector === selection.selector &&
353
+ (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
354
+ (element.sourceFile ?? "index.html") === selection.sourceFile
355
+ ) {
356
+ return element.key ?? element.id;
357
+ }
358
+ }
359
+
360
+ return null;
361
+ }
362
+
363
+ function isManualGeometryStyleProperty(property: string): boolean {
364
+ return property === "left" || property === "top" || property === "width" || property === "height";
365
+ }
366
+
367
+ interface PreviewLocalPointer {
368
+ x: number;
369
+ y: number;
370
+ viewport: DomEditViewport;
371
+ }
372
+
373
+ interface AgentModalAnchorPoint {
374
+ x: number;
375
+ y: number;
376
+ }
377
+
378
+ function resolvePreviewLocalPointer(
379
+ iframe: HTMLIFrameElement,
380
+ doc: Document,
381
+ win: Window,
382
+ clientX: number,
383
+ clientY: number,
384
+ ): PreviewLocalPointer | null {
385
+ const iframeRect = iframe.getBoundingClientRect();
386
+ const root =
387
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
388
+ const rootRect = root?.getBoundingClientRect();
389
+ const rootWidth = rootRect?.width || win.innerWidth;
390
+ const rootHeight = rootRect?.height || win.innerHeight;
391
+ if (!rootWidth || !rootHeight) return null;
392
+
393
+ const scaleX = iframeRect.width / rootWidth;
394
+ const scaleY = iframeRect.height / rootHeight;
395
+ return {
396
+ x: (clientX - iframeRect.left) / scaleX,
397
+ y: (clientY - iframeRect.top) / scaleY,
398
+ viewport: { width: rootWidth, height: rootHeight },
399
+ };
400
+ }
401
+
402
+ function getPreviewLocalPointer(
403
+ iframe: HTMLIFrameElement,
404
+ clientX: number,
405
+ clientY: number,
406
+ ): PreviewLocalPointer | null {
407
+ let doc: Document | null = null;
408
+ let win: Window | null = null;
409
+ try {
410
+ doc = iframe.contentDocument;
411
+ win = iframe.contentWindow;
412
+ } catch {
413
+ return null;
414
+ }
415
+ if (!doc || !win) return null;
416
+
417
+ return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
418
+ }
419
+
420
+ function getPreviewTargetFromPointer(
421
+ iframe: HTMLIFrameElement,
422
+ clientX: number,
423
+ clientY: number,
424
+ activeCompositionPath: string | null,
425
+ ): HTMLElement | null {
426
+ let doc: Document | null = null;
427
+ let win: Window | null = null;
428
+ try {
429
+ doc = iframe.contentDocument;
430
+ win = iframe.contentWindow;
431
+ } catch {
432
+ return null;
433
+ }
434
+ if (!doc || !win) return null;
435
+
436
+ const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
437
+ if (!localPointer) return null;
438
+
439
+ if (typeof doc.elementsFromPoint === "function") {
440
+ const visualTarget = resolveVisualDomEditSelectionTarget(
441
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
442
+ {
443
+ activeCompositionPath,
444
+ },
445
+ );
446
+ if (visualTarget) return visualTarget;
447
+ }
448
+
449
+ return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
450
+ }
451
+
452
+ function buildRasterClickSelectionContext(
453
+ selection: DomEditSelection,
454
+ localPointer: PreviewLocalPointer,
455
+ ): string {
456
+ return [
457
+ "The user clicked a large raster/background element in the Studio preview.",
458
+ `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
459
+ localPointer.viewport.width,
460
+ )}x${Math.round(localPointer.viewport.height)} composition.`,
461
+ `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
462
+ "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
463
+ "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
464
+ ].join("\n");
465
+ }
466
+
467
+ function domEditSelectionsTargetSame(
468
+ a: DomEditSelection | null,
469
+ b: DomEditSelection | null,
470
+ ): boolean {
471
+ if (a === b) return true;
472
+ if (!a || !b) return false;
473
+ return getDomEditTargetKey(a) === getDomEditTargetKey(b);
474
+ }
475
+
476
+ function domEditSelectionInGroup(
477
+ group: DomEditSelection[],
478
+ selection: DomEditSelection | null,
479
+ ): boolean {
480
+ if (!selection) return false;
481
+ return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
482
+ }
483
+
484
+ function toggleDomEditGroupSelection(
485
+ group: DomEditSelection[],
486
+ selection: DomEditSelection,
487
+ ): DomEditSelection[] {
488
+ if (domEditSelectionInGroup(group, selection)) {
489
+ return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
490
+ }
491
+ return [...group, selection];
492
+ }
493
+
494
+ function replaceDomEditGroupSelection(
495
+ group: DomEditSelection[],
496
+ selection: DomEditSelection,
497
+ ): DomEditSelection[] {
498
+ let replaced = false;
499
+ const nextGroup = group.map((entry) => {
500
+ if (!domEditSelectionsTargetSame(entry, selection)) return entry;
501
+ replaced = true;
502
+ return selection;
503
+ });
504
+ return replaced ? nextGroup : [...group, selection];
505
+ }
506
+
507
+ function seedDomEditGroupWithSelection(
508
+ group: DomEditSelection[],
509
+ selection: DomEditSelection | null,
510
+ ): DomEditSelection[] {
511
+ if (!selection || domEditSelectionInGroup(group, selection)) return group;
512
+ return [selection, ...group];
513
+ }
514
+
515
+ function objectLike(value: unknown): object | null {
516
+ return value && (typeof value === "object" || typeof value === "function") ? value : null;
517
+ }
518
+
519
+ function callPlaybackMethod(target: object | null, key: string): void {
520
+ const method = target ? Reflect.get(target, key) : null;
521
+ if (typeof method !== "function") return;
522
+ try {
523
+ method.call(target);
524
+ } catch {
525
+ // Best-effort playback freeze; drag should still work if playback control is unavailable.
526
+ }
527
+ }
528
+
529
+ function readPlaybackTime(target: object | null, key: string): number | null {
530
+ const method = target ? Reflect.get(target, key) : null;
531
+ if (typeof method !== "function") return null;
532
+ try {
533
+ const value = method.call(target);
534
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+
540
+ interface PreviewPlayerCompat {
541
+ getTime: () => number;
542
+ renderSeek: (timeSeconds: number) => void;
543
+ }
544
+
545
+ function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
546
+ const player = objectLike(win ? Reflect.get(win, "__player") : null);
547
+ if (!player) return null;
548
+ const getTime = Reflect.get(player, "getTime");
549
+ const renderSeek = Reflect.get(player, "renderSeek");
550
+ if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
551
+ return {
552
+ getTime: () => {
553
+ const value = getTime.call(player);
554
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
555
+ },
556
+ renderSeek: (timeSeconds: number) => {
557
+ renderSeek.call(player, timeSeconds);
558
+ },
559
+ };
560
+ }
561
+
562
+ function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
563
+ const player = getPreviewPlayer(iframe?.contentWindow);
564
+ if (!player) return false;
565
+ const nextTime = Math.max(0, timeSeconds);
566
+ player.renderSeek(nextTime);
567
+ usePlayerStore.getState().setCurrentTime(nextTime);
568
+ liveTime.notify(nextTime);
569
+ return true;
570
+ }
571
+
572
+ function parseFiniteSeconds(value: string | null): number | null {
573
+ if (value == null || value.trim() === "") return null;
574
+ const parsed = Number.parseFloat(value);
575
+ return Number.isFinite(parsed) ? parsed : null;
576
+ }
577
+
578
+ function resolveLayerVisibleSeekTime(
579
+ layerElement: HTMLElement,
580
+ timelineElement: TimelineElement | null,
581
+ player: PreviewPlayerCompat | null,
582
+ ): number | null {
583
+ if (!timelineElement || !player) return null;
584
+ const originalTime = player.getTime();
585
+
586
+ const clipStart = Math.max(0, timelineElement.start);
587
+ const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
588
+ const authoredStart = parseFiniteSeconds(
589
+ layerElement.getAttribute("data-start") ??
590
+ layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
591
+ null,
592
+ );
593
+ const preferredTime =
594
+ authoredStart == null
595
+ ? clipStart
596
+ : Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
597
+ const candidates = [preferredTime, clipStart];
598
+ const duration = clipEnd - clipStart;
599
+ if (duration > 0) {
600
+ const maxSamples = 24;
601
+ const frameStep = 1 / 24;
602
+ const step = Math.max(frameStep, duration / maxSamples);
603
+ for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
604
+ candidates.push(Math.min(clipEnd, time));
605
+ }
606
+ }
607
+ candidates.push(clipEnd);
608
+
609
+ let lastTried = preferredTime;
610
+ let clearestVisibleTime: number | null = null;
611
+ let clearestVisibleOpacity = 0;
612
+ let resolvedTime: number | null = null;
613
+ const seen = new Set<string>();
614
+ try {
615
+ for (const candidate of candidates) {
616
+ const time = Math.min(clipEnd, Math.max(clipStart, candidate));
617
+ const key = time.toFixed(4);
618
+ if (seen.has(key)) continue;
619
+ seen.add(key);
620
+ lastTried = time;
621
+ player.renderSeek(time);
622
+ const visibility = getTimelineLayerVisibilityInPreview(layerElement);
623
+ if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
624
+ clearestVisibleTime = time;
625
+ clearestVisibleOpacity = visibility.compositeOpacity;
626
+ }
627
+ if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
628
+ resolvedTime = time;
629
+ break;
630
+ }
631
+ }
632
+ } finally {
633
+ player.renderSeek(originalTime);
634
+ }
635
+
636
+ return resolvedTime ?? clearestVisibleTime ?? lastTried;
637
+ }
638
+
639
+ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
640
+ const win = iframe?.contentWindow;
641
+ if (!win) return null;
642
+
643
+ try {
644
+ let pausedTime: number | null = null;
645
+ const player = objectLike(Reflect.get(win, "__player"));
646
+ pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
647
+ callPlaybackMethod(player, "pause");
648
+
649
+ const timeline = objectLike(Reflect.get(win, "__timeline"));
650
+ pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
651
+ callPlaybackMethod(timeline, "pause");
652
+
653
+ const timelines = objectLike(Reflect.get(win, "__timelines"));
654
+ if (timelines) {
655
+ for (const value of Object.values(timelines)) {
656
+ const timelineRecord = objectLike(value);
657
+ pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
658
+ callPlaybackMethod(timelineRecord, "pause");
659
+ }
660
+ }
661
+
662
+ return pausedTime;
663
+ } catch {
664
+ return null;
665
+ }
666
+ }
667
+
668
+ // ── Ask Agent Modal ──
669
+
670
+ function clampNumber(value: number, min: number, max: number): number {
671
+ if (max < min) return min;
672
+ return Math.min(Math.max(value, min), max);
673
+ }
674
+
675
+ function getAgentModalPositionStyle(
676
+ anchorPoint: AgentModalAnchorPoint | null,
677
+ ): CSSProperties | undefined {
678
+ if (!anchorPoint || typeof window === "undefined") return undefined;
679
+
680
+ const modalWidth = 480;
681
+ const estimatedModalHeight = 270;
682
+ const margin = 16;
683
+ const left = clampNumber(
684
+ anchorPoint.x,
685
+ margin + modalWidth / 2,
686
+ window.innerWidth - margin - modalWidth / 2,
687
+ );
688
+ const top = clampNumber(
689
+ anchorPoint.y + 12,
690
+ margin,
691
+ window.innerHeight - margin - estimatedModalHeight,
692
+ );
693
+
694
+ return { left, top, transform: "translateX(-50%)" };
695
+ }
696
+
697
+ function AskAgentModal({
698
+ selectionLabel,
699
+ anchorPoint = null,
700
+ onSubmit,
701
+ onClose,
702
+ }: {
703
+ selectionLabel: string;
704
+ anchorPoint?: AgentModalAnchorPoint | null;
705
+ onSubmit: (instruction: string) => void;
706
+ onClose: () => void;
707
+ }) {
708
+ const [value, setValue] = useState("");
709
+ const inputRef = useRef<HTMLTextAreaElement>(null);
710
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
711
+
712
+ useMountEffect(() => {
713
+ requestAnimationFrame(() => inputRef.current?.focus());
714
+ });
715
+
716
+ const handleSubmit = () => {
717
+ if (!value.trim()) return;
718
+ onSubmit(value.trim());
719
+ };
720
+
721
+ return (
722
+ <div
723
+ className={
724
+ anchorPoint
725
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
726
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
727
+ }
728
+ onClick={onClose}
729
+ >
730
+ <div
731
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
732
+ anchorPoint ? "fixed" : ""
733
+ }`}
734
+ style={modalPositionStyle}
735
+ onClick={(e) => e.stopPropagation()}
736
+ >
737
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
738
+ <div>
739
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
740
+ <p className="text-xs text-neutral-500 mt-0.5">
741
+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
742
+ </p>
743
+ </div>
744
+ <button
745
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
746
+ onClick={onClose}
747
+ >
748
+ <svg
749
+ width="14"
750
+ height="14"
751
+ viewBox="0 0 24 24"
752
+ fill="none"
753
+ stroke="currentColor"
754
+ strokeWidth="2"
755
+ strokeLinecap="round"
756
+ >
757
+ <line x1="18" y1="6" x2="6" y2="18" />
758
+ <line x1="6" y1="6" x2="18" y2="18" />
759
+ </svg>
760
+ </button>
761
+ </div>
762
+ <div className="px-5 py-4">
763
+ <textarea
764
+ ref={inputRef}
765
+ 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"
766
+ placeholder="Describe what you want to change…"
767
+ value={value}
768
+ onChange={(e) => setValue(e.target.value)}
769
+ onKeyDown={(e) => {
770
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
771
+ if (e.key === "Escape") onClose();
772
+ }}
773
+ />
774
+ </div>
775
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
776
+ <span className="text-[11px] text-neutral-600">
777
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
778
+ </span>
779
+ <button
780
+ 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"
781
+ disabled={!value.trim()}
782
+ onClick={handleSubmit}
783
+ >
784
+ Copy prompt
785
+ </button>
786
+ </div>
787
+ </div>
788
+ </div>
789
+ );
790
+ }
791
+
69
792
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
70
793
  image: 3,
71
794
  video: 5,
@@ -144,6 +867,7 @@ export function StudioApp() {
144
867
  });
145
868
 
146
869
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
870
+ const [projectDir, setProjectDir] = useState<string | null>(null);
147
871
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
148
872
  const [fileTree, setFileTree] = useState<string[]>([]);
149
873
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -157,6 +881,28 @@ export function StudioApp() {
157
881
  const [rightWidth, setRightWidth] = useState(400);
158
882
  const [leftCollapsed, setLeftCollapsed] = useState(false);
159
883
  const [rightCollapsed, setRightCollapsed] = useState(true);
884
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
885
+ const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
886
+ const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
887
+ const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
888
+ const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
889
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
890
+ string | undefined
891
+ >();
892
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
893
+ null,
894
+ );
895
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
896
+ const [agentModalOpen, setAgentModalOpen] = useState(false);
897
+ const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
898
+ const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
899
+ const [compositionLoading, setCompositionLoading] = useState(true);
900
+ const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
901
+ const refreshPreviewDocumentVersion = useCallback(() => {
902
+ setPreviewDocumentVersion((version) => version + 1);
903
+ window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
904
+ window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
905
+ }, []);
160
906
  // Auto-enter caption edit mode when the iframe contains .caption-group elements.
161
907
  // This is a subscription to external events (postMessage from runtime) — useEffect
162
908
  // is appropriate here. The runtime fires "state"/"timeline" messages after all
@@ -283,7 +1029,12 @@ export function StudioApp() {
283
1029
  const dragCounterRef = useRef(0);
284
1030
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
285
1031
  const lastBlockedTimelineToastAtRef = useRef(0);
1032
+ const lastBlockedDomMoveToastAtRef = useRef(0);
1033
+ const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
286
1034
  const previewHotkeyWindowRef = useRef<Window | null>(null);
1035
+ const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
1036
+ const leftSidebarRef = useRef<LeftSidebarHandle>(null);
1037
+ const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
287
1038
  const panelDragRef = useRef<{
288
1039
  side: "left" | "right";
289
1040
  startX: number;
@@ -294,11 +1045,15 @@ export function StudioApp() {
294
1045
  const activePreviewUrl = activeCompPath
295
1046
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
296
1047
  : null;
1048
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
297
1049
  const zoomMode = usePlayerStore((s) => s.zoomMode);
298
1050
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
299
1051
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
300
1052
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
1053
+ const currentTime = usePlayerStore((s) => s.currentTime);
301
1054
  const timelineElements = usePlayerStore((s) => s.elements);
1055
+ const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
1056
+ const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
302
1057
  const timelineDuration = usePlayerStore((s) => s.duration);
303
1058
  const effectiveTimelineDuration = useMemo(() => {
304
1059
  const maxEnd =
@@ -346,34 +1101,31 @@ export function StudioApp() {
346
1101
  [toggleTimelineVisibility],
347
1102
  );
348
1103
 
349
- useMountEffect(() => {
350
- window.addEventListener("keydown", handleTimelineToggleHotkey);
351
- return () => {
352
- window.removeEventListener("keydown", handleTimelineToggleHotkey);
353
- };
354
- });
1104
+ const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
1105
+ handleAppKeyDownRef.current?.(event);
1106
+ }, []);
355
1107
 
356
1108
  const syncPreviewTimelineHotkey = useCallback(
357
1109
  (iframe: HTMLIFrameElement | null) => {
358
1110
  const nextWindow = iframe?.contentWindow ?? null;
359
1111
  if (previewHotkeyWindowRef.current === nextWindow) return;
360
1112
  if (previewHotkeyWindowRef.current) {
361
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1113
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
362
1114
  }
363
1115
  previewHotkeyWindowRef.current = nextWindow;
364
- nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
1116
+ nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
365
1117
  },
366
- [handleTimelineToggleHotkey],
1118
+ [previewAppKeyDownHandler],
367
1119
  );
368
1120
 
369
1121
  useEffect(
370
1122
  () => () => {
371
1123
  if (previewHotkeyWindowRef.current) {
372
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1124
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
373
1125
  previewHotkeyWindowRef.current = null;
374
1126
  }
375
1127
  },
376
- [handleTimelineToggleHotkey],
1128
+ [previewAppKeyDownHandler],
377
1129
  );
378
1130
 
379
1131
  const renderClipContent = useCallback(
@@ -400,7 +1152,6 @@ export function StudioApp() {
400
1152
  label={getTimelineElementLabel(el)}
401
1153
  labelColor={style.label}
402
1154
  accentColor={style.clip}
403
- selector={el.selector}
404
1155
  seekTime={0}
405
1156
  duration={el.duration}
406
1157
  />
@@ -417,6 +1168,7 @@ export function StudioApp() {
417
1168
  labelColor={style.label}
418
1169
  accentColor={style.clip}
419
1170
  selector={el.selector}
1171
+ selectorIndex={el.selectorIndex}
420
1172
  seekTime={el.start}
421
1173
  duration={el.duration}
422
1174
  />
@@ -478,6 +1230,7 @@ export function StudioApp() {
478
1230
  labelColor={style.label}
479
1231
  accentColor={style.clip}
480
1232
  selector={el.selector}
1233
+ selectorIndex={el.selectorIndex}
481
1234
  seekTime={el.start}
482
1235
  duration={el.duration}
483
1236
  />
@@ -562,16 +1315,80 @@ export function StudioApp() {
562
1315
  const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
563
1316
  const [linting, setLinting] = useState(false);
564
1317
  const [refreshKey, setRefreshKey] = useState(0);
1318
+ const [, setStudioMotionRevision] = useState(0);
565
1319
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
566
1320
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
567
1321
  const projectIdRef = useRef(projectId);
568
1322
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
569
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
+ }, []);
570
1365
 
571
1366
  // Listen for external file changes (user editing HTML outside the editor).
572
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.
573
1371
  useMountEffect(() => {
574
- const handler = () => {
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;
575
1392
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
576
1393
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
577
1394
  };
@@ -585,6 +1402,21 @@ export function StudioApp() {
585
1402
  return () => es.close();
586
1403
  });
587
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]);
588
1420
 
589
1421
  // Load file tree when projectId changes.
590
1422
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -596,10 +1428,13 @@ export function StudioApp() {
596
1428
  let cancelled = false;
597
1429
  fetch(`/api/projects/${projectId}`)
598
1430
  .then((r) => r.json())
599
- .then((data: { files?: string[] }) => {
1431
+ .then((data: { files?: string[]; dir?: string }) => {
600
1432
  if (!cancelled && data.files) setFileTree(data.files);
1433
+ if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
601
1434
  })
602
- .catch(() => {});
1435
+ .catch(() => {
1436
+ if (!cancelled) setProjectDir(null);
1437
+ });
603
1438
  return () => {
604
1439
  cancelled = true;
605
1440
  };
@@ -627,29 +1462,76 @@ export function StudioApp() {
627
1462
 
628
1463
  const editingPathRef = useRef(editingFile?.path);
629
1464
  editingPathRef.current = editingFile?.path;
1465
+ const editHistory = usePersistentEditHistory({ projectId });
630
1466
 
631
- const handleContentChange = useCallback((content: string) => {
1467
+ const readProjectFile = useCallback(async (path: string): Promise<string> => {
632
1468
  const pid = projectIdRef.current;
633
- if (!pid) return;
634
- const path = editingPathRef.current;
635
- if (!path) return;
636
-
637
- // Debounce the server write (600ms)
638
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
639
- saveTimerRef.current = setTimeout(() => {
640
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
641
- method: "PUT",
642
- headers: { "Content-Type": "text/plain" },
643
- body: content,
644
- })
645
- .then(() => {
646
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
647
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
648
- })
649
- .catch(() => {});
650
- }, 600);
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 : "";
651
1499
  }, []);
652
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
+ // Suppress the file-change watcher echo — the save callback triggers
1512
+ // its own refresh, so a second one from the watcher causes a double-reload
1513
+ // race that can leave the player in a non-playable state.
1514
+ domEditSaveTimestampRef.current = Date.now();
1515
+ saveProjectFilesWithHistory({
1516
+ projectId: pid,
1517
+ label: "Edit source",
1518
+ kind: "source",
1519
+ coalesceKey: `source:${path}`,
1520
+ files: { [path]: content },
1521
+ readFile: readProjectFile,
1522
+ writeFile: writeProjectFile,
1523
+ recordEdit: editHistory.recordEdit,
1524
+ })
1525
+ .then(() => {
1526
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1527
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
1528
+ })
1529
+ .catch(() => {});
1530
+ }, 600);
1531
+ },
1532
+ [editHistory.recordEdit, readProjectFile, writeProjectFile],
1533
+ );
1534
+
653
1535
  const handleTimelineElementMove = useCallback(
654
1536
  async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
655
1537
  const pid = projectIdRef.current;
@@ -728,25 +1610,20 @@ export function StudioApp() {
728
1610
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
729
1611
  }
730
1612
 
731
- const saveResponse = await fetch(
732
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
733
- {
734
- method: "PUT",
735
- headers: { "Content-Type": "text/plain" },
736
- body: patchedContent,
737
- },
738
- );
739
- if (!saveResponse.ok) {
740
- throw new Error(`Failed to save ${targetPath}`);
741
- }
742
-
743
- if (editingPathRef.current === targetPath) {
744
- setEditingFile({ path: targetPath, content: patchedContent });
745
- }
1613
+ domEditSaveTimestampRef.current = Date.now();
1614
+ await saveProjectFilesWithHistory({
1615
+ projectId: pid,
1616
+ label: "Move timeline clip",
1617
+ kind: "timeline",
1618
+ files: { [targetPath]: patchedContent },
1619
+ readFile: async () => originalContent,
1620
+ writeFile: writeProjectFile,
1621
+ recordEdit: editHistory.recordEdit,
1622
+ });
746
1623
 
747
1624
  setRefreshKey((k) => k + 1);
748
1625
  },
749
- [activeCompPath, timelineElements],
1626
+ [activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
750
1627
  );
751
1628
 
752
1629
  const handleTimelineElementResize = useCallback(
@@ -818,25 +1695,20 @@ export function StudioApp() {
818
1695
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
819
1696
  }
820
1697
 
821
- const saveResponse = await fetch(
822
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
823
- {
824
- method: "PUT",
825
- headers: { "Content-Type": "text/plain" },
826
- body: patchedContent,
827
- },
828
- );
829
- if (!saveResponse.ok) {
830
- throw new Error(`Failed to save ${targetPath}`);
831
- }
832
-
833
- if (editingPathRef.current === targetPath) {
834
- setEditingFile({ path: targetPath, content: patchedContent });
835
- }
1698
+ domEditSaveTimestampRef.current = Date.now();
1699
+ await saveProjectFilesWithHistory({
1700
+ projectId: pid,
1701
+ label: "Resize timeline clip",
1702
+ kind: "timeline",
1703
+ files: { [targetPath]: patchedContent },
1704
+ readFile: async () => originalContent,
1705
+ writeFile: writeProjectFile,
1706
+ recordEdit: editHistory.recordEdit,
1707
+ });
836
1708
 
837
1709
  setRefreshKey((k) => k + 1);
838
1710
  },
839
- [activeCompPath],
1711
+ [activeCompPath, editHistory.recordEdit, writeProjectFile],
840
1712
  );
841
1713
 
842
1714
  const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
@@ -852,6 +1724,7 @@ export function StudioApp() {
852
1724
 
853
1725
  const currentTime = usePlayerStore.getState().currentTime;
854
1726
  setCaptureFrameTime(currentTime);
1727
+ await waitForPendingDomEditSaves();
855
1728
  const href = buildFrameCaptureUrl({
856
1729
  projectId,
857
1730
  compositionPath: activeCompPath,
@@ -878,7 +1751,7 @@ export function StudioApp() {
878
1751
  showToast(message);
879
1752
  }
880
1753
  },
881
- [activeCompPath, projectId, showToast],
1754
+ [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
882
1755
  );
883
1756
 
884
1757
  const handleTimelineElementDelete = useCallback(
@@ -961,48 +1834,1751 @@ export function StudioApp() {
961
1834
  });
962
1835
  }
963
1836
 
964
- const saveResponse = await fetch(
965
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
966
- {
967
- method: "PUT",
968
- headers: { "Content-Type": "text/plain" },
969
- body: patchedContent,
970
- },
971
- );
972
- if (!saveResponse.ok) {
973
- throw new Error(`Failed to save ${targetPath}`);
974
- }
1837
+ domEditSaveTimestampRef.current = Date.now();
1838
+ await saveProjectFilesWithHistory({
1839
+ projectId: pid,
1840
+ label: "Delete timeline clip",
1841
+ kind: "timeline",
1842
+ files: { [targetPath]: patchedContent },
1843
+ readFile: async () => originalContent,
1844
+ writeFile: writeProjectFile,
1845
+ recordEdit: editHistory.recordEdit,
1846
+ });
975
1847
 
976
- if (editingPathRef.current === targetPath) {
977
- setEditingFile({ path: targetPath, content: patchedContent });
978
- }
1848
+ usePlayerStore
1849
+ .getState()
1850
+ .setElements(
1851
+ timelineElements.filter(
1852
+ (timelineElement) =>
1853
+ (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
1854
+ ),
1855
+ );
1856
+ usePlayerStore.getState().setSelectedElementId(null);
1857
+ setRefreshKey((k) => k + 1);
1858
+ } catch (error) {
1859
+ const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
1860
+ showToast(message);
1861
+ }
1862
+ },
1863
+ [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1864
+ );
1865
+
1866
+ const handleDomEditElementDelete = useCallback(
1867
+ async (selection: DomEditSelection) => {
1868
+ const pid = projectIdRef.current;
1869
+ if (!pid) return;
1870
+
1871
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1872
+ try {
1873
+ const response = await fetch(
1874
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1875
+ );
1876
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
1877
+
1878
+ const data = (await response.json()) as { content?: string };
1879
+ const originalContent = data.content;
1880
+ if (typeof originalContent !== "string")
1881
+ throw new Error(`Missing file contents for ${targetPath}`);
1882
+
1883
+ const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
1884
+ ? {
1885
+ id: selection.id,
1886
+ selector: selection.selector,
1887
+ selectorIndex: selection.selectorIndex,
1888
+ }
1889
+ : selection.selector
1890
+ ? { selector: selection.selector, selectorIndex: selection.selectorIndex }
1891
+ : ({} as never);
1892
+ if (!patchTarget.id && !patchTarget.selector) {
1893
+ throw new Error("Selected element has no patchable target");
1894
+ }
1895
+
1896
+ const removeResponse = await fetch(
1897
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
1898
+ {
1899
+ method: "POST",
1900
+ headers: { "Content-Type": "application/json" },
1901
+ body: JSON.stringify({ target: patchTarget }),
1902
+ },
1903
+ );
1904
+ if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
1905
+
1906
+ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
1907
+ const patchedContent =
1908
+ typeof removeData.content === "string" ? removeData.content : originalContent;
1909
+
1910
+ domEditSaveTimestampRef.current = Date.now();
1911
+ await saveProjectFilesWithHistory({
1912
+ projectId: pid,
1913
+ label: "Delete element",
1914
+ kind: "timeline",
1915
+ files: { [targetPath]: patchedContent },
1916
+ readFile: async () => originalContent,
1917
+ writeFile: writeProjectFile,
1918
+ recordEdit: editHistory.recordEdit,
1919
+ });
1920
+
1921
+ domEditSelectionRef.current = null;
1922
+ domEditGroupSelectionsRef.current = [];
1923
+ setDomEditSelection(null);
1924
+ setDomEditGroupSelections([]);
1925
+ usePlayerStore.getState().setSelectedElementId(null);
1926
+ setRefreshKey((k) => k + 1);
1927
+ } catch (error) {
1928
+ const message = error instanceof Error ? error.message : "Failed to delete element";
1929
+ showToast(message);
1930
+ }
1931
+ },
1932
+ [activeCompPath, editHistory.recordEdit, showToast, writeProjectFile],
1933
+ );
1934
+
1935
+ // ── Consolidated keyboard shortcuts ────────────────────────────────
1936
+ // All app-level window keydown handlers live here.
1937
+ // Component-scoped shortcuts (playback J/K/L/Space, caption nudge)
1938
+ // stay in their respective hooks.
1939
+ const handleToggleRef = useRef(handleTimelineToggleHotkey);
1940
+ handleToggleRef.current = handleTimelineToggleHotkey;
1941
+ const handleDeleteRef = useRef(handleTimelineElementDelete);
1942
+ handleDeleteRef.current = handleTimelineElementDelete;
1943
+ const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
1944
+ handleDomEditDeleteRef.current = handleDomEditElementDelete;
1945
+
1946
+ handleAppKeyDownRef.current = (event: KeyboardEvent) => {
1947
+ // Shift+T — toggle timeline
1948
+ handleToggleRef.current(event);
1949
+
1950
+ // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
1951
+ if (event.metaKey || event.ctrlKey) {
1952
+ if (!shouldIgnoreHistoryShortcut(event.target)) {
1953
+ const key = event.key.toLowerCase();
1954
+ if (key === "z" && !event.shiftKey) {
1955
+ event.preventDefault();
1956
+ void handleUndoRef.current();
1957
+ return;
1958
+ }
1959
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
1960
+ event.preventDefault();
1961
+ void handleRedoRef.current();
1962
+ return;
1963
+ }
1964
+ }
1965
+
1966
+ // Cmd/Ctrl+1 — sidebar: Compositions tab
1967
+ if (event.key === "1") {
1968
+ event.preventDefault();
1969
+ leftSidebarRef.current?.selectTab("compositions");
1970
+ return;
1971
+ }
1972
+
1973
+ // Cmd/Ctrl+2 — sidebar: Assets tab
1974
+ if (event.key === "2") {
1975
+ event.preventDefault();
1976
+ leftSidebarRef.current?.selectTab("assets");
1977
+ return;
1978
+ }
1979
+ }
1980
+
1981
+ // Delete / Backspace — remove selected element (timeline clip or preview selection)
1982
+ if (
1983
+ (event.key === "Delete" || event.key === "Backspace") &&
1984
+ !event.metaKey &&
1985
+ !event.ctrlKey &&
1986
+ !event.altKey &&
1987
+ !isEditableTarget(event.target)
1988
+ ) {
1989
+ const { selectedElementId, elements } = usePlayerStore.getState();
1990
+ if (selectedElementId) {
1991
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
1992
+ if (element) {
1993
+ event.preventDefault();
1994
+ void handleDeleteRef.current(element);
1995
+ return;
1996
+ }
1997
+ }
1998
+ const domSelection = domEditSelectionRef.current;
1999
+ if (domSelection) {
2000
+ event.preventDefault();
2001
+ void handleDomEditDeleteRef.current(domSelection);
2002
+ }
2003
+ }
2004
+ };
2005
+
2006
+ // eslint-disable-next-line no-restricted-syntax
2007
+ useEffect(() => {
2008
+ function handleAppKeyDown(event: KeyboardEvent) {
2009
+ handleAppKeyDownRef.current?.(event);
2010
+ }
2011
+ window.addEventListener("keydown", handleAppKeyDown, true);
2012
+ return () => window.removeEventListener("keydown", handleAppKeyDown, true);
2013
+ }, []);
2014
+
2015
+ const handleBlockedTimelineEdit = useCallback(
2016
+ (_element: TimelineElement) => {
2017
+ const now = Date.now();
2018
+ if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
2019
+ lastBlockedTimelineToastAtRef.current = now;
2020
+ showToast("This clip can’t be moved or resized from the timeline yet.", "info");
2021
+ },
2022
+ [showToast],
2023
+ );
2024
+
2025
+ const handleBlockedDomMove = useCallback(
2026
+ (selection: DomEditSelection) => {
2027
+ const now = Date.now();
2028
+ if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
2029
+ lastBlockedDomMoveToastAtRef.current = now;
2030
+ showToast(
2031
+ selection.capabilities.reasonIfDisabled ??
2032
+ "This element can’t be adjusted directly from the preview.",
2033
+ "info",
2034
+ );
2035
+ },
2036
+ [showToast],
2037
+ );
2038
+
2039
+ const applyDomSelection = useCallback(
2040
+ (
2041
+ selection: DomEditSelection | null,
2042
+ options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
2043
+ ) => {
2044
+ setAgentPromptTagSnippet(undefined);
2045
+ setAgentPromptSelectionContext(undefined);
2046
+ setAgentModalAnchorPoint(null);
2047
+ setCopiedAgentPrompt(false);
2048
+ if (!selection) {
2049
+ domEditSelectionRef.current = null;
2050
+ domEditGroupSelectionsRef.current = [];
2051
+ setDomEditSelection(null);
2052
+ setDomEditGroupSelections([]);
2053
+ setSelectedTimelineElementId(null);
2054
+ return;
2055
+ }
2056
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
2057
+ domEditSelectionRef.current = null;
2058
+ domEditGroupSelectionsRef.current = [];
2059
+ setDomEditSelection(null);
2060
+ setDomEditGroupSelections([]);
2061
+ setSelectedTimelineElementId(null);
2062
+ return;
2063
+ }
2064
+
2065
+ const isAdditiveSelection = Boolean(options?.additive);
2066
+ const currentSelection = domEditSelectionRef.current;
2067
+ const previousGroup = domEditGroupSelectionsRef.current;
2068
+ const currentGroup = isAdditiveSelection
2069
+ ? seedDomEditGroupWithSelection(previousGroup, currentSelection)
2070
+ : previousGroup;
2071
+ const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
2072
+ const nextGroup = options?.preserveGroup
2073
+ ? replaceDomEditGroupSelection(currentGroup, selection)
2074
+ : isAdditiveSelection
2075
+ ? toggleDomEditGroupSelection(currentGroup, selection)
2076
+ : [selection];
2077
+ const nextSelection = options?.preserveGroup
2078
+ ? selection
2079
+ : isAdditiveSelection && wasInGroup
2080
+ ? domEditSelectionsTargetSame(currentSelection, selection)
2081
+ ? (nextGroup[0] ?? null)
2082
+ : domEditSelectionInGroup(nextGroup, currentSelection)
2083
+ ? currentSelection
2084
+ : (nextGroup[0] ?? null)
2085
+ : selection;
2086
+
2087
+ domEditSelectionRef.current = nextSelection;
2088
+ domEditGroupSelectionsRef.current = nextGroup;
2089
+ setDomEditSelection(nextSelection);
2090
+ setDomEditGroupSelections(nextGroup);
2091
+
2092
+ if (nextSelection) {
2093
+ if (options?.revealPanel !== false) {
2094
+ setRightCollapsed(false);
2095
+ setRightPanelTab("design");
2096
+ }
2097
+ const nextSelectedTimelineId = findMatchingTimelineElementId(
2098
+ nextSelection,
2099
+ timelineElements,
2100
+ );
2101
+ setSelectedTimelineElementId(nextSelectedTimelineId);
2102
+ return;
2103
+ }
2104
+
2105
+ setSelectedTimelineElementId(null);
2106
+ },
2107
+ [setSelectedTimelineElementId, timelineElements],
2108
+ );
2109
+
2110
+ const clearDomSelection = useCallback(() => {
2111
+ applyDomSelection(null, { revealPanel: false });
2112
+ }, [applyDomSelection]);
2113
+
2114
+ const readHistoryProjectFile = useCallback(
2115
+ async (path: string): Promise<string> => {
2116
+ return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
2117
+ ? readOptionalProjectFile(path)
2118
+ : readProjectFile(path);
2119
+ },
2120
+ [readOptionalProjectFile, readProjectFile],
2121
+ );
2122
+
2123
+ const writeHistoryProjectFile = useCallback(
2124
+ async (path: string, content: string): Promise<void> => {
2125
+ await writeProjectFile(path, content);
2126
+ if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
2127
+ domEditSaveTimestampRef.current = Date.now();
2128
+ }
2129
+ },
2130
+ [writeProjectFile],
2131
+ );
2132
+
2133
+ const applyCurrentStudioManualEditsToPreview = useCallback(
2134
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
2135
+ if (!iframe) return;
2136
+ let doc: Document | null = null;
2137
+ try {
2138
+ doc = iframe.contentDocument;
2139
+ } catch {
2140
+ return;
2141
+ }
2142
+ if (!doc) return;
2143
+ const previewDoc = doc;
2144
+
2145
+ const applyManifest = () => {
2146
+ applyStudioManualEditManifest(
2147
+ previewDoc,
2148
+ studioManualEditManifestRef.current,
2149
+ activeCompPathRef.current,
2150
+ );
2151
+ };
2152
+ const applyAndInstallSeekHooks = () => {
2153
+ applyManifest();
2154
+ if (iframe.contentWindow) {
2155
+ installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
2156
+ }
2157
+ };
2158
+
2159
+ const win = iframe.contentWindow;
2160
+ applyAndInstallSeekHooks();
2161
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
2162
+ win?.setTimeout?.(applyAndInstallSeekHooks, 80);
2163
+ win?.setTimeout?.(applyAndInstallSeekHooks, 250);
2164
+ win?.setTimeout?.(applyAndInstallSeekHooks, 500);
2165
+ win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
2166
+ win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
2167
+ },
2168
+ [],
2169
+ );
2170
+
2171
+ const applyStudioManualEditsToPreview = useCallback(
2172
+ async (
2173
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
2174
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
2175
+ ) => {
2176
+ const readRevision = studioManualEditRevisionRef.current;
2177
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
2178
+ if (!readFromDiskFirst) {
2179
+ applyCurrentStudioManualEditsToPreview(iframe);
2180
+ }
2181
+ let content: string;
2182
+ try {
2183
+ content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
2184
+ } catch (error) {
2185
+ const message =
2186
+ error instanceof Error ? error.message : "Failed to read manual edit manifest";
2187
+ showToast(message);
2188
+ if (readFromDiskFirst) {
2189
+ applyCurrentStudioManualEditsToPreview(iframe);
2190
+ }
2191
+ return;
2192
+ }
2193
+ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
2194
+ studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
2195
+ if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
2196
+ applyCurrentStudioManualEditsToPreview(iframe);
2197
+ return;
2198
+ }
2199
+ if (readFromDiskFirst) {
2200
+ applyCurrentStudioManualEditsToPreview(iframe);
2201
+ }
2202
+ },
2203
+ [applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
2204
+ );
2205
+ applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
2206
+
2207
+ const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
2208
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
2209
+ applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
2210
+ [applyStudioManualEditsToPreview],
2211
+ );
2212
+
2213
+ const applyCurrentStudioMotionToPreview = useCallback(
2214
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
2215
+ if (!iframe) return;
2216
+ let doc: Document | null = null;
2217
+ try {
2218
+ doc = iframe.contentDocument;
2219
+ } catch {
2220
+ return;
2221
+ }
2222
+ if (!doc) return;
2223
+ const previewDoc = doc;
2224
+
2225
+ const applyManifest = () => {
2226
+ applyStudioMotionManifest(
2227
+ previewDoc,
2228
+ studioMotionManifestRef.current,
2229
+ activeCompPathRef.current,
2230
+ );
2231
+ };
2232
+ const applyAndInstallSeekHooks = () => {
2233
+ applyManifest();
2234
+ if (iframe.contentWindow) {
2235
+ installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
2236
+ }
2237
+ };
2238
+
2239
+ const win = iframe.contentWindow;
2240
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
2241
+ win?.setTimeout?.(applyAndInstallSeekHooks, 120);
2242
+ },
2243
+ [],
2244
+ );
2245
+
2246
+ const applyStudioMotionToPreview = useCallback(
2247
+ async (
2248
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
2249
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
2250
+ ) => {
2251
+ const readRevision = studioMotionRevisionRef.current;
2252
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
2253
+ if (!readFromDiskFirst) {
2254
+ applyCurrentStudioMotionToPreview(iframe);
2255
+ }
2256
+ let content: string;
2257
+ try {
2258
+ content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
2259
+ } catch (error) {
2260
+ const message = error instanceof Error ? error.message : "Failed to read motion manifest";
2261
+ showToast(message);
2262
+ if (readFromDiskFirst) {
2263
+ applyCurrentStudioMotionToPreview(iframe);
2264
+ }
2265
+ return;
2266
+ }
2267
+ if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
2268
+ studioMotionManifestRef.current = parseStudioMotionManifest(content);
2269
+ if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
2270
+ setStudioMotionRevision((revision) => revision + 1);
2271
+ applyCurrentStudioMotionToPreview(iframe);
2272
+ return;
2273
+ }
2274
+ if (readFromDiskFirst) {
2275
+ applyCurrentStudioMotionToPreview(iframe);
2276
+ }
2277
+ },
2278
+ [applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
2279
+ );
2280
+ applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
2281
+
2282
+ const applyStudioMotionToPreviewAfterRefresh = useCallback(
2283
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
2284
+ applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
2285
+ [applyStudioMotionToPreview],
2286
+ );
2287
+
2288
+ const commitStudioManualEditManifestOptimistically = useCallback(
2289
+ (
2290
+ updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
2291
+ options: { label: string; coalesceKey: string },
2292
+ ) => {
2293
+ const previousManifest = studioManualEditManifestRef.current;
2294
+ const nextManifest = updateManifest(previousManifest);
2295
+ const previousContent = serializeStudioManualEditManifest(previousManifest);
2296
+ const nextContent = serializeStudioManualEditManifest(nextManifest);
2297
+ if (nextContent === previousContent) {
2298
+ return;
2299
+ }
2300
+
2301
+ const revision = studioManualEditRevisionRef.current + 1;
2302
+ studioManualEditRevisionRef.current = revision;
2303
+ studioManualEditManifestRef.current = nextManifest;
2304
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2305
+
2306
+ const save = async () => {
2307
+ const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
2308
+ const diskManifest = parseStudioManualEditManifest(originalContent);
2309
+ const nextDiskManifest = updateManifest(diskManifest);
2310
+ const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
2311
+ if (nextDiskContent === originalContent) {
2312
+ return;
2313
+ }
2314
+
2315
+ const pid = projectIdRef.current;
2316
+ if (!pid) throw new Error("No active project");
2317
+ domEditSaveTimestampRef.current = Date.now();
2318
+ await saveProjectFilesWithHistory({
2319
+ projectId: pid,
2320
+ label: options.label,
2321
+ kind: "manual",
2322
+ coalesceKey: options.coalesceKey,
2323
+ files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
2324
+ readFile: async () => originalContent,
2325
+ writeFile: writeProjectFile,
2326
+ recordEdit: editHistory.recordEdit,
2327
+ });
2328
+ domEditSaveTimestampRef.current = Date.now();
2329
+
2330
+ if (studioManualEditRevisionRef.current === revision) {
2331
+ studioManualEditManifestRef.current = nextDiskManifest;
2332
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2333
+ }
2334
+ };
2335
+
2336
+ void queueDomEditSave(save).catch((error) => {
2337
+ if (studioManualEditRevisionRef.current === revision) {
2338
+ studioManualEditRevisionRef.current += 1;
2339
+ studioManualEditManifestRef.current = previousManifest;
2340
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2341
+ }
2342
+ const message = error instanceof Error ? error.message : "Failed to save manual edit";
2343
+ showToast(message);
2344
+ });
2345
+ },
2346
+ [
2347
+ applyCurrentStudioManualEditsToPreview,
2348
+ editHistory.recordEdit,
2349
+ queueDomEditSave,
2350
+ readOptionalProjectFile,
2351
+ showToast,
2352
+ writeProjectFile,
2353
+ ],
2354
+ );
2355
+
2356
+ const commitStudioMotionManifestOptimistically = useCallback(
2357
+ (
2358
+ updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
2359
+ options: { label: string; coalesceKey: string },
2360
+ ) => {
2361
+ const previousManifest = studioMotionManifestRef.current;
2362
+ const nextManifest = updateManifest(previousManifest);
2363
+ const previousContent = serializeStudioMotionManifest(previousManifest);
2364
+ const nextContent = serializeStudioMotionManifest(nextManifest);
2365
+ if (nextContent === previousContent) {
2366
+ return;
2367
+ }
2368
+
2369
+ const revision = studioMotionRevisionRef.current + 1;
2370
+ studioMotionRevisionRef.current = revision;
2371
+ studioMotionManifestRef.current = nextManifest;
2372
+ setStudioMotionRevision((current) => current + 1);
2373
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2374
+
2375
+ const save = async () => {
2376
+ const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
2377
+ const diskManifest = parseStudioMotionManifest(originalContent);
2378
+ const nextDiskManifest = updateManifest(diskManifest);
2379
+ const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
2380
+ if (nextDiskContent === originalContent) {
2381
+ return;
2382
+ }
2383
+
2384
+ const pid = projectIdRef.current;
2385
+ if (!pid) throw new Error("No active project");
2386
+ domEditSaveTimestampRef.current = Date.now();
2387
+ await saveProjectFilesWithHistory({
2388
+ projectId: pid,
2389
+ label: options.label,
2390
+ kind: "motion",
2391
+ coalesceKey: options.coalesceKey,
2392
+ files: { [STUDIO_MOTION_PATH]: nextDiskContent },
2393
+ readFile: async () => originalContent,
2394
+ writeFile: writeProjectFile,
2395
+ recordEdit: editHistory.recordEdit,
2396
+ });
2397
+ domEditSaveTimestampRef.current = Date.now();
2398
+
2399
+ if (studioMotionRevisionRef.current === revision) {
2400
+ studioMotionManifestRef.current = nextDiskManifest;
2401
+ setStudioMotionRevision((current) => current + 1);
2402
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2403
+ }
2404
+ };
2405
+
2406
+ void queueDomEditSave(save).catch((error) => {
2407
+ if (studioMotionRevisionRef.current === revision) {
2408
+ studioMotionRevisionRef.current += 1;
2409
+ studioMotionManifestRef.current = previousManifest;
2410
+ setStudioMotionRevision((current) => current + 1);
2411
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2412
+ }
2413
+ const message = error instanceof Error ? error.message : "Failed to save motion edit";
2414
+ showToast(message);
2415
+ });
2416
+ },
2417
+ [
2418
+ applyCurrentStudioMotionToPreview,
2419
+ editHistory.recordEdit,
2420
+ queueDomEditSave,
2421
+ readOptionalProjectFile,
2422
+ showToast,
2423
+ writeProjectFile,
2424
+ ],
2425
+ );
2426
+
2427
+ const syncHistoryPreviewAfterApply = useCallback(
2428
+ async (paths: string[] | undefined) => {
2429
+ const changedPaths = paths ?? [];
2430
+ const manualManifestOnly =
2431
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
2432
+ const motionManifestOnly =
2433
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
2434
+
2435
+ if (manualManifestOnly) {
2436
+ await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
2437
+ return;
2438
+ }
2439
+ if (motionManifestOnly) {
2440
+ await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
2441
+ return;
2442
+ }
2443
+
2444
+ setRefreshKey((key) => key + 1);
2445
+ },
2446
+ [applyStudioManualEditsToPreview, applyStudioMotionToPreview],
2447
+ );
2448
+
2449
+ const handleUndo = useCallback(async () => {
2450
+ await waitForPendingDomEditSaves();
2451
+ const result = await editHistory.undo({
2452
+ readFile: readHistoryProjectFile,
2453
+ writeFile: writeHistoryProjectFile,
2454
+ });
2455
+ if (!result.ok && result.reason === "content-mismatch") {
2456
+ showToast("File changed outside Studio. Undo history was not applied.", "info");
2457
+ return;
2458
+ }
2459
+ if (result.ok && result.label) {
2460
+ clearDomSelection();
2461
+ await syncHistoryPreviewAfterApply(result.paths);
2462
+ showToast(`Undid ${result.label}`, "info");
2463
+ }
2464
+ }, [
2465
+ clearDomSelection,
2466
+ editHistory,
2467
+ readHistoryProjectFile,
2468
+ showToast,
2469
+ syncHistoryPreviewAfterApply,
2470
+ waitForPendingDomEditSaves,
2471
+ writeHistoryProjectFile,
2472
+ ]);
2473
+
2474
+ const handleRedo = useCallback(async () => {
2475
+ await waitForPendingDomEditSaves();
2476
+ const result = await editHistory.redo({
2477
+ readFile: readHistoryProjectFile,
2478
+ writeFile: writeHistoryProjectFile,
2479
+ });
2480
+ if (!result.ok && result.reason === "content-mismatch") {
2481
+ showToast("File changed outside Studio. Redo history was not applied.", "info");
2482
+ return;
2483
+ }
2484
+ if (result.ok && result.label) {
2485
+ clearDomSelection();
2486
+ await syncHistoryPreviewAfterApply(result.paths);
2487
+ showToast(`Redid ${result.label}`, "info");
2488
+ }
2489
+ }, [
2490
+ clearDomSelection,
2491
+ editHistory,
2492
+ readHistoryProjectFile,
2493
+ showToast,
2494
+ syncHistoryPreviewAfterApply,
2495
+ waitForPendingDomEditSaves,
2496
+ writeHistoryProjectFile,
2497
+ ]);
2498
+
2499
+ const handleUndoRef = useRef(handleUndo);
2500
+ const handleRedoRef = useRef(handleRedo);
2501
+ handleUndoRef.current = handleUndo;
2502
+ handleRedoRef.current = handleRedo;
2503
+
2504
+ // History hotkey — no longer has its own window listener (consolidated
2505
+ // handler covers it), but kept as a named callback for iframe forwarding.
2506
+ const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
2507
+ if (!(event.metaKey || event.ctrlKey)) return;
2508
+ if (shouldIgnoreHistoryShortcut(event.target)) return;
2509
+ const key = event.key.toLowerCase();
2510
+ if (key === "z" && !event.shiftKey) {
2511
+ event.preventDefault();
2512
+ void handleUndoRef.current();
2513
+ return;
2514
+ }
2515
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
2516
+ event.preventDefault();
2517
+ void handleRedoRef.current();
2518
+ }
2519
+ }, []);
2520
+
2521
+ const syncPreviewHistoryHotkey = useCallback(
2522
+ (iframe: HTMLIFrameElement | null) => {
2523
+ previewHistoryHotkeyCleanupRef.current?.();
2524
+ previewHistoryHotkeyCleanupRef.current = null;
2525
+
2526
+ const win = iframe?.contentWindow ?? null;
2527
+ let doc: Document | null = null;
2528
+ try {
2529
+ doc = iframe?.contentDocument ?? null;
2530
+ } catch {
2531
+ doc = null;
2532
+ }
2533
+ if (!win && !doc) return;
2534
+
2535
+ win?.addEventListener("keydown", handleHistoryHotkey, true);
2536
+ doc?.addEventListener("keydown", handleHistoryHotkey, true);
2537
+ previewHistoryHotkeyCleanupRef.current = () => {
2538
+ win?.removeEventListener("keydown", handleHistoryHotkey, true);
2539
+ doc?.removeEventListener("keydown", handleHistoryHotkey, true);
2540
+ };
2541
+ },
2542
+ [handleHistoryHotkey],
2543
+ );
2544
+
2545
+ useEffect(
2546
+ () => () => {
2547
+ previewHistoryHotkeyCleanupRef.current?.();
2548
+ previewHistoryHotkeyCleanupRef.current = null;
2549
+ },
2550
+ [],
2551
+ );
2552
+
2553
+ const buildDomSelectionFromTarget = useCallback(
2554
+ (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
2555
+ return resolveDomEditSelection(target, {
2556
+ activeCompositionPath: activeCompPath,
2557
+ isMasterView,
2558
+ preferClipAncestor: options?.preferClipAncestor,
2559
+ });
2560
+ },
2561
+ [activeCompPath, isMasterView],
2562
+ );
2563
+
2564
+ const resolveDomSelectionFromPreviewPoint = useCallback(
2565
+ (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2566
+ const iframe = previewIframeRef.current;
2567
+ if (!iframe || captionEditMode) return null;
2568
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2569
+ if (!target) return null;
2570
+ return buildDomSelectionFromTarget(target, {
2571
+ preferClipAncestor: options?.preferClipAncestor,
2572
+ });
2573
+ },
2574
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2575
+ );
2576
+
2577
+ const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
2578
+ if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
2579
+ domEditHoverSelectionRef.current = selection;
2580
+ setDomEditHoverSelection(selection);
2581
+ }, []);
2582
+
2583
+ const buildDomSelectionForTimelineElement = useCallback(
2584
+ (element: TimelineElement): DomEditSelection | null => {
2585
+ const iframe = previewIframeRef.current;
2586
+ let doc: Document | null = null;
2587
+ try {
2588
+ doc = iframe?.contentDocument ?? null;
2589
+ } catch {
2590
+ return null;
2591
+ }
2592
+ if (!doc) return null;
2593
+
2594
+ const targetElement = findElementForTimelineElement(doc, element, {
2595
+ activeCompositionPath: activeCompPath,
2596
+ compIdToSrc,
2597
+ isMasterView,
2598
+ });
2599
+ return targetElement
2600
+ ? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
2601
+ : null;
2602
+ },
2603
+ [activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
2604
+ );
2605
+
2606
+ const inspectedTimelineElement = useMemo(
2607
+ () =>
2608
+ timelineElements.find(
2609
+ (element) => getTimelineElementKey(element) === inspectedTimelineElementId,
2610
+ ) ?? null,
2611
+ [inspectedTimelineElementId, timelineElements],
2612
+ );
2613
+
2614
+ const timelineLayerChildCounts = useMemo(() => {
2615
+ void previewDocumentVersion;
2616
+ const counts = new Map<string, number>();
2617
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
2618
+
2619
+ const key = getTimelineElementKey(inspectedTimelineElement);
2620
+ if (key) {
2621
+ const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2622
+ const count = countDomEditChildLayers(selection?.element, {
2623
+ activeCompositionPath: activeCompPath,
2624
+ isMasterView,
2625
+ });
2626
+ if (count > 0) counts.set(key, count);
2627
+ }
2628
+
2629
+ return counts;
2630
+ }, [
2631
+ activeCompPath,
2632
+ buildDomSelectionForTimelineElement,
2633
+ inspectedTimelineElement,
2634
+ isMasterView,
2635
+ previewDocumentVersion,
2636
+ ]);
2637
+
2638
+ const inspectedTimelineLayers = useMemo(() => {
2639
+ void previewDocumentVersion;
2640
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
2641
+ const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2642
+ return collectDomEditLayerItems(selection?.element, {
2643
+ activeCompositionPath: activeCompPath,
2644
+ isMasterView,
2645
+ });
2646
+ }, [
2647
+ activeCompPath,
2648
+ buildDomSelectionForTimelineElement,
2649
+ inspectedTimelineElement,
2650
+ isMasterView,
2651
+ previewDocumentVersion,
2652
+ ]);
2653
+
2654
+ const selectedTimelineLayerKey = useMemo(
2655
+ () => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
2656
+ [domEditSelection],
2657
+ );
2658
+
2659
+ const handleTimelineElementSelect = useCallback(
2660
+ (element: TimelineElement | null) => {
2661
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2662
+ if (!element) {
2663
+ applyDomSelection(null, { revealPanel: false });
2664
+ setInspectedTimelineElementId(null);
2665
+ return;
2666
+ }
2667
+
2668
+ const selection = buildDomSelectionForTimelineElement(element);
2669
+ if (selection) applyDomSelection(selection);
2670
+
2671
+ const key = getTimelineElementKey(element);
2672
+ if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2673
+ setInspectedTimelineElementId(key);
2674
+ setLeftCollapsed(false);
2675
+
2676
+ const iframe = previewIframeRef.current;
2677
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2678
+ seekStudioPreview(iframe, element.start);
2679
+ }
2680
+ } else {
2681
+ setInspectedTimelineElementId(null);
2682
+ }
2683
+ },
2684
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2685
+ );
2686
+
2687
+ const handleTimelineElementInspect = useCallback(
2688
+ (element: TimelineElement) => {
2689
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
2690
+ if (!canInspectTimelineElement(element)) {
2691
+ showToast("Audio clips do not have visual layers.", "info");
2692
+ return;
2693
+ }
2694
+
2695
+ const key = getTimelineElementKey(element);
2696
+ if (!key) return;
2697
+ setInspectedTimelineElementId((current) => (current === key ? null : key));
2698
+ setLeftCollapsed(false);
2699
+
2700
+ const iframe = previewIframeRef.current;
2701
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2702
+ seekStudioPreview(iframe, element.start);
2703
+ }
2704
+
2705
+ const selection = buildDomSelectionForTimelineElement(element);
2706
+ if (selection) applyDomSelection(selection);
2707
+ },
2708
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2709
+ );
2710
+
2711
+ const handleTimelineLayerSelect = useCallback(
2712
+ (layer: DomEditLayerItem) => {
2713
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2714
+
2715
+ const iframe = previewIframeRef.current;
2716
+ const player = getPreviewPlayer(iframe?.contentWindow);
2717
+ const visibleTime = resolveLayerVisibleSeekTime(
2718
+ layer.element,
2719
+ inspectedTimelineElement,
2720
+ player,
2721
+ );
2722
+ if (visibleTime != null) {
2723
+ seekStudioPreview(iframe, visibleTime);
2724
+ }
2725
+
2726
+ const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
2727
+ if (!selection) {
2728
+ showToast("Studio could not resolve this nested layer.", "error");
2729
+ return;
2730
+ }
2731
+
2732
+ applyDomSelection(selection);
2733
+ requestAnimationFrame(refreshPreviewDocumentVersion);
2734
+ },
2735
+ [
2736
+ applyDomSelection,
2737
+ buildDomSelectionFromTarget,
2738
+ inspectedTimelineElement,
2739
+ refreshPreviewDocumentVersion,
2740
+ showToast,
2741
+ ],
2742
+ );
2743
+
2744
+ const handleTimelineLayerPanelClose = useCallback(() => {
2745
+ setInspectedTimelineElementId(null);
2746
+ }, []);
2747
+
2748
+ const preloadAgentPromptSnippet = useCallback(
2749
+ async (selection: DomEditSelection) => {
2750
+ const pid = projectIdRef.current;
2751
+ if (!pid) return;
2752
+
2753
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
2754
+ try {
2755
+ const response = await fetch(
2756
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
2757
+ );
2758
+ if (!response.ok) return;
2759
+
2760
+ const data = (await response.json()) as { content?: string };
2761
+ const html = data.content;
2762
+ const tagSnippet =
2763
+ typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
2764
+
2765
+ setAgentPromptTagSnippet((current) => {
2766
+ if (domEditSelectionRef.current !== selection) return current;
2767
+ return tagSnippet;
2768
+ });
2769
+ } catch {
2770
+ // Runtime outerHTML is still available as a synchronous copy fallback.
2771
+ }
2772
+ },
2773
+ [activeCompPath],
2774
+ );
2775
+
2776
+ const resolveImportedFontAsset = useCallback(
2777
+ (fontFamilyValue: string): ImportedFontAsset | null => {
2778
+ const family = primaryFontFamilyValue(fontFamilyValue);
2779
+ if (!family) return null;
2780
+ const imported = importedFontAssetsRef.current.find(
2781
+ (font) => font.family.toLowerCase() === family.toLowerCase(),
2782
+ );
2783
+ if (imported) return imported;
2784
+ const asset = fileTree.find(
2785
+ (path) =>
2786
+ FONT_EXT.test(path) &&
2787
+ fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
2788
+ );
2789
+ if (!asset) return null;
2790
+ return {
2791
+ family: fontFamilyFromAssetPath(asset),
2792
+ path: asset,
2793
+ url: `/api/projects/${projectId}/preview/${asset}`,
2794
+ };
2795
+ },
2796
+ [fileTree, projectId],
2797
+ );
2798
+
2799
+ const persistDomEditOperations = useCallback(
2800
+ async (
2801
+ selection: DomEditSelection,
2802
+ operations: Parameters<typeof applyPatchByTarget>[2][],
2803
+ options?: {
2804
+ label?: string;
2805
+ coalesceKey?: string;
2806
+ skipRefresh?: boolean;
2807
+ prepareContent?: (html: string, sourceFile: string) => string;
2808
+ shouldSave?: () => boolean;
2809
+ },
2810
+ ) => {
2811
+ const pid = projectIdRef.current;
2812
+ if (!pid) throw new Error("No active project");
2813
+ if (options?.shouldSave && !options.shouldSave()) return;
2814
+
2815
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
2816
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
2817
+ if (!response.ok) {
2818
+ throw new Error(`Failed to read ${targetPath}`);
2819
+ }
2820
+
2821
+ const data = (await response.json()) as { content?: string };
2822
+ const originalContent = data.content;
2823
+ if (typeof originalContent !== "string") {
2824
+ throw new Error(`Missing file contents for ${targetPath}`);
2825
+ }
2826
+
2827
+ let patchedContent = originalContent;
2828
+ for (const operation of operations) {
2829
+ patchedContent = applyPatchByTarget(patchedContent, selection, operation);
2830
+ }
2831
+ if (options?.prepareContent) {
2832
+ patchedContent = options.prepareContent(patchedContent, targetPath);
2833
+ }
2834
+ if (options?.shouldSave && !options.shouldSave()) return;
2835
+
2836
+ if (patchedContent === originalContent) {
2837
+ throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
2838
+ }
2839
+
2840
+ await saveProjectFilesWithHistory({
2841
+ projectId: pid,
2842
+ label: options?.label ?? "Edit layer",
2843
+ kind: "manual",
2844
+ coalesceKey: options?.coalesceKey,
2845
+ files: { [targetPath]: patchedContent },
2846
+ readFile: async () => originalContent,
2847
+ writeFile: writeProjectFile,
2848
+ recordEdit: editHistory.recordEdit,
2849
+ });
2850
+
2851
+ if (options?.skipRefresh) {
2852
+ domEditSaveTimestampRef.current = Date.now();
2853
+ } else {
2854
+ setRefreshKey((k) => k + 1);
2855
+ }
2856
+ },
2857
+ [activeCompPath, editHistory.recordEdit, writeProjectFile],
2858
+ );
2859
+
2860
+ const refreshDomEditSelectionFromPreview = useCallback(
2861
+ (selection: DomEditSelection) => {
2862
+ const iframe = previewIframeRef.current;
2863
+ let doc: Document | null = null;
2864
+ try {
2865
+ doc = iframe?.contentDocument ?? null;
2866
+ } catch {
2867
+ return;
2868
+ }
2869
+ if (!doc) return;
2870
+
2871
+ const element = findElementForSelection(doc, selection, activeCompPath);
2872
+ if (!element) return;
2873
+
2874
+ const nextSelection = buildDomSelectionFromTarget(element);
2875
+ if (nextSelection) {
2876
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2877
+ }
2878
+ },
2879
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
2880
+ );
2881
+
2882
+ const refreshDomEditGroupSelectionsFromPreview = useCallback(
2883
+ (selections: DomEditSelection[]) => {
2884
+ const iframe = previewIframeRef.current;
2885
+ let doc: Document | null = null;
2886
+ try {
2887
+ doc = iframe?.contentDocument ?? null;
2888
+ } catch {
2889
+ return;
2890
+ }
2891
+ if (!doc) return;
2892
+
2893
+ const nextGroup: DomEditSelection[] = [];
2894
+ for (const selection of selections) {
2895
+ const element = findElementForSelection(doc, selection, activeCompPath);
2896
+ if (!element) continue;
2897
+ const nextSelection = buildDomSelectionFromTarget(element);
2898
+ if (nextSelection) nextGroup.push(nextSelection);
2899
+ }
2900
+ if (nextGroup.length === 0) return;
2901
+
2902
+ const currentSelection = domEditSelectionRef.current;
2903
+ const nextSelection =
2904
+ nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
2905
+ nextGroup[0] ??
2906
+ null;
2907
+
2908
+ setAgentPromptTagSnippet(undefined);
2909
+ setCopiedAgentPrompt(false);
2910
+ domEditSelectionRef.current = nextSelection;
2911
+ domEditGroupSelectionsRef.current = nextGroup;
2912
+ setDomEditSelection(nextSelection);
2913
+ setDomEditGroupSelections(nextGroup);
2914
+
2915
+ if (nextSelection) {
2916
+ setSelectedTimelineElementId(
2917
+ findMatchingTimelineElementId(nextSelection, timelineElements),
2918
+ );
2919
+ } else {
2920
+ setSelectedTimelineElementId(null);
2921
+ }
2922
+ },
2923
+ [activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
2924
+ );
2925
+
2926
+ const handleDomManualDragStart = useCallback(() => {
2927
+ const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
2928
+ const playerStore = usePlayerStore.getState();
2929
+ playerStore.setIsPlaying(false);
2930
+ if (pausedTime != null) {
2931
+ playerStore.setCurrentTime(pausedTime);
2932
+ liveTime.notify(pausedTime);
2933
+ }
2934
+ }, []);
2935
+
2936
+ const handleDomPathOffsetCommit = useCallback(
2937
+ (selection: DomEditSelection, next: { x: number; y: number }) => {
2938
+ commitStudioManualEditManifestOptimistically(
2939
+ (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
2940
+ {
2941
+ label: "Move layer",
2942
+ coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
2943
+ },
2944
+ );
2945
+ refreshDomEditSelectionFromPreview(selection);
2946
+ },
2947
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2948
+ );
979
2949
 
980
- usePlayerStore
981
- .getState()
982
- .setElements(
983
- timelineElements.filter(
984
- (timelineElement) =>
985
- (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
986
- ),
987
- );
988
- usePlayerStore.getState().setSelectedElementId(null);
989
- setRefreshKey((k) => k + 1);
990
- } catch (error) {
991
- const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
992
- showToast(message);
2950
+ const handleDomGroupPathOffsetCommit = useCallback(
2951
+ (updates: DomEditGroupPathOffsetCommit[]) => {
2952
+ if (updates.length === 0) return;
2953
+ const coalesceKey = updates
2954
+ .map((update) => getDomEditTargetKey(update.selection))
2955
+ .sort()
2956
+ .join(":");
2957
+ commitStudioManualEditManifestOptimistically(
2958
+ (manifest) =>
2959
+ updates.reduce(
2960
+ (nextManifest, update) =>
2961
+ upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
2962
+ manifest,
2963
+ ),
2964
+ {
2965
+ label: `Move ${updates.length} layers`,
2966
+ coalesceKey: `group-path-offset:${coalesceKey}`,
2967
+ },
2968
+ );
2969
+ refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
2970
+ },
2971
+ [commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
2972
+ );
2973
+
2974
+ const handleDomBoxSizeCommit = useCallback(
2975
+ (selection: DomEditSelection, next: { width: number; height: number }) => {
2976
+ commitStudioManualEditManifestOptimistically(
2977
+ (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
2978
+ {
2979
+ label: "Resize layer box",
2980
+ coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
2981
+ },
2982
+ );
2983
+ refreshDomEditSelectionFromPreview(selection);
2984
+ },
2985
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2986
+ );
2987
+
2988
+ const handleDomRotationCommit = useCallback(
2989
+ (selection: DomEditSelection, next: { angle: number }) => {
2990
+ commitStudioManualEditManifestOptimistically(
2991
+ (manifest) => upsertStudioRotationEdit(manifest, selection, next),
2992
+ {
2993
+ label: "Rotate layer",
2994
+ coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
2995
+ },
2996
+ );
2997
+ refreshDomEditSelectionFromPreview(selection);
2998
+ },
2999
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
3000
+ );
3001
+
3002
+ const handleDomManualEditsReset = useCallback(
3003
+ (selection: DomEditSelection) => {
3004
+ commitStudioManualEditManifestOptimistically(
3005
+ (manifest) => removeStudioManualEditsForSelection(manifest, selection),
3006
+ {
3007
+ label: "Reset layer edits",
3008
+ coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
3009
+ },
3010
+ );
3011
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
3012
+ refreshDomEditSelectionFromPreview(selection);
3013
+ },
3014
+ [
3015
+ applyCurrentStudioManualEditsToPreview,
3016
+ commitStudioManualEditManifestOptimistically,
3017
+ refreshDomEditSelectionFromPreview,
3018
+ ],
3019
+ );
3020
+
3021
+ const handleDomMotionCommit = useCallback(
3022
+ (
3023
+ selection: DomEditSelection,
3024
+ motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
3025
+ ) => {
3026
+ commitStudioMotionManifestOptimistically(
3027
+ (manifest) => upsertStudioGsapMotion(manifest, selection, motion),
3028
+ {
3029
+ label: "Set GSAP motion",
3030
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
3031
+ },
3032
+ );
3033
+ refreshDomEditSelectionFromPreview(selection);
3034
+ },
3035
+ [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
3036
+ );
3037
+
3038
+ const handleDomMotionClear = useCallback(
3039
+ (selection: DomEditSelection) => {
3040
+ commitStudioMotionManifestOptimistically(
3041
+ (manifest) => removeStudioMotionForSelection(manifest, selection),
3042
+ {
3043
+ label: "Clear GSAP motion",
3044
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
3045
+ },
3046
+ );
3047
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
3048
+ refreshDomEditSelectionFromPreview(selection);
3049
+ },
3050
+ [
3051
+ applyCurrentStudioMotionToPreview,
3052
+ commitStudioMotionManifestOptimistically,
3053
+ refreshDomEditSelectionFromPreview,
3054
+ ],
3055
+ );
3056
+
3057
+ const handleDomStyleCommit = useCallback(
3058
+ async (property: string, value: string) => {
3059
+ if (!domEditSelection) return;
3060
+ if (isManualGeometryStyleProperty(property)) return;
3061
+ if (!domEditSelection.capabilities.canEditStyles) return;
3062
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
3063
+ const iframe = previewIframeRef.current;
3064
+ const doc = iframe?.contentDocument;
3065
+ if (doc) {
3066
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
3067
+ if (el) {
3068
+ el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
3069
+ if (property === "font-family") {
3070
+ injectPreviewGoogleFont(doc, value);
3071
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
3072
+ }
3073
+ if (property === "background-image" && isImageBackgroundValue(value)) {
3074
+ el.style.setProperty("background-position", "center");
3075
+ el.style.setProperty("background-repeat", "no-repeat");
3076
+ el.style.setProperty("background-size", "contain");
3077
+ }
3078
+ }
993
3079
  }
3080
+ const operations: PatchOperation[] = [
3081
+ buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
3082
+ ];
3083
+ if (property === "background-image" && isImageBackgroundValue(value)) {
3084
+ operations.push(
3085
+ buildDomEditStylePatchOperation("background-position", "center"),
3086
+ buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
3087
+ buildDomEditStylePatchOperation("background-size", "contain"),
3088
+ );
3089
+ }
3090
+ try {
3091
+ await persistDomEditOperations(domEditSelection, operations, {
3092
+ label: "Edit layer style",
3093
+ skipRefresh: true,
3094
+ prepareContent: importedFont
3095
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
3096
+ : undefined,
3097
+ });
3098
+ } catch (err) {
3099
+ console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
3100
+ }
3101
+ refreshDomEditSelectionFromPreview(domEditSelection);
994
3102
  },
995
- [activeCompPath, showToast, timelineElements],
3103
+ [
3104
+ activeCompPath,
3105
+ domEditSelection,
3106
+ persistDomEditOperations,
3107
+ refreshDomEditSelectionFromPreview,
3108
+ resolveImportedFontAsset,
3109
+ ],
996
3110
  );
997
3111
 
998
- const handleBlockedTimelineEdit = useCallback(
999
- (_element: TimelineElement) => {
1000
- const now = Date.now();
1001
- if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
1002
- lastBlockedTimelineToastAtRef.current = now;
1003
- showToast("This clip can’t be moved or resized from the timeline yet.", "info");
3112
+ const handleDomTextCommit = useCallback(
3113
+ async (value: string, fieldKey?: string) => {
3114
+ if (!domEditSelection) return;
3115
+ if (!isTextEditableSelection(domEditSelection)) return;
3116
+ const commitVersion = domTextCommitVersionRef.current + 1;
3117
+ domTextCommitVersionRef.current = commitVersion;
3118
+ const nextTextFields =
3119
+ domEditSelection.textFields.length > 0
3120
+ ? domEditSelection.textFields.map((field) =>
3121
+ field.key === fieldKey ? { ...field, value } : field,
3122
+ )
3123
+ : [];
3124
+ const nextContent =
3125
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
3126
+ ? serializeDomEditTextFields(nextTextFields)
3127
+ : value;
3128
+ const iframe = previewIframeRef.current;
3129
+ const doc = iframe?.contentDocument;
3130
+ if (doc) {
3131
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
3132
+ if (el) {
3133
+ if (
3134
+ nextTextFields.length > 1 ||
3135
+ nextTextFields.some((field) => field.source === "child")
3136
+ ) {
3137
+ el.innerHTML = nextContent;
3138
+ } else {
3139
+ el.textContent = value;
3140
+ }
3141
+ }
3142
+ }
3143
+ await persistDomEditOperations(
3144
+ domEditSelection,
3145
+ [buildDomEditTextPatchOperation(nextContent)],
3146
+ {
3147
+ label: "Edit text",
3148
+ skipRefresh: true,
3149
+ shouldSave: () => domTextCommitVersionRef.current === commitVersion,
3150
+ },
3151
+ );
3152
+ if (domTextCommitVersionRef.current !== commitVersion) return;
3153
+
3154
+ if (doc) {
3155
+ const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
3156
+ if (refreshed) {
3157
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
3158
+ if (nextSelection) {
3159
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3160
+ }
3161
+ }
3162
+ }
1004
3163
  },
1005
- [showToast],
3164
+ [
3165
+ activeCompPath,
3166
+ applyDomSelection,
3167
+ buildDomSelectionFromTarget,
3168
+ domEditSelection,
3169
+ persistDomEditOperations,
3170
+ ],
3171
+ );
3172
+
3173
+ const commitDomTextFields = useCallback(
3174
+ async (
3175
+ selection: DomEditSelection,
3176
+ nextTextFields: DomEditTextField[],
3177
+ options?: { importedFont?: ImportedFontAsset | null },
3178
+ ) => {
3179
+ const nextContent =
3180
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
3181
+ ? serializeDomEditTextFields(nextTextFields)
3182
+ : (nextTextFields[0]?.value ?? "");
3183
+
3184
+ const iframe = previewIframeRef.current;
3185
+ const doc = iframe?.contentDocument;
3186
+ if (doc) {
3187
+ const el = findElementForSelection(doc, selection, activeCompPath);
3188
+ if (el) {
3189
+ if (
3190
+ nextTextFields.length > 1 ||
3191
+ nextTextFields.some((field) => field.source === "child")
3192
+ ) {
3193
+ el.innerHTML = nextContent;
3194
+ } else {
3195
+ el.textContent = nextContent;
3196
+ }
3197
+ }
3198
+ }
3199
+
3200
+ const importedFont = options?.importedFont ?? null;
3201
+ await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
3202
+ label: "Edit text",
3203
+ skipRefresh: true,
3204
+ prepareContent: importedFont
3205
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
3206
+ : undefined,
3207
+ });
3208
+
3209
+ if (doc) {
3210
+ const refreshed = findElementForSelection(doc, selection, activeCompPath);
3211
+ if (refreshed) {
3212
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
3213
+ if (nextSelection) {
3214
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3215
+ }
3216
+ }
3217
+ }
3218
+ },
3219
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
3220
+ );
3221
+
3222
+ const handleDomTextFieldStyleCommit = useCallback(
3223
+ async (fieldKey: string, property: string, value: string) => {
3224
+ if (!domEditSelection) return;
3225
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3226
+ if (!field) return;
3227
+
3228
+ if (field.source === "self") {
3229
+ await handleDomStyleCommit(property, value);
3230
+ return;
3231
+ }
3232
+
3233
+ const normalizedValue = normalizeDomEditStyleValue(property, value);
3234
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
3235
+ if (property === "font-family") {
3236
+ const doc = previewIframeRef.current?.contentDocument;
3237
+ if (doc) {
3238
+ injectPreviewGoogleFont(doc, normalizedValue);
3239
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
3240
+ }
3241
+ }
3242
+ const nextTextFields = domEditSelection.textFields.map((entry) =>
3243
+ entry.key === fieldKey
3244
+ ? {
3245
+ ...entry,
3246
+ inlineStyles: {
3247
+ ...entry.inlineStyles,
3248
+ [property]: normalizedValue,
3249
+ },
3250
+ computedStyles: {
3251
+ ...entry.computedStyles,
3252
+ [property]: normalizedValue,
3253
+ },
3254
+ }
3255
+ : entry,
3256
+ );
3257
+
3258
+ await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
3259
+ },
3260
+ [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
3261
+ );
3262
+
3263
+ const handleDomAddTextField = useCallback(
3264
+ async (afterFieldKey?: string) => {
3265
+ if (!domEditSelection) return null;
3266
+ if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
3267
+
3268
+ const insertionIndex = domEditSelection.textFields.findIndex(
3269
+ (field) => field.key === afterFieldKey,
3270
+ );
3271
+ const baseField =
3272
+ domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
3273
+ domEditSelection.textFields[0];
3274
+ const nextField = buildDefaultDomEditTextField(baseField);
3275
+ const nextTextFields = [...domEditSelection.textFields];
3276
+ nextTextFields.splice(
3277
+ insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
3278
+ 0,
3279
+ nextField,
3280
+ );
3281
+
3282
+ await commitDomTextFields(domEditSelection, nextTextFields);
3283
+ return nextField.key;
3284
+ },
3285
+ [commitDomTextFields, domEditSelection],
3286
+ );
3287
+
3288
+ const handleDomRemoveTextField = useCallback(
3289
+ async (fieldKey: string) => {
3290
+ if (!domEditSelection) return;
3291
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3292
+ if (!field) return;
3293
+
3294
+ if (field.source === "self") {
3295
+ await handleDomTextCommit("", fieldKey);
3296
+ return;
3297
+ }
3298
+
3299
+ const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
3300
+ await commitDomTextFields(domEditSelection, nextTextFields);
3301
+ },
3302
+ [commitDomTextFields, domEditSelection, handleDomTextCommit],
3303
+ );
3304
+
3305
+ const handleAskAgent = useCallback(() => {
3306
+ if (!domEditSelection) return;
3307
+ setAgentPromptTagSnippet(undefined);
3308
+ setAgentPromptSelectionContext(undefined);
3309
+ setAgentModalAnchorPoint(null);
3310
+ void preloadAgentPromptSnippet(domEditSelection);
3311
+ setAgentModalOpen(true);
3312
+ }, [domEditSelection, preloadAgentPromptSnippet]);
3313
+
3314
+ const handleAgentModalSubmit = useCallback(
3315
+ async (userInstruction: string) => {
3316
+ if (!domEditSelection) return;
3317
+
3318
+ const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
3319
+ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
3320
+ const prompt = buildElementAgentPrompt({
3321
+ selection: domEditSelection,
3322
+ currentTime,
3323
+ tagSnippet,
3324
+ selectionContext: agentPromptSelectionContext,
3325
+ userInstruction,
3326
+ sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3327
+ });
3328
+
3329
+ const copied = await copyTextToClipboard(prompt);
3330
+ if (!copied) {
3331
+ showToast("Could not copy prompt to clipboard.", "error");
3332
+ return;
3333
+ }
3334
+
3335
+ setAgentModalOpen(false);
3336
+ setAgentPromptSelectionContext(undefined);
3337
+ setAgentModalAnchorPoint(null);
3338
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3339
+ setCopiedAgentPrompt(true);
3340
+ copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3341
+ },
3342
+ [
3343
+ activeCompPath,
3344
+ agentPromptSelectionContext,
3345
+ agentPromptTagSnippet,
3346
+ currentTime,
3347
+ domEditSelection,
3348
+ projectDir,
3349
+ showToast,
3350
+ ],
3351
+ );
3352
+
3353
+ const handlePreviewIframeRef = useCallback(
3354
+ (iframe: HTMLIFrameElement | null) => {
3355
+ previewIframeRef.current = iframe;
3356
+ setPreviewIframe(iframe);
3357
+ syncPreviewTimelineHotkey(iframe);
3358
+ syncPreviewHistoryHotkey(iframe);
3359
+ consoleErrorsRef.current = [];
3360
+ setConsoleErrors(null);
3361
+ refreshPreviewDocumentVersion();
3362
+ },
3363
+ [refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
3364
+ );
3365
+
3366
+ const handlePreviewCanvasMouseDown = useCallback(
3367
+ (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3368
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
3369
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3370
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3371
+ });
3372
+ if (!nextSelection) {
3373
+ if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
3374
+ return;
3375
+ }
3376
+ e.preventDefault();
3377
+ e.stopPropagation();
3378
+ const localPointer = previewIframeRef.current
3379
+ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3380
+ : null;
3381
+ applyDomSelection(nextSelection, { additive: e.shiftKey });
3382
+ if (
3383
+ !e.shiftKey &&
3384
+ localPointer &&
3385
+ isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3386
+ ) {
3387
+ setAgentPromptSelectionContext(
3388
+ buildRasterClickSelectionContext(nextSelection, localPointer),
3389
+ );
3390
+ setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3391
+ void preloadAgentPromptSnippet(nextSelection);
3392
+ setAgentModalOpen(true);
3393
+ }
3394
+ },
3395
+ [
3396
+ applyDomSelection,
3397
+ captionEditMode,
3398
+ compositionLoading,
3399
+ preloadAgentPromptSnippet,
3400
+ resolveDomSelectionFromPreviewPoint,
3401
+ ],
3402
+ );
3403
+
3404
+ const handlePreviewCanvasPointerMove = useCallback(
3405
+ (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3406
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
3407
+ updateDomEditHoverSelection(null);
3408
+ return null;
3409
+ }
3410
+
3411
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3412
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3413
+ });
3414
+ updateDomEditHoverSelection(nextSelection);
3415
+ return nextSelection;
3416
+ },
3417
+ [
3418
+ captionEditMode,
3419
+ compositionLoading,
3420
+ resolveDomSelectionFromPreviewPoint,
3421
+ updateDomEditHoverSelection,
3422
+ ],
3423
+ );
3424
+
3425
+ const handlePreviewCanvasPointerLeave = useCallback(() => {
3426
+ updateDomEditHoverSelection(null);
3427
+ }, [updateDomEditHoverSelection]);
3428
+
3429
+ // eslint-disable-next-line no-restricted-syntax
3430
+ useEffect(() => {
3431
+ if (captionEditMode) updateDomEditHoverSelection(null);
3432
+ }, [captionEditMode, updateDomEditHoverSelection]);
3433
+
3434
+ // eslint-disable-next-line no-restricted-syntax
3435
+ useEffect(() => {
3436
+ updateDomEditHoverSelection(null);
3437
+ }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
3438
+
3439
+ // eslint-disable-next-line no-restricted-syntax
3440
+ useEffect(() => {
3441
+ if (!domEditHoverSelection) return;
3442
+ const hoverMatchesSelection = domEditSelectionsTargetSame(
3443
+ domEditHoverSelection,
3444
+ domEditSelection,
3445
+ );
3446
+ const hoverMatchesGroup = domEditSelectionInGroup(
3447
+ domEditGroupSelections,
3448
+ domEditHoverSelection,
3449
+ );
3450
+ if (!hoverMatchesSelection && !hoverMatchesGroup) return;
3451
+ updateDomEditHoverSelection(null);
3452
+ }, [
3453
+ domEditGroupSelections,
3454
+ domEditHoverSelection,
3455
+ domEditSelection,
3456
+ updateDomEditHoverSelection,
3457
+ ]);
3458
+
3459
+ // eslint-disable-next-line no-restricted-syntax
3460
+ useEffect(() => {
3461
+ if (!domEditHoverSelection) return;
3462
+ if (domEditHoverSelection.element.isConnected) return;
3463
+ updateDomEditHoverSelection(null);
3464
+ }, [domEditHoverSelection, updateDomEditHoverSelection]);
3465
+
3466
+ // eslint-disable-next-line no-restricted-syntax
3467
+ useEffect(() => {
3468
+ if (!previewIframe) return;
3469
+
3470
+ const syncSelectionFromDocument = () => {
3471
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
3472
+ const currentSelection = domEditSelectionRef.current;
3473
+ if (!currentSelection) return;
3474
+ let doc: Document | null = null;
3475
+ try {
3476
+ doc = previewIframe.contentDocument;
3477
+ } catch {
3478
+ return;
3479
+ }
3480
+ if (!doc) return;
3481
+
3482
+ const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
3483
+ if (!nextElement) {
3484
+ applyDomSelection(null, { revealPanel: false });
3485
+ return;
3486
+ }
3487
+
3488
+ const nextSelection = buildDomSelectionFromTarget(nextElement);
3489
+ if (nextSelection) {
3490
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3491
+ }
3492
+ };
3493
+
3494
+ const attachErrorCapture = () => {
3495
+ try {
3496
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
3497
+ if (!win) return;
3498
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
3499
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
3500
+ const origError = win.console.error.bind(win.console);
3501
+ win.console.error = function (...args: unknown[]) {
3502
+ origError(...args);
3503
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
3504
+ if (text.includes("favicon")) return;
3505
+ consoleErrorsRef.current = [
3506
+ ...consoleErrorsRef.current,
3507
+ { severity: "error", message: text },
3508
+ ];
3509
+ setConsoleErrors([...consoleErrorsRef.current]);
3510
+ };
3511
+ win.addEventListener("error", (e: ErrorEvent) => {
3512
+ const text = e.message || String(e);
3513
+ consoleErrorsRef.current = [
3514
+ ...consoleErrorsRef.current,
3515
+ { severity: "error", message: text },
3516
+ ];
3517
+ setConsoleErrors([...consoleErrorsRef.current]);
3518
+ });
3519
+ } catch {
3520
+ // same-origin only
3521
+ }
3522
+ };
3523
+
3524
+ attachErrorCapture();
3525
+ syncPreviewHistoryHotkey(previewIframe);
3526
+ void (async () => {
3527
+ await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3528
+ await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3529
+ })();
3530
+ syncSelectionFromDocument();
3531
+ refreshPreviewDocumentVersion();
3532
+
3533
+ const handleLoad = () => {
3534
+ consoleErrorsRef.current = [];
3535
+ setConsoleErrors(null);
3536
+ attachErrorCapture();
3537
+ syncPreviewHistoryHotkey(previewIframe);
3538
+ void (async () => {
3539
+ await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3540
+ await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3541
+ })();
3542
+ syncSelectionFromDocument();
3543
+ refreshPreviewDocumentVersion();
3544
+ };
3545
+
3546
+ previewIframe.addEventListener("load", handleLoad);
3547
+ return () => {
3548
+ previewIframe.removeEventListener("load", handleLoad);
3549
+ };
3550
+ }, [
3551
+ activeCompPath,
3552
+ applyDomSelection,
3553
+ applyStudioManualEditsToPreviewAfterRefresh,
3554
+ applyStudioMotionToPreviewAfterRefresh,
3555
+ buildDomSelectionFromTarget,
3556
+ captionEditMode,
3557
+ previewIframe,
3558
+ refreshPreviewDocumentVersion,
3559
+ syncPreviewHistoryHotkey,
3560
+ ]);
3561
+
3562
+ // eslint-disable-next-line no-restricted-syntax
3563
+ useEffect(() => {
3564
+ if (!captionEditMode) return;
3565
+ applyDomSelection(null, { revealPanel: false });
3566
+ }, [applyDomSelection, captionEditMode]);
3567
+
3568
+ // eslint-disable-next-line no-restricted-syntax
3569
+ useEffect(() => {
3570
+ if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
3571
+ updateDomEditHoverSelection(null);
3572
+ applyDomSelection(null, { revealPanel: false });
3573
+ if (rightPanelTab !== "renders") setRightPanelTab("renders");
3574
+ }, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
3575
+
3576
+ // eslint-disable-next-line no-restricted-syntax
3577
+ useEffect(
3578
+ () => () => {
3579
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3580
+ },
3581
+ [],
1006
3582
  );
1007
3583
 
1008
3584
  const refreshFileTree = useCallback(async () => {
@@ -1138,24 +3714,20 @@ export function StudioApp() {
1138
3714
  duration: normalizedDuration,
1139
3715
  track: placement.track,
1140
3716
  zIndex: trackZIndices.get(placement.track) ?? 1,
3717
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
1141
3718
  }),
1142
3719
  );
1143
3720
 
1144
- const saveResponse = await fetch(
1145
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1146
- {
1147
- method: "PUT",
1148
- headers: { "Content-Type": "text/plain" },
1149
- body: patchedContent,
1150
- },
1151
- );
1152
- if (!saveResponse.ok) {
1153
- throw new Error(`Failed to save ${targetPath}`);
1154
- }
1155
-
1156
- if (editingPathRef.current === targetPath) {
1157
- setEditingFile({ path: targetPath, content: patchedContent });
1158
- }
3721
+ domEditSaveTimestampRef.current = Date.now();
3722
+ await saveProjectFilesWithHistory({
3723
+ projectId: pid,
3724
+ label: "Add timeline asset",
3725
+ kind: "timeline",
3726
+ files: { [targetPath]: patchedContent },
3727
+ readFile: async () => originalContent,
3728
+ writeFile: writeProjectFile,
3729
+ recordEdit: editHistory.recordEdit,
3730
+ });
1159
3731
 
1160
3732
  setRefreshKey((k) => k + 1);
1161
3733
  } catch (error) {
@@ -1164,7 +3736,7 @@ export function StudioApp() {
1164
3736
  showToast(message);
1165
3737
  }
1166
3738
  },
1167
- [activeCompPath, showToast, timelineElements],
3739
+ [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1168
3740
  );
1169
3741
 
1170
3742
  const handleTimelineFileDrop = useCallback(
@@ -1322,7 +3894,33 @@ export function StudioApp() {
1322
3894
 
1323
3895
  const handleImportFiles = useCallback(
1324
3896
  async (files: FileList | File[], dir?: string) => {
1325
- void uploadProjectFiles(Array.from(files), dir);
3897
+ return uploadProjectFiles(Array.from(files), dir);
3898
+ },
3899
+ [uploadProjectFiles],
3900
+ );
3901
+
3902
+ const handleImportFonts = useCallback(
3903
+ async (files: FileList | File[]) => {
3904
+ const uploaded = await uploadProjectFiles(
3905
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
3906
+ "assets/fonts",
3907
+ );
3908
+ const pid = projectIdRef.current;
3909
+ const imported = uploaded
3910
+ .filter((asset) => FONT_EXT.test(asset))
3911
+ .map((asset) => ({
3912
+ family: fontFamilyFromAssetPath(asset),
3913
+ path: asset,
3914
+ url: `/api/projects/${pid}/preview/${asset}`,
3915
+ }));
3916
+ importedFontAssetsRef.current = [
3917
+ ...imported,
3918
+ ...importedFontAssetsRef.current.filter(
3919
+ (existing) =>
3920
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
3921
+ ),
3922
+ ];
3923
+ return imported;
1326
3924
  },
1327
3925
  [uploadProjectFiles],
1328
3926
  );
@@ -1394,6 +3992,53 @@ export function StudioApp() {
1394
3992
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
1395
3993
  [fileTree],
1396
3994
  );
3995
+ const fontAssets = useMemo<ImportedFontAsset[]>(
3996
+ () =>
3997
+ assets
3998
+ .filter((asset) => FONT_EXT.test(asset))
3999
+ .map((asset) => ({
4000
+ family: fontFamilyFromAssetPath(asset),
4001
+ path: asset,
4002
+ url: `/api/projects/${projectId}/preview/${asset}`,
4003
+ })),
4004
+ [assets, projectId],
4005
+ );
4006
+ const selectedStudioMotion =
4007
+ STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
4008
+ ? getStudioMotionForSelection(studioMotionManifestRef.current, domEditSelection)
4009
+ : null;
4010
+ const selectedTimelineElement = useMemo(
4011
+ () =>
4012
+ selectedTimelineElementId
4013
+ ? (timelineElements.find(
4014
+ (element) => getTimelineElementKey(element) === selectedTimelineElementId,
4015
+ ) ?? null)
4016
+ : null,
4017
+ [selectedTimelineElementId, timelineElements],
4018
+ );
4019
+ const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
4020
+ const motionPanelActive =
4021
+ STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
4022
+ const inspectorPanelActive = designPanelActive || motionPanelActive;
4023
+ const shouldShowSelectedDomBounds =
4024
+ inspectorPanelActive &&
4025
+ !rightCollapsed &&
4026
+ (!selectedTimelineElement ||
4027
+ isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
4028
+ const inspectorButtonActive =
4029
+ STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
4030
+ const timelineLayerPanel =
4031
+ STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED &&
4032
+ inspectedTimelineElement &&
4033
+ inspectedTimelineLayers.length > 0 ? (
4034
+ <TimelineLayerPanel
4035
+ clipLabel={getTimelineElementLabel(inspectedTimelineElement)}
4036
+ layers={inspectedTimelineLayers}
4037
+ selectedLayerKey={selectedTimelineLayerKey}
4038
+ onSelectLayer={handleTimelineLayerSelect}
4039
+ onClose={handleTimelineLayerPanelClose}
4040
+ />
4041
+ ) : null;
1397
4042
 
1398
4043
  if (resolving || !projectId) {
1399
4044
  return (
@@ -1439,6 +4084,42 @@ export function StudioApp() {
1439
4084
  </div>
1440
4085
  {/* Right: toolbar buttons */}
1441
4086
  <div className="flex items-center gap-1.5">
4087
+ <button
4088
+ type="button"
4089
+ onClick={() => void handleUndo()}
4090
+ disabled={!editHistory.canUndo}
4091
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
4092
+ editHistory.canUndo
4093
+ ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
4094
+ : "border-neutral-900 text-neutral-700"
4095
+ }`}
4096
+ title={
4097
+ editHistory.undoLabel
4098
+ ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
4099
+ : `Undo (${getHistoryShortcutLabel("undo")})`
4100
+ }
4101
+ aria-label="Undo"
4102
+ >
4103
+ <RotateCcw size={14} />
4104
+ </button>
4105
+ <button
4106
+ type="button"
4107
+ onClick={() => void handleRedo()}
4108
+ disabled={!editHistory.canRedo}
4109
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
4110
+ editHistory.canRedo
4111
+ ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
4112
+ : "border-neutral-900 text-neutral-700"
4113
+ }`}
4114
+ title={
4115
+ editHistory.redoLabel
4116
+ ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
4117
+ : `Redo (${getHistoryShortcutLabel("redo")})`
4118
+ }
4119
+ aria-label="Redo"
4120
+ >
4121
+ <RotateCw size={14} />
4122
+ </button>
1442
4123
  <a
1443
4124
  href={captureFrameHref}
1444
4125
  download={captureFrameFilename}
@@ -1453,12 +4134,31 @@ export function StudioApp() {
1453
4134
  <span>Capture</span>
1454
4135
  </a>
1455
4136
  <button
1456
- onClick={() => setRightCollapsed((v) => !v)}
4137
+ type="button"
4138
+ onClick={() => {
4139
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
4140
+ if (rightCollapsed || !inspectorPanelActive) {
4141
+ setRightPanelTab("design");
4142
+ setRightCollapsed(false);
4143
+ return;
4144
+ }
4145
+ clearDomSelection();
4146
+ setRightCollapsed(true);
4147
+ }}
4148
+ disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
1457
4149
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1458
- !rightCollapsed
4150
+ inspectorButtonActive
1459
4151
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
1460
- : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
4152
+ : STUDIO_INSPECTOR_PANELS_ENABLED
4153
+ ? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
4154
+ : "cursor-not-allowed border-transparent text-neutral-700"
1461
4155
  }`}
4156
+ title={
4157
+ STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
4158
+ }
4159
+ aria-label={
4160
+ STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
4161
+ }
1462
4162
  >
1463
4163
  <svg
1464
4164
  width="12"
@@ -1471,8 +4171,7 @@ export function StudioApp() {
1471
4171
  <circle cx="12" cy="12" r="10" />
1472
4172
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
1473
4173
  </svg>
1474
- Renders
1475
- {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
4174
+ Inspector
1476
4175
  </button>
1477
4176
  </div>
1478
4177
  </div>
@@ -1507,6 +4206,7 @@ export function StudioApp() {
1507
4206
  </div>
1508
4207
  ) : (
1509
4208
  <LeftSidebar
4209
+ ref={leftSidebarRef}
1510
4210
  width={leftWidth}
1511
4211
  projectId={projectId}
1512
4212
  compositions={compositions}
@@ -1553,6 +4253,7 @@ export function StudioApp() {
1553
4253
  onLint={handleLint}
1554
4254
  linting={linting}
1555
4255
  onToggleCollapse={toggleLeftSidebar}
4256
+ takeoverContent={timelineLayerPanel}
1556
4257
  />
1557
4258
  )}
1558
4259
 
@@ -1583,62 +4284,47 @@ export function StudioApp() {
1583
4284
  onMoveElement={handleTimelineElementMove}
1584
4285
  onResizeElement={handleTimelineElementResize}
1585
4286
  onBlockedEditAttempt={handleBlockedTimelineEdit}
4287
+ onSelectTimelineElement={handleTimelineElementSelect}
4288
+ onInspectTimelineElement={handleTimelineElementInspect}
4289
+ inspectedTimelineElementId={inspectedTimelineElementId}
4290
+ timelineLayerChildCounts={timelineLayerChildCounts}
1586
4291
  onCompIdToSrcChange={setCompIdToSrc}
4292
+ onCompositionLoadingChange={setCompositionLoading}
1587
4293
  onCompositionChange={(compPath) => {
1588
4294
  // Sync activeCompPath when user drills down via timeline double-click
1589
4295
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
1590
4296
  setActiveCompPath(compPath);
4297
+ setInspectedTimelineElementId(null);
4298
+ refreshPreviewDocumentVersion();
1591
4299
  }}
1592
- onIframeRef={(iframe) => {
1593
- previewIframeRef.current = iframe;
1594
- syncPreviewTimelineHotkey(iframe);
1595
- consoleErrorsRef.current = [];
1596
- setConsoleErrors(null);
1597
- if (!iframe) return;
1598
-
1599
- // Attach error capture after each iframe load (content resets on navigation)
1600
- const attachErrorCapture = () => {
1601
- try {
1602
- const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1603
- if (!win) return;
1604
- // Guard against double-patching
1605
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1606
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1607
- const origError = win.console.error.bind(win.console);
1608
- win.console.error = function (...args: unknown[]) {
1609
- origError(...args);
1610
- const text = args
1611
- .map((a) => (a instanceof Error ? a.message : String(a)))
1612
- .join(" ");
1613
- if (text.includes("favicon")) return;
1614
- consoleErrorsRef.current = [
1615
- ...consoleErrorsRef.current,
1616
- { severity: "error", message: text },
1617
- ];
1618
- setConsoleErrors([...consoleErrorsRef.current]);
1619
- };
1620
- win.addEventListener("error", (e: ErrorEvent) => {
1621
- const text = e.message || String(e);
1622
- consoleErrorsRef.current = [
1623
- ...consoleErrorsRef.current,
1624
- { severity: "error", message: text },
1625
- ];
1626
- setConsoleErrors([...consoleErrorsRef.current]);
1627
- });
1628
- } catch {
1629
- // cross-origin — can't attach
1630
- }
1631
- };
1632
- // Attach now (iframe may already be loaded) and on future loads
1633
- attachErrorCapture();
1634
- iframe.addEventListener("load", () => {
1635
- consoleErrorsRef.current = [];
1636
- setConsoleErrors(null);
1637
- attachErrorCapture();
1638
- });
1639
- }}
4300
+ onIframeRef={handlePreviewIframeRef}
1640
4301
  previewOverlay={
1641
- captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
4302
+ captionEditMode ? (
4303
+ <CaptionOverlay iframeRef={previewIframeRef} />
4304
+ ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
4305
+ <DomEditOverlay
4306
+ iframeRef={previewIframeRef}
4307
+ activeCompositionPath={activeCompPath}
4308
+ hoverSelection={
4309
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
4310
+ ? domEditHoverSelection
4311
+ : null
4312
+ }
4313
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
4314
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
4315
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
4316
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
4317
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
4318
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
4319
+ onSelectionChange={applyDomSelection}
4320
+ onBlockedMove={handleBlockedDomMove}
4321
+ onManualDragStart={handleDomManualDragStart}
4322
+ onPathOffsetCommit={handleDomPathOffsetCommit}
4323
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
4324
+ onBoxSizeCommit={handleDomBoxSizeCommit}
4325
+ onRotationCommit={handleDomRotationCommit}
4326
+ />
4327
+ ) : null
1642
4328
  }
1643
4329
  timelineFooter={
1644
4330
  captionEditMode ? (
@@ -1679,16 +4365,95 @@ export function StudioApp() {
1679
4365
  {captionEditMode ? (
1680
4366
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
1681
4367
  ) : (
1682
- <RenderQueue
1683
- jobs={renderQueue.jobs}
1684
- projectId={projectId}
1685
- onDelete={renderQueue.deleteRender}
1686
- onClearCompleted={renderQueue.clearCompleted}
1687
- onStartRender={(format, quality, resolution) =>
1688
- renderQueue.startRender({ format, quality, resolution })
1689
- }
1690
- isRendering={renderQueue.isRendering}
1691
- />
4368
+ <>
4369
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
4370
+ {STUDIO_INSPECTOR_PANELS_ENABLED && (
4371
+ <>
4372
+ <button
4373
+ type="button"
4374
+ onClick={() => setRightPanelTab("design")}
4375
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4376
+ rightPanelTab === "design"
4377
+ ? "bg-neutral-800 text-white"
4378
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4379
+ }`}
4380
+ >
4381
+ Design
4382
+ </button>
4383
+ {STUDIO_MOTION_PANEL_ENABLED && (
4384
+ <button
4385
+ type="button"
4386
+ onClick={() => setRightPanelTab("motion")}
4387
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4388
+ rightPanelTab === "motion"
4389
+ ? "bg-neutral-800 text-white"
4390
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4391
+ }`}
4392
+ >
4393
+ Motion
4394
+ </button>
4395
+ )}
4396
+ </>
4397
+ )}
4398
+ <button
4399
+ type="button"
4400
+ onClick={() => setRightPanelTab("renders")}
4401
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4402
+ rightPanelTab === "renders"
4403
+ ? "bg-neutral-800 text-white"
4404
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4405
+ }`}
4406
+ >
4407
+ {renderQueue.jobs.length > 0
4408
+ ? `Renders (${renderQueue.jobs.length})`
4409
+ : "Renders"}
4410
+ </button>
4411
+ </div>
4412
+ <div className="min-h-0 flex-1">
4413
+ {designPanelActive ? (
4414
+ <PropertyPanel
4415
+ projectId={projectId}
4416
+ assets={assets}
4417
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4418
+ multiSelectCount={domEditGroupSelections.length}
4419
+ copiedAgentPrompt={copiedAgentPrompt}
4420
+ onClearSelection={clearDomSelection}
4421
+ onSetStyle={handleDomStyleCommit}
4422
+ onSetManualOffset={handleDomPathOffsetCommit}
4423
+ onSetManualSize={handleDomBoxSizeCommit}
4424
+ onSetText={handleDomTextCommit}
4425
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
4426
+ onAddTextField={handleDomAddTextField}
4427
+ onRemoveTextField={handleDomRemoveTextField}
4428
+ onResetManualEdits={handleDomManualEditsReset}
4429
+ onAskAgent={handleAskAgent}
4430
+ onImportAssets={handleImportFiles}
4431
+ fontAssets={fontAssets}
4432
+ onImportFonts={handleImportFonts}
4433
+ />
4434
+ ) : motionPanelActive ? (
4435
+ <MotionPanel
4436
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4437
+ motion={selectedStudioMotion}
4438
+ onClearSelection={clearDomSelection}
4439
+ onSetMotion={handleDomMotionCommit}
4440
+ onClearMotion={handleDomMotionClear}
4441
+ />
4442
+ ) : (
4443
+ <RenderQueue
4444
+ jobs={renderQueue.jobs}
4445
+ projectId={projectId}
4446
+ onDelete={renderQueue.deleteRender}
4447
+ onClearCompleted={renderQueue.clearCompleted}
4448
+ onStartRender={async (format, quality, resolution, fps) => {
4449
+ await waitForPendingDomEditSaves();
4450
+ await renderQueue.startRender({ fps, quality, format, resolution });
4451
+ }}
4452
+ isRendering={renderQueue.isRendering}
4453
+ />
4454
+ )}
4455
+ </div>
4456
+ </>
1692
4457
  )}
1693
4458
  </div>
1694
4459
  </>
@@ -1709,6 +4474,20 @@ export function StudioApp() {
1709
4474
  />
1710
4475
  )}
1711
4476
 
4477
+ {/* Ask agent modal */}
4478
+ {agentModalOpen && domEditSelection && (
4479
+ <AskAgentModal
4480
+ selectionLabel={domEditSelection.label}
4481
+ anchorPoint={agentModalAnchorPoint}
4482
+ onSubmit={handleAgentModalSubmit}
4483
+ onClose={() => {
4484
+ setAgentModalOpen(false);
4485
+ setAgentPromptSelectionContext(undefined);
4486
+ setAgentModalAnchorPoint(null);
4487
+ }}
4488
+ />
4489
+ )}
4490
+
1712
4491
  {/* Global drag-drop overlay */}
1713
4492
  {globalDragOver && (
1714
4493
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">