@hyperframes/studio 0.6.0-alpha.13 → 0.6.0-alpha.14

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