@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
package/src/App.tsx CHANGED
@@ -1,24 +1,31 @@
1
- import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useEffect,
6
+ useMemo,
7
+ type MouseEvent,
8
+ type ReactNode,
9
+ } from "react";
2
10
  import { useMountEffect } from "./hooks/useMountEffect";
3
11
  import { NLELayout } from "./components/nle/NLELayout";
4
12
  import { SourceEditor } from "./components/editor/SourceEditor";
5
13
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
6
14
  import { RenderQueue } from "./components/renders/RenderQueue";
7
15
  import { useRenderQueue } from "./components/renders/useRenderQueue";
8
- import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
16
+ import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
9
17
  import { AudioWaveform } from "./player/components/AudioWaveform";
10
18
  import type { TimelineElement } from "./player";
11
19
  import { LintModal } from "./components/LintModal";
12
20
  import type { LintFinding } from "./components/LintModal";
13
21
  import { MediaPreview } from "./components/MediaPreview";
14
- import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
22
+ import { isMediaFile } from "./utils/mediaTypes";
15
23
  import {
16
24
  buildTimelineAssetId,
17
25
  buildTimelineAssetInsertHtml,
18
26
  buildTimelineFileDropPlacements,
19
27
  getTimelineAssetKind,
20
28
  insertTimelineAssetIntoSource,
21
- resolveTimelineAssetInitialGeometry,
22
29
  resolveTimelineAssetSrc,
23
30
  type TimelineAssetKind,
24
31
  } from "./utils/timelineAssetDrop";
@@ -28,13 +35,7 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
28
35
  import { useCaptionStore } from "./captions/store";
29
36
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
30
37
  import { parseCaptionComposition } from "./captions/parser";
31
- import { copyTextToClipboard } from "./utils/clipboard";
32
- import {
33
- applyPatchByTarget,
34
- readAttributeByTarget,
35
- readTagSnippetByTarget,
36
- type PatchOperation,
37
- } from "./utils/sourcePatcher";
38
+ import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
38
39
  import {
39
40
  buildTrackZIndexMap,
40
41
  formatTimelineAttributeNumber,
@@ -44,36 +45,12 @@ import {
44
45
  getTimelineZoomPercent,
45
46
  } from "./player/components/timelineZoom";
46
47
  import {
47
- TIMELINE_TOGGLE_SHORTCUT_LABEL,
48
- getTimelineEditorHintDismissed,
49
48
  getTimelineToggleTitle,
50
- setTimelineEditorHintDismissed,
51
49
  shouldHandleTimelineToggleHotkey,
52
50
  } from "./utils/timelineDiscovery";
53
- import { PropertyPanel } from "./components/editor/PropertyPanel";
54
- import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
55
- import {
56
- fontFamilyFromAssetPath,
57
- importedFontFaceCss,
58
- type ImportedFontAsset,
59
- } from "./components/editor/fontAssets";
60
- import { DomEditOverlay } from "./components/editor/DomEditOverlay";
61
- import {
62
- buildDefaultDomEditTextField,
63
- buildDomEditDetachPatchOperations,
64
- buildDomEditMovePatchOperations,
65
- buildDomEditResizePatchOperations,
66
- buildDomEditStylePatchOperation,
67
- buildDomEditTextPatchOperation,
68
- buildElementAgentPrompt,
69
- findElementForSelection,
70
- isTextEditableSelection,
71
- serializeDomEditTextFields,
72
- resolveDomEditCapabilities,
73
- resolveDomEditSelection,
74
- type DomEditTextField,
75
- type DomEditSelection,
76
- } from "./components/editor/domEditing";
51
+ import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
+ import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
53
+ import { Camera } from "./icons/SystemIcons";
77
54
 
78
55
  interface EditingFile {
79
56
  path: string;
@@ -85,433 +62,8 @@ interface AppToast {
85
62
  tone: "error" | "info";
86
63
  }
87
64
 
88
- type RightPanelTab = "design" | "renders";
89
-
90
- const GENERIC_FONT_FAMILIES = new Set([
91
- "inherit",
92
- "initial",
93
- "revert",
94
- "revert-layer",
95
- "serif",
96
- "sans-serif",
97
- "monospace",
98
- "cursive",
99
- "fantasy",
100
- "system-ui",
101
- "ui-sans-serif",
102
- "ui-serif",
103
- "ui-monospace",
104
- "ui-rounded",
105
- "emoji",
106
- "math",
107
- "fangsong",
108
- ]);
109
-
110
- function primaryFontFamilyFromCss(value: string): string {
111
- const first = value.split(",")[0] ?? "";
112
- return first.trim().replace(/^["']|["']$/g, "");
113
- }
114
-
115
- function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
116
- const family = primaryFontFamilyFromCss(fontFamilyValue);
117
- if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
118
-
119
- const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
120
- if (doc.getElementById(id)) return;
121
-
122
- const link = doc.createElement("link");
123
- link.id = id;
124
- link.rel = "stylesheet";
125
- link.href = googleFontStylesheetUrl(family);
126
- doc.head.appendChild(link);
127
- }
128
-
129
- function primaryFontFamilyValue(value: string): string {
130
- return (
131
- value
132
- .split(",")[0]
133
- ?.trim()
134
- .replace(/^["']|["']$/g, "")
135
- .trim() ?? ""
136
- );
137
- }
138
-
139
- function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
140
- const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
141
- if (doc.getElementById(id)) return;
142
- const style = doc.createElement("style");
143
- style.id = id;
144
- style.textContent = importedFontFaceCss(asset);
145
- doc.head.appendChild(style);
146
- }
147
-
148
- function normalizeProjectAssetPath(value: string): string {
149
- const trimmed = value.trim();
150
- const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
151
- return decodeURIComponent(maybeUrl)
152
- .replace(/\\/g, "/")
153
- .replace(/^\.?\//, "");
154
- }
155
-
156
- function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
157
- const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
158
- const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
159
-
160
- fromParts.pop();
161
-
162
- while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
163
- fromParts.shift();
164
- targetParts.shift();
165
- }
166
-
167
- return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
168
- }
169
-
170
- function isAbsoluteFilePath(value: string): boolean {
171
- return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
172
- }
173
-
174
- function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
175
- const trimmedSource = sourceFile.trim();
176
- if (!trimmedSource) return undefined;
177
-
178
- const normalizedSource = trimmedSource.replace(/\\/g, "/");
179
- if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
180
-
181
- const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
182
- if (!normalizedRoot) return undefined;
183
-
184
- return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
185
- }
186
-
187
- function ensureImportedFontFace(
188
- html: string,
189
- asset: ImportedFontAsset,
190
- sourceFile: string,
191
- ): string {
192
- const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
193
- if (html.includes(css)) return html;
194
-
195
- const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
196
- const styleMatch = styleRe.exec(html);
197
- if (styleMatch) {
198
- const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
199
- return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
200
- }
201
-
202
- const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
203
- if (/<\/head>/i.test(html)) {
204
- return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
205
- }
206
- return `${styleTag}\n${html}`;
207
- }
208
- function normalizeDomEditStyleValue(property: string, value: string): string {
209
- const trimmed = value.trim();
210
- if (!trimmed) return trimmed;
211
-
212
- if (
213
- ["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
214
- /^-?\d+(\.\d+)?$/.test(trimmed)
215
- ) {
216
- return `${trimmed}px`;
217
- }
218
-
219
- return trimmed;
220
- }
221
-
222
- function isImageBackgroundValue(value: string): boolean {
223
- return /^url\(/i.test(value.trim());
224
- }
225
-
226
- function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
227
- return Boolean(
228
- selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
229
- );
230
- }
231
-
232
- function buildOppositeEdgePatchOperations(
233
- selection: DomEditSelection,
234
- dimension: "width" | "height" | "both",
235
- ): PatchOperation[] {
236
- if (!shouldDetachOppositeEdges(selection)) return [];
237
- const operations: PatchOperation[] = [];
238
- if (dimension === "width" || dimension === "both") {
239
- operations.push({ type: "inline-style", property: "right", value: "auto" });
240
- }
241
- if (dimension === "height" || dimension === "both") {
242
- operations.push({ type: "inline-style", property: "bottom", value: "auto" });
243
- }
244
- return operations;
245
- }
246
-
247
- function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
248
- if (!target || typeof target !== "object") return null;
249
- const maybeNode = target as {
250
- nodeType?: number;
251
- parentElement?: Element | null;
252
- };
253
- if (maybeNode.nodeType === 1) return target as HTMLElement;
254
- if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
255
- return maybeNode.parentElement as HTMLElement;
256
- }
257
- return null;
258
- }
259
-
260
- function findMatchingTimelineElementId(
261
- selection: Pick<
262
- DomEditSelection,
263
- "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
264
- >,
265
- elements: TimelineElement[],
266
- ): string | null {
267
- for (const element of elements) {
268
- if (selection.id && element.domId === selection.id) {
269
- return element.key ?? element.id;
270
- }
271
- if (
272
- selection.isCompositionHost &&
273
- selection.compositionSrc &&
274
- element.compositionSrc === selection.compositionSrc
275
- ) {
276
- return element.key ?? element.id;
277
- }
278
- if (
279
- selection.selector &&
280
- element.selector === selection.selector &&
281
- (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
282
- (element.sourceFile ?? "index.html") === selection.sourceFile
283
- ) {
284
- return element.key ?? element.id;
285
- }
286
- }
287
-
288
- return null;
289
- }
290
-
291
- function findMappedCompositionHost(
292
- target: HTMLElement,
293
- timelineElements: TimelineElement[],
294
- compIdToSrc: Map<string, string>,
295
- fileTree: string[],
296
- ): { host: HTMLElement; compositionSrc: string } | null {
297
- const rootCompositionId =
298
- target.ownerDocument
299
- .querySelector("[data-composition-id]")
300
- ?.getAttribute("data-composition-id") ?? null;
301
-
302
- let nestedCurrent: HTMLElement | null = target;
303
- while (nestedCurrent) {
304
- const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
305
- if (nestedCompId && nestedCompId !== rootCompositionId) {
306
- const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
307
- if (hostCandidate instanceof HTMLElement) {
308
- const hostCompId = hostCandidate.getAttribute("data-composition-id");
309
- const compositionSrc =
310
- hostCandidate.getAttribute("data-composition-src") ??
311
- hostCandidate.getAttribute("data-composition-file") ??
312
- (hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
313
- compIdToSrc.get(nestedCompId) ??
314
- fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
315
- undefined;
316
- if (compositionSrc) {
317
- return { host: hostCandidate, compositionSrc };
318
- }
319
- }
320
- }
321
- nestedCurrent = nestedCurrent.parentElement;
322
- }
323
-
324
- let current: HTMLElement | null = target;
325
- while (current) {
326
- const compId = current.getAttribute("data-composition-id");
327
- const directSrc =
328
- current.getAttribute("data-composition-src") ??
329
- current.getAttribute("data-composition-file") ??
330
- undefined;
331
- const timelineMatch =
332
- timelineElements.find(
333
- (element) =>
334
- Boolean(element.compositionSrc) &&
335
- (element.domId === current?.id ||
336
- (current?.id && element.id === current.id) ||
337
- (compId && element.id === compId)),
338
- ) ?? null;
339
- const compositionSrc =
340
- directSrc ??
341
- timelineMatch?.compositionSrc ??
342
- (compId ? compIdToSrc.get(compId) : undefined) ??
343
- (compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
344
- if (compositionSrc) {
345
- return { host: current, compositionSrc };
346
- }
347
- current = current.parentElement;
348
- }
349
-
350
- return null;
351
- }
352
-
353
- function isMoveStyleProperty(property: string): boolean {
354
- return property === "left" || property === "top";
355
- }
356
-
357
- function isResizeStyleProperty(property: string): boolean {
358
- return property === "width" || property === "height";
359
- }
360
-
361
- function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
362
- const offsetParent = element.offsetParent;
363
- if (offsetParent instanceof HTMLElement) return offsetParent;
364
-
365
- let current = element.parentElement;
366
- while (current) {
367
- if (current.hasAttribute("data-composition-id")) return current;
368
- current = current.parentElement;
369
- }
370
-
371
- return element.ownerDocument.body;
372
- }
373
-
374
- function measureDomDetachRect(element: HTMLElement): {
375
- left: number;
376
- top: number;
377
- width: number;
378
- height: number;
379
- } {
380
- const root = getDomDetachCoordinateRoot(element);
381
- const rect = element.getBoundingClientRect();
382
- const rootRect = root.getBoundingClientRect();
383
-
384
- return {
385
- left: rect.left - rootRect.left + root.scrollLeft,
386
- top: rect.top - rootRect.top + root.scrollTop,
387
- width: rect.width,
388
- height: rect.height,
389
- };
390
- }
391
-
392
- function getDomSelectionClickKey(
393
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
394
- ): string {
395
- if (selection.id) return `id:${selection.id}`;
396
- return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
397
- }
398
-
399
- function getPreviewTargetFromPointer(
400
- iframe: HTMLIFrameElement,
401
- clientX: number,
402
- clientY: number,
403
- ): HTMLElement | null {
404
- let doc: Document | null = null;
405
- let win: Window | null = null;
406
- try {
407
- doc = iframe.contentDocument;
408
- win = iframe.contentWindow;
409
- } catch {
410
- return null;
411
- }
412
- if (!doc || !win) return null;
413
-
414
- const iframeRect = iframe.getBoundingClientRect();
415
- const root =
416
- doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
417
- const rootRect = root?.getBoundingClientRect();
418
- const rootWidth = rootRect?.width || win.innerWidth;
419
- const rootHeight = rootRect?.height || win.innerHeight;
420
- if (!rootWidth || !rootHeight) return null;
421
-
422
- const scaleX = iframeRect.width / rootWidth;
423
- const scaleY = iframeRect.height / rootHeight;
424
- const localX = (clientX - iframeRect.left) / scaleX;
425
- const localY = (clientY - iframeRect.top) / scaleY;
426
-
427
- return getEventTargetElement(doc.elementFromPoint(localX, localY));
428
- }
429
-
430
- // ── Ask Agent Modal ──
431
-
432
- function AskAgentModal({
433
- selectionLabel,
434
- onSubmit,
435
- onClose,
436
- }: {
437
- selectionLabel: string;
438
- onSubmit: (instruction: string) => void;
439
- onClose: () => void;
440
- }) {
441
- const [value, setValue] = useState("");
442
- const inputRef = useRef<HTMLTextAreaElement>(null);
443
-
444
- useMountEffect(() => {
445
- requestAnimationFrame(() => inputRef.current?.focus());
446
- });
447
-
448
- const handleSubmit = () => {
449
- if (!value.trim()) return;
450
- onSubmit(value.trim());
451
- };
452
-
453
- return (
454
- <div
455
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
456
- onClick={onClose}
457
- >
458
- <div
459
- className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
460
- onClick={(e) => e.stopPropagation()}
461
- >
462
- <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
463
- <div>
464
- <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
465
- <p className="text-xs text-neutral-500 mt-0.5">
466
- {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
467
- </p>
468
- </div>
469
- <button
470
- className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
471
- onClick={onClose}
472
- >
473
- <svg
474
- width="14"
475
- height="14"
476
- viewBox="0 0 24 24"
477
- fill="none"
478
- stroke="currentColor"
479
- strokeWidth="2"
480
- strokeLinecap="round"
481
- >
482
- <line x1="18" y1="6" x2="6" y2="18" />
483
- <line x1="6" y1="6" x2="18" y2="18" />
484
- </svg>
485
- </button>
486
- </div>
487
- <div className="px-5 py-4">
488
- <textarea
489
- ref={inputRef}
490
- 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"
491
- placeholder="Describe what you want to change…"
492
- value={value}
493
- onChange={(e) => setValue(e.target.value)}
494
- onKeyDown={(e) => {
495
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
496
- if (e.key === "Escape") onClose();
497
- }}
498
- />
499
- </div>
500
- <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
501
- <span className="text-[11px] text-neutral-600">
502
- {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
503
- </span>
504
- <button
505
- 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"
506
- disabled={!value.trim()}
507
- onClick={handleSubmit}
508
- >
509
- Copy prompt
510
- </button>
511
- </div>
512
- </div>
513
- </div>
514
- );
65
+ function getTimelineElementLabel(element: TimelineElement): string {
66
+ return element.label || element.id || element.tag;
515
67
  }
516
68
 
517
69
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
@@ -571,9 +123,9 @@ export function StudioApp() {
571
123
  const [resolving, setResolving] = useState(true);
572
124
 
573
125
  useMountEffect(() => {
574
- const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
575
- if (hashMatch) {
576
- setProjectId(hashMatch[1]);
126
+ const hashProjectId = parseProjectIdFromHash(window.location.hash);
127
+ if (hashProjectId) {
128
+ setProjectId(hashProjectId);
577
129
  setResolving(false);
578
130
  return;
579
131
  }
@@ -584,7 +136,7 @@ export function StudioApp() {
584
136
  const first = (data.projects ?? [])[0];
585
137
  if (first) {
586
138
  setProjectId(first.id);
587
- window.location.hash = `#project/${first.id}`;
139
+ window.location.hash = buildProjectHash(first.id);
588
140
  }
589
141
  })
590
142
  .catch(() => {})
@@ -592,7 +144,6 @@ export function StudioApp() {
592
144
  });
593
145
 
594
146
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
595
- const [projectDir, setProjectDir] = useState<string | null>(null);
596
147
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
597
148
  const [fileTree, setFileTree] = useState<string[]>([]);
598
149
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -606,12 +157,6 @@ export function StudioApp() {
606
157
  const [rightWidth, setRightWidth] = useState(400);
607
158
  const [leftCollapsed, setLeftCollapsed] = useState(false);
608
159
  const [rightCollapsed, setRightCollapsed] = useState(true);
609
- const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
610
- const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
611
- const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
612
- const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
613
- const [agentModalOpen, setAgentModalOpen] = useState(false);
614
- const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
615
160
  // Auto-enter caption edit mode when the iframe contains .caption-group elements.
616
161
  // This is a subscription to external events (postMessage from runtime) — useEffect
617
162
  // is appropriate here. The runtime fires "state"/"timeline" messages after all
@@ -734,14 +279,10 @@ export function StudioApp() {
734
279
  const [globalDragOver, setGlobalDragOver] = useState(false);
735
280
  const [appToast, setAppToast] = useState<AppToast | null>(null);
736
281
  const [timelineVisible, setTimelineVisible] = useState(true);
737
- const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
738
- getTimelineEditorHintDismissed,
739
- );
282
+ const [captureFrameTime, setCaptureFrameTime] = useState(0);
740
283
  const dragCounterRef = useRef(0);
741
284
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
742
285
  const lastBlockedTimelineToastAtRef = useRef(0);
743
- const lastBlockedDomMoveToastAtRef = useRef(0);
744
- const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
745
286
  const previewHotkeyWindowRef = useRef<Window | null>(null);
746
287
  const panelDragRef = useRef<{
747
288
  side: "left" | "right";
@@ -753,14 +294,11 @@ export function StudioApp() {
753
294
  const activePreviewUrl = activeCompPath
754
295
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
755
296
  : null;
756
- const isMasterView = !activeCompPath || activeCompPath === "index.html";
757
297
  const zoomMode = usePlayerStore((s) => s.zoomMode);
758
298
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
759
299
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
760
300
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
761
- const currentTime = usePlayerStore((s) => s.currentTime);
762
301
  const timelineElements = usePlayerStore((s) => s.elements);
763
- const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
764
302
  const timelineDuration = usePlayerStore((s) => s.duration);
765
303
  const effectiveTimelineDuration = useMemo(() => {
766
304
  const maxEnd =
@@ -776,13 +314,29 @@ export function StudioApp() {
776
314
  const toggleTimelineVisibility = useCallback(() => {
777
315
  setTimelineVisible((visible) => !visible);
778
316
  }, []);
317
+ const toggleLeftSidebar = useCallback(() => {
318
+ setLeftCollapsed((collapsed) => !collapsed);
319
+ }, []);
320
+ const refreshCaptureFrameTime = useCallback(() => {
321
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
322
+ }, []);
323
+
324
+ useMountEffect(() => {
325
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
326
+ return liveTime.subscribe(setCaptureFrameTime);
327
+ });
328
+
329
+ const captureFrameHref = projectId
330
+ ? buildFrameCaptureUrl({
331
+ projectId,
332
+ compositionPath: activeCompPath,
333
+ currentTime: captureFrameTime,
334
+ })
335
+ : "#";
336
+ const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
779
337
  useMountEffect(() => () => {
780
338
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
781
339
  });
782
- const dismissTimelineEditorHint = useCallback(() => {
783
- setTimelineEditorHintState(true);
784
- setTimelineEditorHintDismissed(true);
785
- }, []);
786
340
  const handleTimelineToggleHotkey = useCallback(
787
341
  (event: KeyboardEvent) => {
788
342
  if (!shouldHandleTimelineToggleHotkey(event)) return;
@@ -843,9 +397,10 @@ export function StudioApp() {
843
397
  return (
844
398
  <CompositionThumbnail
845
399
  previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
846
- label={el.id || el.tag}
400
+ label={getTimelineElementLabel(el)}
847
401
  labelColor={style.label}
848
402
  accentColor={style.clip}
403
+ selector={el.selector}
849
404
  seekTime={0}
850
405
  duration={el.duration}
851
406
  />
@@ -858,11 +413,10 @@ export function StudioApp() {
858
413
  return (
859
414
  <CompositionThumbnail
860
415
  previewUrl={activePreviewUrl}
861
- label={el.id || el.tag}
416
+ label={getTimelineElementLabel(el)}
862
417
  labelColor={style.label}
863
418
  accentColor={style.clip}
864
419
  selector={el.selector}
865
- selectorIndex={el.selectorIndex}
866
420
  seekTime={el.start}
867
421
  duration={el.duration}
868
422
  />
@@ -896,7 +450,7 @@ export function StudioApp() {
896
450
  <AudioWaveform
897
451
  audioUrl={audioUrl}
898
452
  waveformUrl={waveformUrl}
899
- label={el.id || el.tag}
453
+ label={getTimelineElementLabel(el)}
900
454
  labelColor={style.label}
901
455
  />
902
456
  );
@@ -909,7 +463,7 @@ export function StudioApp() {
909
463
  return (
910
464
  <VideoThumbnail
911
465
  videoSrc={mediaSrc}
912
- label={el.id || el.tag}
466
+ label={getTimelineElementLabel(el)}
913
467
  labelColor={style.label}
914
468
  duration={el.duration}
915
469
  />
@@ -920,11 +474,10 @@ export function StudioApp() {
920
474
  return (
921
475
  <CompositionThumbnail
922
476
  previewUrl={`/api/projects/${pid}/preview`}
923
- label={el.id || el.tag}
477
+ label={getTimelineElementLabel(el)}
924
478
  labelColor={style.label}
925
479
  accentColor={style.clip}
926
480
  selector={el.selector}
927
- selectorIndex={el.selectorIndex}
928
481
  seekTime={el.start}
929
482
  duration={el.duration}
930
483
  />
@@ -937,31 +490,6 @@ export function StudioApp() {
937
490
  );
938
491
  const timelineToolbar = (
939
492
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
940
- {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
941
- <div className="px-3 pt-3">
942
- <div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
943
- <div className="min-w-0">
944
- <div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
945
- <p className="mt-1 text-[11px] leading-5 text-neutral-300">
946
- Drag clips to move timing, and drag clip edges to resize them when handles are
947
- available. Hide the panel anytime and bring it back with{" "}
948
- <span className="font-mono text-[10px] text-studio-accent">
949
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
950
- </span>
951
- .
952
- </p>
953
- </div>
954
- <button
955
- type="button"
956
- onClick={dismissTimelineEditorHint}
957
- className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
958
- >
959
- Dismiss
960
- </button>
961
- </div>
962
- </div>
963
- )}
964
-
965
493
  <div className="flex items-center justify-between px-3 py-2">
966
494
  <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
967
495
  Timeline
@@ -1004,6 +532,28 @@ export function StudioApp() {
1004
532
  >
1005
533
  +
1006
534
  </button>
535
+ <button
536
+ type="button"
537
+ onClick={toggleTimelineVisibility}
538
+ 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"
539
+ title={getTimelineToggleTitle(true)}
540
+ aria-label="Hide timeline editor"
541
+ >
542
+ <svg
543
+ width="14"
544
+ height="14"
545
+ viewBox="0 0 24 24"
546
+ fill="none"
547
+ stroke="currentColor"
548
+ strokeWidth="1.8"
549
+ strokeLinecap="round"
550
+ strokeLinejoin="round"
551
+ aria-hidden="true"
552
+ >
553
+ <path d="M5 7h14" />
554
+ <path d="m8 11 4 4 4-4" />
555
+ </svg>
556
+ </button>
1007
557
  </div>
1008
558
  </div>
1009
559
  </div>
@@ -1017,20 +567,11 @@ export function StudioApp() {
1017
567
  const projectIdRef = useRef(projectId);
1018
568
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
1019
569
  const consoleErrorsRef = useRef<LintFinding[]>([]);
1020
- const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1021
- const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
1022
- const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
1023
- const domEditSaveTimestampRef = useRef(0);
1024
- const domTextCommitVersionRef = useRef(0);
1025
570
 
1026
571
  // Listen for external file changes (user editing HTML outside the editor).
1027
572
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
1028
- // Suppress file-change events that echo back from a recent DOM edit save —
1029
- // those changes are already applied to the iframe DOM and a full reload
1030
- // would flash the preview.
1031
573
  useMountEffect(() => {
1032
574
  const handler = () => {
1033
- if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
1034
575
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1035
576
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
1036
577
  };
@@ -1044,7 +585,6 @@ export function StudioApp() {
1044
585
  return () => es.close();
1045
586
  });
1046
587
  projectIdRef.current = projectId;
1047
- domEditSelectionRef.current = domEditSelection;
1048
588
 
1049
589
  // Load file tree when projectId changes.
1050
590
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -1056,13 +596,10 @@ export function StudioApp() {
1056
596
  let cancelled = false;
1057
597
  fetch(`/api/projects/${projectId}`)
1058
598
  .then((r) => r.json())
1059
- .then((data: { files?: string[]; dir?: string }) => {
599
+ .then((data: { files?: string[] }) => {
1060
600
  if (!cancelled && data.files) setFileTree(data.files);
1061
- if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
1062
601
  })
1063
- .catch(() => {
1064
- if (!cancelled) setProjectDir(null);
1065
- });
602
+ .catch(() => {});
1066
603
  return () => {
1067
604
  cancelled = true;
1068
605
  };
@@ -1308,6 +845,42 @@ export function StudioApp() {
1308
845
  toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
1309
846
  }, []);
1310
847
 
848
+ const handleCaptureFrameClick = useCallback(
849
+ async (event: MouseEvent<HTMLAnchorElement>) => {
850
+ if (!projectId) return;
851
+ event.preventDefault();
852
+
853
+ const currentTime = usePlayerStore.getState().currentTime;
854
+ setCaptureFrameTime(currentTime);
855
+ const href = buildFrameCaptureUrl({
856
+ projectId,
857
+ compositionPath: activeCompPath,
858
+ currentTime,
859
+ });
860
+ const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
861
+
862
+ try {
863
+ const response = await fetch(href, { cache: "no-store" });
864
+ if (!response.ok) {
865
+ throw new Error(`Capture failed (${response.status})`);
866
+ }
867
+ const blob = await response.blob();
868
+ const blobUrl = URL.createObjectURL(blob);
869
+ const link = document.createElement("a");
870
+ link.href = blobUrl;
871
+ link.download = filename;
872
+ document.body.appendChild(link);
873
+ link.click();
874
+ link.remove();
875
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
876
+ } catch (err) {
877
+ const message = err instanceof Error ? err.message : "Capture failed";
878
+ showToast(message);
879
+ }
880
+ },
881
+ [activeCompPath, projectId, showToast],
882
+ );
883
+
1311
884
  const handleTimelineElementDelete = useCallback(
1312
885
  async (element: TimelineElement) => {
1313
886
  const pid = projectIdRef.current;
@@ -1432,722 +1005,6 @@ export function StudioApp() {
1432
1005
  [showToast],
1433
1006
  );
1434
1007
 
1435
- const handleBlockedDomMove = useCallback(
1436
- (selection: DomEditSelection) => {
1437
- const now = Date.now();
1438
- if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1439
- lastBlockedDomMoveToastAtRef.current = now;
1440
- showToast(
1441
- selection.capabilities.canDetachFromLayout
1442
- ? "This layer is controlled by layout. Use Make movable in the panel to detach it."
1443
- : (selection.capabilities.reasonIfDisabled ??
1444
- "This element can’t be moved directly from the preview."),
1445
- "info",
1446
- );
1447
- },
1448
- [showToast],
1449
- );
1450
-
1451
- const applyDomSelection = useCallback(
1452
- (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
1453
- setDomEditSelection(selection);
1454
- setAgentPromptTagSnippet(undefined);
1455
- setCopiedAgentPrompt(false);
1456
- if (selection) {
1457
- if (options?.revealPanel !== false) {
1458
- setRightCollapsed(false);
1459
- setRightPanelTab("design");
1460
- }
1461
- const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
1462
- setSelectedTimelineElementId(nextSelectedTimelineId);
1463
- return;
1464
- }
1465
-
1466
- setSelectedTimelineElementId(null);
1467
- },
1468
- [setSelectedTimelineElementId, timelineElements],
1469
- );
1470
-
1471
- const clearDomSelection = useCallback(() => {
1472
- applyDomSelection(null, { revealPanel: false });
1473
- }, [applyDomSelection]);
1474
-
1475
- const buildDomSelectionFromTarget = useCallback(
1476
- (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
1477
- if (isMasterView) {
1478
- const mappedHost = findMappedCompositionHost(
1479
- target,
1480
- timelineElements,
1481
- compIdToSrc,
1482
- fileTree,
1483
- );
1484
- if (mappedHost) {
1485
- const hostSelection = resolveDomEditSelection(mappedHost.host, {
1486
- activeCompositionPath: activeCompPath,
1487
- isMasterView,
1488
- preferClipAncestor: options?.preferClipAncestor,
1489
- });
1490
- if (!hostSelection) return null;
1491
- return {
1492
- ...hostSelection,
1493
- compositionSrc: mappedHost.compositionSrc,
1494
- isCompositionHost: true,
1495
- capabilities: resolveDomEditCapabilities({
1496
- selector: hostSelection.selector,
1497
- tagName: hostSelection.tagName,
1498
- className: hostSelection.element.className,
1499
- inlineStyles: hostSelection.inlineStyles,
1500
- computedStyles: hostSelection.computedStyles,
1501
- isCompositionHost: true,
1502
- isMasterView: true,
1503
- }),
1504
- } satisfies DomEditSelection;
1505
- }
1506
- }
1507
-
1508
- return resolveDomEditSelection(target, {
1509
- activeCompositionPath: activeCompPath,
1510
- isMasterView,
1511
- preferClipAncestor: options?.preferClipAncestor,
1512
- });
1513
- },
1514
- [activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
1515
- );
1516
-
1517
- const preloadAgentPromptSnippet = useCallback(
1518
- async (selection: DomEditSelection) => {
1519
- const pid = projectIdRef.current;
1520
- if (!pid) return;
1521
-
1522
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
1523
- try {
1524
- const response = await fetch(
1525
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1526
- );
1527
- if (!response.ok) return;
1528
-
1529
- const data = (await response.json()) as { content?: string };
1530
- const html = data.content;
1531
- const tagSnippet =
1532
- typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
1533
-
1534
- setAgentPromptTagSnippet((current) => {
1535
- if (domEditSelectionRef.current !== selection) return current;
1536
- return tagSnippet;
1537
- });
1538
- } catch {
1539
- // Runtime outerHTML is still available as a synchronous copy fallback.
1540
- }
1541
- },
1542
- [activeCompPath],
1543
- );
1544
-
1545
- const resolveImportedFontAsset = useCallback(
1546
- (fontFamilyValue: string): ImportedFontAsset | null => {
1547
- const family = primaryFontFamilyValue(fontFamilyValue);
1548
- if (!family) return null;
1549
- const imported = importedFontAssetsRef.current.find(
1550
- (font) => font.family.toLowerCase() === family.toLowerCase(),
1551
- );
1552
- if (imported) return imported;
1553
- const asset = fileTree.find(
1554
- (path) =>
1555
- FONT_EXT.test(path) &&
1556
- fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
1557
- );
1558
- if (!asset) return null;
1559
- return {
1560
- family: fontFamilyFromAssetPath(asset),
1561
- path: asset,
1562
- url: `/api/projects/${projectId}/preview/${asset}`,
1563
- };
1564
- },
1565
- [fileTree, projectId],
1566
- );
1567
-
1568
- const persistDomEditOperations = useCallback(
1569
- async (
1570
- selection: DomEditSelection,
1571
- operations: Parameters<typeof applyPatchByTarget>[2][],
1572
- options?: {
1573
- skipRefresh?: boolean;
1574
- prepareContent?: (html: string, sourceFile: string) => string;
1575
- shouldSave?: () => boolean;
1576
- },
1577
- ) => {
1578
- const pid = projectIdRef.current;
1579
- if (!pid) throw new Error("No active project");
1580
- if (options?.shouldSave && !options.shouldSave()) return;
1581
-
1582
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
1583
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1584
- if (!response.ok) {
1585
- throw new Error(`Failed to read ${targetPath}`);
1586
- }
1587
-
1588
- const data = (await response.json()) as { content?: string };
1589
- const originalContent = data.content;
1590
- if (typeof originalContent !== "string") {
1591
- throw new Error(`Missing file contents for ${targetPath}`);
1592
- }
1593
-
1594
- let patchedContent = originalContent;
1595
- for (const operation of operations) {
1596
- patchedContent = applyPatchByTarget(patchedContent, selection, operation);
1597
- }
1598
- if (options?.prepareContent) {
1599
- patchedContent = options.prepareContent(patchedContent, targetPath);
1600
- }
1601
- if (options?.shouldSave && !options.shouldSave()) return;
1602
-
1603
- if (patchedContent === originalContent) {
1604
- throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
1605
- }
1606
-
1607
- const saveResponse = await fetch(
1608
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1609
- {
1610
- method: "PUT",
1611
- headers: { "Content-Type": "text/plain" },
1612
- body: patchedContent,
1613
- },
1614
- );
1615
- if (!saveResponse.ok) {
1616
- throw new Error(`Failed to save ${targetPath}`);
1617
- }
1618
-
1619
- if (editingPathRef.current === targetPath) {
1620
- setEditingFile({ path: targetPath, content: patchedContent });
1621
- }
1622
-
1623
- if (options?.skipRefresh) {
1624
- domEditSaveTimestampRef.current = Date.now();
1625
- } else {
1626
- setRefreshKey((k) => k + 1);
1627
- }
1628
- },
1629
- [activeCompPath],
1630
- );
1631
-
1632
- const handleDomMoveCommit = useCallback(
1633
- async (selection: DomEditSelection, next: { left: number; top: number }) => {
1634
- await persistDomEditOperations(
1635
- selection,
1636
- [
1637
- ...buildDomEditMovePatchOperations(next.left, next.top),
1638
- ...buildOppositeEdgePatchOperations(selection, "both"),
1639
- ],
1640
- { skipRefresh: true },
1641
- );
1642
- },
1643
- [persistDomEditOperations],
1644
- );
1645
-
1646
- const handleDomResizeCommit = useCallback(
1647
- async (selection: DomEditSelection, next: { width: number; height: number }) => {
1648
- if (shouldDetachOppositeEdges(selection)) {
1649
- selection.element.style.right = "auto";
1650
- selection.element.style.bottom = "auto";
1651
- }
1652
- await persistDomEditOperations(
1653
- selection,
1654
- [
1655
- ...buildDomEditResizePatchOperations(next.width, next.height),
1656
- ...buildOppositeEdgePatchOperations(selection, "both"),
1657
- ],
1658
- { skipRefresh: true },
1659
- );
1660
- },
1661
- [persistDomEditOperations],
1662
- );
1663
-
1664
- const handleDomDetachFromLayout = useCallback(async () => {
1665
- const selection = domEditSelection;
1666
- if (!selection?.capabilities.canDetachFromLayout) return;
1667
-
1668
- const doc = previewIframeRef.current?.contentDocument;
1669
- const element = doc
1670
- ? findElementForSelection(doc, selection, selection.sourceFile)
1671
- : selection.element;
1672
- if (!element) {
1673
- showToast("Could not find the selected layer in the preview.", "info");
1674
- return;
1675
- }
1676
-
1677
- const rect = measureDomDetachRect(element);
1678
- const operations = buildDomEditDetachPatchOperations(rect);
1679
-
1680
- for (const operation of operations) {
1681
- element.style.setProperty(operation.property, operation.value);
1682
- }
1683
-
1684
- await persistDomEditOperations(selection, operations, { skipRefresh: true });
1685
-
1686
- const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
1687
- if (refreshed) {
1688
- const nextSelection = buildDomSelectionFromTarget(refreshed);
1689
- if (nextSelection) {
1690
- applyDomSelection(nextSelection, { revealPanel: false });
1691
- }
1692
- }
1693
- showToast("Layer detached from layout. You can move it now.", "info");
1694
- }, [
1695
- applyDomSelection,
1696
- buildDomSelectionFromTarget,
1697
- domEditSelection,
1698
- persistDomEditOperations,
1699
- showToast,
1700
- ]);
1701
-
1702
- const handleDomStyleCommit = useCallback(
1703
- async (property: string, value: string) => {
1704
- if (!domEditSelection) return;
1705
- const isMoveStyle = isMoveStyleProperty(property);
1706
- const isResizeStyle = isResizeStyleProperty(property);
1707
- if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
1708
- if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
1709
- if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
1710
- const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1711
- const iframe = previewIframeRef.current;
1712
- const doc = iframe?.contentDocument;
1713
- if (doc) {
1714
- const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1715
- if (el) {
1716
- el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
1717
- if (property === "font-family") {
1718
- injectPreviewGoogleFont(doc, value);
1719
- if (importedFont) injectPreviewImportedFont(doc, importedFont);
1720
- }
1721
- if (shouldDetachOppositeEdges(domEditSelection)) {
1722
- if (property === "width") el.style.right = "auto";
1723
- if (property === "height") el.style.bottom = "auto";
1724
- }
1725
- if (property === "background-image" && isImageBackgroundValue(value)) {
1726
- el.style.setProperty("background-position", "center");
1727
- el.style.setProperty("background-repeat", "no-repeat");
1728
- el.style.setProperty("background-size", "contain");
1729
- }
1730
- }
1731
- }
1732
- const operations: PatchOperation[] = [
1733
- buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
1734
- ];
1735
- if (property === "width") {
1736
- operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
1737
- } else if (property === "height") {
1738
- operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
1739
- } else if (property === "background-image" && isImageBackgroundValue(value)) {
1740
- operations.push(
1741
- buildDomEditStylePatchOperation("background-position", "center"),
1742
- buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
1743
- buildDomEditStylePatchOperation("background-size", "contain"),
1744
- );
1745
- }
1746
- await persistDomEditOperations(domEditSelection, operations, {
1747
- skipRefresh: true,
1748
- prepareContent: importedFont
1749
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1750
- : undefined,
1751
- });
1752
- },
1753
- [domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
1754
- );
1755
-
1756
- const handleDomTextCommit = useCallback(
1757
- async (value: string, fieldKey?: string) => {
1758
- if (!domEditSelection) return;
1759
- if (!isTextEditableSelection(domEditSelection)) return;
1760
- const commitVersion = domTextCommitVersionRef.current + 1;
1761
- domTextCommitVersionRef.current = commitVersion;
1762
- const nextTextFields =
1763
- domEditSelection.textFields.length > 0
1764
- ? domEditSelection.textFields.map((field) =>
1765
- field.key === fieldKey ? { ...field, value } : field,
1766
- )
1767
- : [];
1768
- const nextContent =
1769
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1770
- ? serializeDomEditTextFields(nextTextFields)
1771
- : value;
1772
- const iframe = previewIframeRef.current;
1773
- const doc = iframe?.contentDocument;
1774
- if (doc) {
1775
- const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1776
- if (el) {
1777
- if (
1778
- nextTextFields.length > 1 ||
1779
- nextTextFields.some((field) => field.source === "child")
1780
- ) {
1781
- el.innerHTML = nextContent;
1782
- } else {
1783
- el.textContent = value;
1784
- }
1785
- }
1786
- }
1787
- await persistDomEditOperations(
1788
- domEditSelection,
1789
- [buildDomEditTextPatchOperation(nextContent)],
1790
- {
1791
- skipRefresh: true,
1792
- shouldSave: () => domTextCommitVersionRef.current === commitVersion,
1793
- },
1794
- );
1795
- if (domTextCommitVersionRef.current !== commitVersion) return;
1796
-
1797
- if (doc) {
1798
- const refreshed = findElementForSelection(
1799
- doc,
1800
- domEditSelection,
1801
- domEditSelection.sourceFile,
1802
- );
1803
- if (refreshed) {
1804
- const nextSelection = buildDomSelectionFromTarget(refreshed);
1805
- if (nextSelection) {
1806
- applyDomSelection(nextSelection, { revealPanel: false });
1807
- }
1808
- }
1809
- }
1810
- },
1811
- [applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
1812
- );
1813
-
1814
- const commitDomTextFields = useCallback(
1815
- async (
1816
- selection: DomEditSelection,
1817
- nextTextFields: DomEditTextField[],
1818
- options?: { importedFont?: ImportedFontAsset | null },
1819
- ) => {
1820
- const nextContent =
1821
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1822
- ? serializeDomEditTextFields(nextTextFields)
1823
- : (nextTextFields[0]?.value ?? "");
1824
-
1825
- const iframe = previewIframeRef.current;
1826
- const doc = iframe?.contentDocument;
1827
- if (doc) {
1828
- const el = findElementForSelection(doc, selection, selection.sourceFile);
1829
- if (el) {
1830
- if (
1831
- nextTextFields.length > 1 ||
1832
- nextTextFields.some((field) => field.source === "child")
1833
- ) {
1834
- el.innerHTML = nextContent;
1835
- } else {
1836
- el.textContent = nextContent;
1837
- }
1838
- }
1839
- }
1840
-
1841
- const importedFont = options?.importedFont ?? null;
1842
- await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
1843
- skipRefresh: true,
1844
- prepareContent: importedFont
1845
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1846
- : undefined,
1847
- });
1848
-
1849
- if (doc) {
1850
- const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
1851
- if (refreshed) {
1852
- const nextSelection = buildDomSelectionFromTarget(refreshed);
1853
- if (nextSelection) {
1854
- applyDomSelection(nextSelection, { revealPanel: false });
1855
- }
1856
- }
1857
- }
1858
- },
1859
- [applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
1860
- );
1861
-
1862
- const handleDomTextFieldStyleCommit = useCallback(
1863
- async (fieldKey: string, property: string, value: string) => {
1864
- if (!domEditSelection) return;
1865
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1866
- if (!field) return;
1867
-
1868
- if (field.source === "self") {
1869
- await handleDomStyleCommit(property, value);
1870
- return;
1871
- }
1872
-
1873
- const normalizedValue = normalizeDomEditStyleValue(property, value);
1874
- const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1875
- if (property === "font-family") {
1876
- const doc = previewIframeRef.current?.contentDocument;
1877
- if (doc) {
1878
- injectPreviewGoogleFont(doc, normalizedValue);
1879
- if (importedFont) injectPreviewImportedFont(doc, importedFont);
1880
- }
1881
- }
1882
- const nextTextFields = domEditSelection.textFields.map((entry) =>
1883
- entry.key === fieldKey
1884
- ? {
1885
- ...entry,
1886
- inlineStyles: {
1887
- ...entry.inlineStyles,
1888
- [property]: normalizedValue,
1889
- },
1890
- computedStyles: {
1891
- ...entry.computedStyles,
1892
- [property]: normalizedValue,
1893
- },
1894
- }
1895
- : entry,
1896
- );
1897
-
1898
- await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
1899
- },
1900
- [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
1901
- );
1902
-
1903
- const handleDomAddTextField = useCallback(
1904
- async (afterFieldKey?: string) => {
1905
- if (!domEditSelection) return null;
1906
- if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
1907
-
1908
- const insertionIndex = domEditSelection.textFields.findIndex(
1909
- (field) => field.key === afterFieldKey,
1910
- );
1911
- const baseField =
1912
- domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
1913
- domEditSelection.textFields[0];
1914
- const nextField = buildDefaultDomEditTextField(baseField);
1915
- const nextTextFields = [...domEditSelection.textFields];
1916
- nextTextFields.splice(
1917
- insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
1918
- 0,
1919
- nextField,
1920
- );
1921
-
1922
- await commitDomTextFields(domEditSelection, nextTextFields);
1923
- return nextField.key;
1924
- },
1925
- [commitDomTextFields, domEditSelection],
1926
- );
1927
-
1928
- const handleDomRemoveTextField = useCallback(
1929
- async (fieldKey: string) => {
1930
- if (!domEditSelection) return;
1931
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1932
- if (!field) return;
1933
-
1934
- if (field.source === "self") {
1935
- await handleDomTextCommit("", fieldKey);
1936
- return;
1937
- }
1938
-
1939
- const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
1940
- await commitDomTextFields(domEditSelection, nextTextFields);
1941
- },
1942
- [commitDomTextFields, domEditSelection, handleDomTextCommit],
1943
- );
1944
-
1945
- const handleAskAgent = useCallback(() => {
1946
- if (!domEditSelection) return;
1947
- setAgentPromptTagSnippet(undefined);
1948
- void preloadAgentPromptSnippet(domEditSelection);
1949
- setAgentModalOpen(true);
1950
- }, [domEditSelection, preloadAgentPromptSnippet]);
1951
-
1952
- const handleAgentModalSubmit = useCallback(
1953
- async (userInstruction: string) => {
1954
- if (!domEditSelection) return;
1955
-
1956
- const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
1957
- const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
1958
- const prompt = buildElementAgentPrompt({
1959
- selection: domEditSelection,
1960
- currentTime,
1961
- tagSnippet,
1962
- userInstruction,
1963
- sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
1964
- });
1965
-
1966
- const copied = await copyTextToClipboard(prompt);
1967
- if (!copied) {
1968
- showToast("Could not copy prompt to clipboard.", "error");
1969
- return;
1970
- }
1971
-
1972
- setAgentModalOpen(false);
1973
- if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
1974
- setCopiedAgentPrompt(true);
1975
- copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
1976
- },
1977
- [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
1978
- );
1979
-
1980
- const handlePreviewIframeRef = useCallback(
1981
- (iframe: HTMLIFrameElement | null) => {
1982
- previewIframeRef.current = iframe;
1983
- setPreviewIframe(iframe);
1984
- syncPreviewTimelineHotkey(iframe);
1985
- consoleErrorsRef.current = [];
1986
- setConsoleErrors(null);
1987
- },
1988
- [syncPreviewTimelineHotkey],
1989
- );
1990
-
1991
- const handlePreviewCanvasMouseDown = useCallback(
1992
- (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
1993
- const iframe = previewIframeRef.current;
1994
- if (!iframe || captionEditMode) return;
1995
- const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
1996
- if (!target) {
1997
- lastPreviewClickRef.current = null;
1998
- applyDomSelection(null, { revealPanel: false });
1999
- return;
2000
- }
2001
- e.preventDefault();
2002
- e.stopPropagation();
2003
- const nextSelection = buildDomSelectionFromTarget(target, {
2004
- preferClipAncestor: options?.preferClipAncestor ?? true,
2005
- });
2006
- if (!nextSelection) {
2007
- lastPreviewClickRef.current = null;
2008
- applyDomSelection(null, { revealPanel: false });
2009
- return;
2010
- }
2011
- if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
2012
- const key = getDomSelectionClickKey(nextSelection);
2013
- const last = lastPreviewClickRef.current;
2014
- const now = Date.now();
2015
- if (last && last.key === key && now - last.at < 350) {
2016
- lastPreviewClickRef.current = null;
2017
- applyDomSelection(null, { revealPanel: false });
2018
- setActiveCompPath(nextSelection.compositionSrc);
2019
- return;
2020
- }
2021
- lastPreviewClickRef.current = { key, at: now };
2022
- } else {
2023
- lastPreviewClickRef.current = null;
2024
- }
2025
- applyDomSelection(nextSelection);
2026
- },
2027
- [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2028
- );
2029
-
2030
- const handlePreviewCanvasDoubleClick = useCallback(
2031
- (e: React.MouseEvent<HTMLDivElement>) => {
2032
- const iframe = previewIframeRef.current;
2033
- if (!iframe || captionEditMode) return;
2034
- const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
2035
- if (!target) return;
2036
- const nextSelection = buildDomSelectionFromTarget(target, {
2037
- preferClipAncestor: false,
2038
- });
2039
- if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
2040
- return;
2041
- }
2042
- e.preventDefault();
2043
- e.stopPropagation();
2044
- lastPreviewClickRef.current = null;
2045
- applyDomSelection(null, { revealPanel: false });
2046
- setActiveCompPath(nextSelection.compositionSrc);
2047
- },
2048
- [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2049
- );
2050
-
2051
- const handleSelectedOverlayDoubleClick = useCallback(() => {
2052
- const selection = domEditSelectionRef.current;
2053
- if (!selection?.isCompositionHost || !selection.compositionSrc) return;
2054
- applyDomSelection(null, { revealPanel: false });
2055
- setActiveCompPath(selection.compositionSrc);
2056
- }, [applyDomSelection]);
2057
-
2058
- // eslint-disable-next-line no-restricted-syntax
2059
- useEffect(() => {
2060
- if (!previewIframe || captionEditMode) return;
2061
-
2062
- const syncSelectionFromDocument = () => {
2063
- const currentSelection = domEditSelectionRef.current;
2064
- if (!currentSelection) return;
2065
- let doc: Document | null = null;
2066
- try {
2067
- doc = previewIframe.contentDocument;
2068
- } catch {
2069
- return;
2070
- }
2071
- if (!doc) return;
2072
-
2073
- const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
2074
- if (!nextElement) {
2075
- applyDomSelection(null, { revealPanel: false });
2076
- return;
2077
- }
2078
-
2079
- const nextSelection = buildDomSelectionFromTarget(nextElement);
2080
- if (nextSelection) {
2081
- applyDomSelection(nextSelection, { revealPanel: false });
2082
- }
2083
- };
2084
-
2085
- const attachErrorCapture = () => {
2086
- try {
2087
- const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
2088
- if (!win) return;
2089
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
2090
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
2091
- const origError = win.console.error.bind(win.console);
2092
- win.console.error = function (...args: unknown[]) {
2093
- origError(...args);
2094
- const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
2095
- if (text.includes("favicon")) return;
2096
- consoleErrorsRef.current = [
2097
- ...consoleErrorsRef.current,
2098
- { severity: "error", message: text },
2099
- ];
2100
- setConsoleErrors([...consoleErrorsRef.current]);
2101
- };
2102
- win.addEventListener("error", (e: ErrorEvent) => {
2103
- const text = e.message || String(e);
2104
- consoleErrorsRef.current = [
2105
- ...consoleErrorsRef.current,
2106
- { severity: "error", message: text },
2107
- ];
2108
- setConsoleErrors([...consoleErrorsRef.current]);
2109
- });
2110
- } catch {
2111
- // same-origin only
2112
- }
2113
- };
2114
-
2115
- attachErrorCapture();
2116
- syncSelectionFromDocument();
2117
-
2118
- const handleLoad = () => {
2119
- consoleErrorsRef.current = [];
2120
- setConsoleErrors(null);
2121
- attachErrorCapture();
2122
- syncSelectionFromDocument();
2123
- };
2124
-
2125
- previewIframe.addEventListener("load", handleLoad);
2126
- return () => {
2127
- previewIframe.removeEventListener("load", handleLoad);
2128
- };
2129
- }, [
2130
- activeCompPath,
2131
- applyDomSelection,
2132
- buildDomSelectionFromTarget,
2133
- captionEditMode,
2134
- previewIframe,
2135
- ]);
2136
-
2137
- // eslint-disable-next-line no-restricted-syntax
2138
- useEffect(() => {
2139
- if (!captionEditMode) return;
2140
- applyDomSelection(null, { revealPanel: false });
2141
- }, [applyDomSelection, captionEditMode]);
2142
-
2143
- // eslint-disable-next-line no-restricted-syntax
2144
- useEffect(
2145
- () => () => {
2146
- if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
2147
- },
2148
- [],
2149
- );
2150
-
2151
1008
  const refreshFileTree = useCallback(async () => {
2152
1009
  const pid = projectIdRef.current;
2153
1010
  if (!pid) return;
@@ -2281,7 +1138,6 @@ export function StudioApp() {
2281
1138
  duration: normalizedDuration,
2282
1139
  track: placement.track,
2283
1140
  zIndex: trackZIndices.get(placement.track) ?? 1,
2284
- geometry: resolveTimelineAssetInitialGeometry(originalContent),
2285
1141
  }),
2286
1142
  );
2287
1143
 
@@ -2466,33 +1322,7 @@ export function StudioApp() {
2466
1322
 
2467
1323
  const handleImportFiles = useCallback(
2468
1324
  async (files: FileList | File[], dir?: string) => {
2469
- return uploadProjectFiles(Array.from(files), dir);
2470
- },
2471
- [uploadProjectFiles],
2472
- );
2473
-
2474
- const handleImportFonts = useCallback(
2475
- async (files: FileList | File[]) => {
2476
- const uploaded = await uploadProjectFiles(
2477
- Array.from(files).filter((file) => FONT_EXT.test(file.name)),
2478
- "assets/fonts",
2479
- );
2480
- const pid = projectIdRef.current;
2481
- const imported = uploaded
2482
- .filter((asset) => FONT_EXT.test(asset))
2483
- .map((asset) => ({
2484
- family: fontFamilyFromAssetPath(asset),
2485
- path: asset,
2486
- url: `/api/projects/${pid}/preview/${asset}`,
2487
- }));
2488
- importedFontAssetsRef.current = [
2489
- ...imported,
2490
- ...importedFontAssetsRef.current.filter(
2491
- (existing) =>
2492
- !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
2493
- ),
2494
- ];
2495
- return imported;
1325
+ void uploadProjectFiles(Array.from(files), dir);
2496
1326
  },
2497
1327
  [uploadProjectFiles],
2498
1328
  );
@@ -2564,17 +1394,6 @@ export function StudioApp() {
2564
1394
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
2565
1395
  [fileTree],
2566
1396
  );
2567
- const fontAssets = useMemo<ImportedFontAsset[]>(
2568
- () =>
2569
- assets
2570
- .filter((asset) => FONT_EXT.test(asset))
2571
- .map((asset) => ({
2572
- family: fontFamilyFromAssetPath(asset),
2573
- path: asset,
2574
- url: `/api/projects/${projectId}/preview/${asset}`,
2575
- })),
2576
- [assets, projectId],
2577
- );
2578
1397
 
2579
1398
  if (resolving || !projectId) {
2580
1399
  return (
@@ -2620,65 +1439,21 @@ export function StudioApp() {
2620
1439
  </div>
2621
1440
  {/* Right: toolbar buttons */}
2622
1441
  <div className="flex items-center gap-1.5">
2623
- <button
2624
- onClick={() => setLeftCollapsed((v) => !v)}
2625
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
2626
- !leftCollapsed
2627
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2628
- : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
2629
- }`}
2630
- title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
2631
- >
2632
- <svg
2633
- width="14"
2634
- height="14"
2635
- viewBox="0 0 24 24"
2636
- fill="none"
2637
- stroke="currentColor"
2638
- strokeWidth="1.5"
2639
- strokeLinecap="round"
2640
- strokeLinejoin="round"
2641
- >
2642
- <rect x="3" y="3" width="18" height="18" rx="2" />
2643
- <path d="M9 3v18" />
2644
- </svg>
2645
- </button>
2646
- <button
2647
- type="button"
2648
- onClick={toggleTimelineVisibility}
2649
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
2650
- timelineVisible
2651
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2652
- : "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
2653
- }`}
2654
- title={getTimelineToggleTitle(timelineVisible)}
2655
- aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
1442
+ <a
1443
+ href={captureFrameHref}
1444
+ download={captureFrameFilename}
1445
+ onClick={handleCaptureFrameClick}
1446
+ onFocus={refreshCaptureFrameTime}
1447
+ onPointerDown={refreshCaptureFrameTime}
1448
+ 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"
1449
+ title="Capture current frame"
1450
+ aria-label="Capture current frame"
2656
1451
  >
2657
- <svg
2658
- width="14"
2659
- height="14"
2660
- viewBox="0 0 24 24"
2661
- fill="none"
2662
- stroke="currentColor"
2663
- strokeWidth="1.5"
2664
- strokeLinecap="round"
2665
- >
2666
- <rect x="3" y="13" width="18" height="8" rx="1" />
2667
- <line x1="3" y1="9" x2="21" y2="9" />
2668
- <line x1="3" y1="5" x2="21" y2="5" />
2669
- </svg>
2670
- <span>Timeline</span>
2671
- </button>
1452
+ <Camera size={14} />
1453
+ <span>Capture</span>
1454
+ </a>
2672
1455
  <button
2673
- onClick={() => {
2674
- if (rightCollapsed || rightPanelTab !== "design") {
2675
- setRightPanelTab("design");
2676
- setRightCollapsed(false);
2677
- return;
2678
- }
2679
- clearDomSelection();
2680
- setRightCollapsed(true);
2681
- }}
1456
+ onClick={() => setRightCollapsed((v) => !v)}
2682
1457
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
2683
1458
  !rightCollapsed
2684
1459
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -2696,7 +1471,8 @@ export function StudioApp() {
2696
1471
  <circle cx="12" cy="12" r="10" />
2697
1472
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
2698
1473
  </svg>
2699
- Inspector
1474
+ Renders
1475
+ {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
2700
1476
  </button>
2701
1477
  </div>
2702
1478
  </div>
@@ -2704,7 +1480,32 @@ export function StudioApp() {
2704
1480
  {/* Main content: sidebar + preview + right panel */}
2705
1481
  <div className="flex flex-1 min-h-0">
2706
1482
  {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
2707
- {!leftCollapsed && (
1483
+ {leftCollapsed ? (
1484
+ <div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
1485
+ <button
1486
+ type="button"
1487
+ onClick={toggleLeftSidebar}
1488
+ 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"
1489
+ title="Show sidebar"
1490
+ aria-label="Show sidebar"
1491
+ >
1492
+ <svg
1493
+ width="14"
1494
+ height="14"
1495
+ viewBox="0 0 24 24"
1496
+ fill="none"
1497
+ stroke="currentColor"
1498
+ strokeWidth="1.5"
1499
+ strokeLinecap="round"
1500
+ strokeLinejoin="round"
1501
+ aria-hidden="true"
1502
+ >
1503
+ <path d="M5 4v16" />
1504
+ <path d="m10 7 5 5-5 5" />
1505
+ </svg>
1506
+ </button>
1507
+ </div>
1508
+ ) : (
2708
1509
  <LeftSidebar
2709
1510
  width={leftWidth}
2710
1511
  projectId={projectId}
@@ -2751,6 +1552,7 @@ export function StudioApp() {
2751
1552
  }
2752
1553
  onLint={handleLint}
2753
1554
  linting={linting}
1555
+ onToggleCollapse={toggleLeftSidebar}
2754
1556
  />
2755
1557
  )}
2756
1558
 
@@ -2787,25 +1589,56 @@ export function StudioApp() {
2787
1589
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
2788
1590
  setActiveCompPath(compPath);
2789
1591
  }}
2790
- onIframeRef={handlePreviewIframeRef}
1592
+ onIframeRef={(iframe) => {
1593
+ previewIframeRef.current = iframe;
1594
+ syncPreviewTimelineHotkey(iframe);
1595
+ consoleErrorsRef.current = [];
1596
+ setConsoleErrors(null);
1597
+ if (!iframe) return;
1598
+
1599
+ // Attach error capture after each iframe load (content resets on navigation)
1600
+ const attachErrorCapture = () => {
1601
+ try {
1602
+ const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1603
+ if (!win) return;
1604
+ // Guard against double-patching
1605
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1606
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1607
+ const origError = win.console.error.bind(win.console);
1608
+ win.console.error = function (...args: unknown[]) {
1609
+ origError(...args);
1610
+ const text = args
1611
+ .map((a) => (a instanceof Error ? a.message : String(a)))
1612
+ .join(" ");
1613
+ if (text.includes("favicon")) return;
1614
+ consoleErrorsRef.current = [
1615
+ ...consoleErrorsRef.current,
1616
+ { severity: "error", message: text },
1617
+ ];
1618
+ setConsoleErrors([...consoleErrorsRef.current]);
1619
+ };
1620
+ win.addEventListener("error", (e: ErrorEvent) => {
1621
+ const text = e.message || String(e);
1622
+ consoleErrorsRef.current = [
1623
+ ...consoleErrorsRef.current,
1624
+ { severity: "error", message: text },
1625
+ ];
1626
+ setConsoleErrors([...consoleErrorsRef.current]);
1627
+ });
1628
+ } catch {
1629
+ // cross-origin — can't attach
1630
+ }
1631
+ };
1632
+ // Attach now (iframe may already be loaded) and on future loads
1633
+ attachErrorCapture();
1634
+ iframe.addEventListener("load", () => {
1635
+ consoleErrorsRef.current = [];
1636
+ setConsoleErrors(null);
1637
+ attachErrorCapture();
1638
+ });
1639
+ }}
2791
1640
  previewOverlay={
2792
- captionEditMode ? (
2793
- <CaptionOverlay iframeRef={previewIframeRef} />
2794
- ) : (
2795
- <DomEditOverlay
2796
- iframeRef={previewIframeRef}
2797
- selection={
2798
- !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
2799
- }
2800
- allowCanvasMovement={false}
2801
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
2802
- onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
2803
- onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
2804
- onBlockedMove={handleBlockedDomMove}
2805
- onMoveCommit={handleDomMoveCommit}
2806
- onResizeCommit={handleDomResizeCommit}
2807
- />
2808
- )
1641
+ captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
2809
1642
  }
2810
1643
  timelineFooter={
2811
1644
  captionEditMode ? (
@@ -2846,68 +1679,14 @@ export function StudioApp() {
2846
1679
  {captionEditMode ? (
2847
1680
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
2848
1681
  ) : (
2849
- <>
2850
- <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
2851
- <button
2852
- type="button"
2853
- onClick={() => setRightPanelTab("design")}
2854
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2855
- rightPanelTab === "design"
2856
- ? "bg-neutral-800 text-white"
2857
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2858
- }`}
2859
- >
2860
- Design
2861
- </button>
2862
- <button
2863
- type="button"
2864
- onClick={() => setRightPanelTab("renders")}
2865
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2866
- rightPanelTab === "renders"
2867
- ? "bg-neutral-800 text-white"
2868
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2869
- }`}
2870
- >
2871
- {renderQueue.jobs.length > 0
2872
- ? `Renders (${renderQueue.jobs.length})`
2873
- : "Renders"}
2874
- </button>
2875
- </div>
2876
- <div className="min-h-0 flex-1">
2877
- {rightPanelTab === "design" ? (
2878
- <PropertyPanel
2879
- projectId={projectId}
2880
- assets={assets}
2881
- element={domEditSelection}
2882
- copiedAgentPrompt={copiedAgentPrompt}
2883
- onClearSelection={clearDomSelection}
2884
- onSetStyle={handleDomStyleCommit}
2885
- onSetText={handleDomTextCommit}
2886
- onSetTextFieldStyle={handleDomTextFieldStyleCommit}
2887
- onAddTextField={handleDomAddTextField}
2888
- onRemoveTextField={handleDomRemoveTextField}
2889
- onDetachFromLayout={handleDomDetachFromLayout}
2890
- onAskAgent={handleAskAgent}
2891
- onCopyAgentInstruction={handleAgentModalSubmit}
2892
- onImportAssets={handleImportFiles}
2893
- fontAssets={fontAssets}
2894
- onImportFonts={handleImportFonts}
2895
- allowLayoutDetach={false}
2896
- />
2897
- ) : (
2898
- <RenderQueue
2899
- jobs={renderQueue.jobs}
2900
- projectId={projectId}
2901
- onDelete={renderQueue.deleteRender}
2902
- onClearCompleted={renderQueue.clearCompleted}
2903
- onStartRender={(format, quality) =>
2904
- renderQueue.startRender(30, quality, format)
2905
- }
2906
- isRendering={renderQueue.isRendering}
2907
- />
2908
- )}
2909
- </div>
2910
- </>
1682
+ <RenderQueue
1683
+ jobs={renderQueue.jobs}
1684
+ projectId={projectId}
1685
+ onDelete={renderQueue.deleteRender}
1686
+ onClearCompleted={renderQueue.clearCompleted}
1687
+ onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
1688
+ isRendering={renderQueue.isRendering}
1689
+ />
2911
1690
  )}
2912
1691
  </div>
2913
1692
  </>
@@ -2928,15 +1707,6 @@ export function StudioApp() {
2928
1707
  />
2929
1708
  )}
2930
1709
 
2931
- {/* Ask agent modal */}
2932
- {agentModalOpen && domEditSelection && (
2933
- <AskAgentModal
2934
- selectionLabel={domEditSelection.label}
2935
- onSubmit={handleAgentModalSubmit}
2936
- onClose={() => setAgentModalOpen(false)}
2937
- />
2938
- )}
2939
-
2940
1710
  {/* Global drag-drop overlay */}
2941
1711
  {globalDragOver && (
2942
1712
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">