@hyperframes/studio 0.4.24 → 0.5.0-alpha.2

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 (36) hide show
  1. package/dist/assets/index-BExHzIDS.js +105 -0
  2. package/dist/assets/index-BpcIkyVP.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1327 -76
  6. package/src/components/editor/DomEditOverlay.tsx +410 -0
  7. package/src/components/editor/PropertyPanel.tsx +2462 -206
  8. package/src/components/editor/colorValue.test.ts +82 -0
  9. package/src/components/editor/colorValue.ts +175 -0
  10. package/src/components/editor/domEditing.test.ts +427 -0
  11. package/src/components/editor/domEditing.ts +733 -0
  12. package/src/components/editor/floatingPanel.test.ts +34 -0
  13. package/src/components/editor/floatingPanel.ts +54 -0
  14. package/src/components/editor/fontAssets.ts +32 -0
  15. package/src/components/editor/fontCatalog.ts +126 -0
  16. package/src/components/editor/gradientValue.test.ts +89 -0
  17. package/src/components/editor/gradientValue.ts +445 -0
  18. package/src/components/nle/NLELayout.tsx +9 -4
  19. package/src/components/nle/NLEPreview.tsx +50 -5
  20. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  22. package/src/components/sidebar/LeftSidebar.tsx +38 -33
  23. package/src/player/components/Player.tsx +18 -70
  24. package/src/player/components/Timeline.test.ts +0 -1
  25. package/src/player/components/Timeline.tsx +0 -3
  26. package/src/player/components/TimelineClip.tsx +20 -7
  27. package/src/player/components/timelineEditing.test.ts +0 -2
  28. package/src/player/components/timelineEditing.ts +0 -2
  29. package/src/player/hooks/useTimelinePlayer.ts +0 -17
  30. package/src/utils/mediaTypes.ts +1 -1
  31. package/src/utils/sourcePatcher.test.ts +128 -1
  32. package/src/utils/sourcePatcher.ts +130 -18
  33. package/src/utils/timelineAssetDrop.test.ts +31 -11
  34. package/src/utils/timelineAssetDrop.ts +22 -2
  35. package/dist/assets/index-CAscydDF.js +0 -115
  36. package/dist/assets/index-dpgHnQGg.css +0 -1
package/src/App.tsx CHANGED
@@ -1,7 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
2
2
  import { useMountEffect } from "./hooks/useMountEffect";
3
3
  import { NLELayout } from "./components/nle/NLELayout";
4
- import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
5
4
  import { SourceEditor } from "./components/editor/SourceEditor";
6
5
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
7
6
  import { RenderQueue } from "./components/renders/RenderQueue";
@@ -12,13 +11,14 @@ import type { TimelineElement } from "./player";
12
11
  import { LintModal } from "./components/LintModal";
13
12
  import type { LintFinding } from "./components/LintModal";
14
13
  import { MediaPreview } from "./components/MediaPreview";
15
- import { isMediaFile } from "./utils/mediaTypes";
14
+ import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
16
15
  import {
17
16
  buildTimelineAssetId,
18
17
  buildTimelineAssetInsertHtml,
19
18
  buildTimelineFileDropPlacements,
20
19
  getTimelineAssetKind,
21
20
  insertTimelineAssetIntoSource,
21
+ resolveTimelineAssetInitialGeometry,
22
22
  resolveTimelineAssetSrc,
23
23
  type TimelineAssetKind,
24
24
  } from "./utils/timelineAssetDrop";
@@ -28,7 +28,12 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
28
28
  import { useCaptionStore } from "./captions/store";
29
29
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
30
30
  import { parseCaptionComposition } from "./captions/parser";
31
- import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
31
+ import {
32
+ applyPatchByTarget,
33
+ readAttributeByTarget,
34
+ readTagSnippetByTarget,
35
+ type PatchOperation,
36
+ } from "./utils/sourcePatcher";
32
37
  import {
33
38
  buildTrackZIndexMap,
34
39
  formatTimelineAttributeNumber,
@@ -38,11 +43,36 @@ import {
38
43
  getTimelineZoomPercent,
39
44
  } from "./player/components/timelineZoom";
40
45
  import {
46
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
41
47
  getTimelineEditorHintDismissed,
42
48
  getTimelineToggleTitle,
43
49
  setTimelineEditorHintDismissed,
44
50
  shouldHandleTimelineToggleHotkey,
45
51
  } from "./utils/timelineDiscovery";
52
+ import { PropertyPanel } from "./components/editor/PropertyPanel";
53
+ import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
54
+ import {
55
+ fontFamilyFromAssetPath,
56
+ importedFontFaceCss,
57
+ type ImportedFontAsset,
58
+ } from "./components/editor/fontAssets";
59
+ import { DomEditOverlay } from "./components/editor/DomEditOverlay";
60
+ import {
61
+ buildDefaultDomEditTextField,
62
+ buildDomEditDetachPatchOperations,
63
+ buildDomEditMovePatchOperations,
64
+ buildDomEditResizePatchOperations,
65
+ buildDomEditStylePatchOperation,
66
+ buildDomEditTextPatchOperation,
67
+ buildElementAgentPrompt,
68
+ findElementForSelection,
69
+ isTextEditableSelection,
70
+ serializeDomEditTextFields,
71
+ resolveDomEditCapabilities,
72
+ resolveDomEditSelection,
73
+ type DomEditTextField,
74
+ type DomEditSelection,
75
+ } from "./components/editor/domEditing";
46
76
 
47
77
  interface EditingFile {
48
78
  path: string;
@@ -54,6 +84,418 @@ interface AppToast {
54
84
  tone: "error" | "info";
55
85
  }
56
86
 
87
+ type RightPanelTab = "design" | "renders";
88
+
89
+ const GENERIC_FONT_FAMILIES = new Set([
90
+ "inherit",
91
+ "initial",
92
+ "revert",
93
+ "revert-layer",
94
+ "serif",
95
+ "sans-serif",
96
+ "monospace",
97
+ "cursive",
98
+ "fantasy",
99
+ "system-ui",
100
+ "ui-sans-serif",
101
+ "ui-serif",
102
+ "ui-monospace",
103
+ "ui-rounded",
104
+ "emoji",
105
+ "math",
106
+ "fangsong",
107
+ ]);
108
+
109
+ function primaryFontFamilyFromCss(value: string): string {
110
+ const first = value.split(",")[0] ?? "";
111
+ return first.trim().replace(/^["']|["']$/g, "");
112
+ }
113
+
114
+ function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
115
+ const family = primaryFontFamilyFromCss(fontFamilyValue);
116
+ if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
117
+
118
+ const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
119
+ if (doc.getElementById(id)) return;
120
+
121
+ const link = doc.createElement("link");
122
+ link.id = id;
123
+ link.rel = "stylesheet";
124
+ link.href = googleFontStylesheetUrl(family);
125
+ doc.head.appendChild(link);
126
+ }
127
+
128
+ function primaryFontFamilyValue(value: string): string {
129
+ return (
130
+ value
131
+ .split(",")[0]
132
+ ?.trim()
133
+ .replace(/^["']|["']$/g, "")
134
+ .trim() ?? ""
135
+ );
136
+ }
137
+
138
+ function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
139
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
140
+ if (doc.getElementById(id)) return;
141
+ const style = doc.createElement("style");
142
+ style.id = id;
143
+ style.textContent = importedFontFaceCss(asset);
144
+ doc.head.appendChild(style);
145
+ }
146
+
147
+ function normalizeProjectAssetPath(value: string): string {
148
+ const trimmed = value.trim();
149
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
150
+ return decodeURIComponent(maybeUrl)
151
+ .replace(/\\/g, "/")
152
+ .replace(/^\.?\//, "");
153
+ }
154
+
155
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
156
+ const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
157
+ const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
158
+
159
+ fromParts.pop();
160
+
161
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
162
+ fromParts.shift();
163
+ targetParts.shift();
164
+ }
165
+
166
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
167
+ }
168
+
169
+ function ensureImportedFontFace(
170
+ html: string,
171
+ asset: ImportedFontAsset,
172
+ sourceFile: string,
173
+ ): string {
174
+ const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
175
+ if (html.includes(css)) return html;
176
+
177
+ const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
178
+ const styleMatch = styleRe.exec(html);
179
+ if (styleMatch) {
180
+ const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
181
+ return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
182
+ }
183
+
184
+ const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
185
+ if (/<\/head>/i.test(html)) {
186
+ return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
187
+ }
188
+ return `${styleTag}\n${html}`;
189
+ }
190
+ function normalizeDomEditStyleValue(property: string, value: string): string {
191
+ const trimmed = value.trim();
192
+ if (!trimmed) return trimmed;
193
+
194
+ if (
195
+ ["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
196
+ /^-?\d+(\.\d+)?$/.test(trimmed)
197
+ ) {
198
+ return `${trimmed}px`;
199
+ }
200
+
201
+ return trimmed;
202
+ }
203
+
204
+ function isImageBackgroundValue(value: string): boolean {
205
+ return /^url\(/i.test(value.trim());
206
+ }
207
+
208
+ function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
209
+ return Boolean(
210
+ selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
211
+ );
212
+ }
213
+
214
+ function buildOppositeEdgePatchOperations(
215
+ selection: DomEditSelection,
216
+ dimension: "width" | "height" | "both",
217
+ ): PatchOperation[] {
218
+ if (!shouldDetachOppositeEdges(selection)) return [];
219
+ const operations: PatchOperation[] = [];
220
+ if (dimension === "width" || dimension === "both") {
221
+ operations.push({ type: "inline-style", property: "right", value: "auto" });
222
+ }
223
+ if (dimension === "height" || dimension === "both") {
224
+ operations.push({ type: "inline-style", property: "bottom", value: "auto" });
225
+ }
226
+ return operations;
227
+ }
228
+
229
+ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
230
+ if (!target || typeof target !== "object") return null;
231
+ const maybeNode = target as {
232
+ nodeType?: number;
233
+ parentElement?: Element | null;
234
+ };
235
+ if (maybeNode.nodeType === 1) return target as HTMLElement;
236
+ if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
237
+ return maybeNode.parentElement as HTMLElement;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function findMatchingTimelineElementId(
243
+ selection: Pick<
244
+ DomEditSelection,
245
+ "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
246
+ >,
247
+ elements: TimelineElement[],
248
+ ): string | null {
249
+ for (const element of elements) {
250
+ if (selection.id && element.domId === selection.id) {
251
+ return element.key ?? element.id;
252
+ }
253
+ if (
254
+ selection.isCompositionHost &&
255
+ selection.compositionSrc &&
256
+ element.compositionSrc === selection.compositionSrc
257
+ ) {
258
+ return element.key ?? element.id;
259
+ }
260
+ if (
261
+ selection.selector &&
262
+ element.selector === selection.selector &&
263
+ (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
264
+ (element.sourceFile ?? "index.html") === selection.sourceFile
265
+ ) {
266
+ return element.key ?? element.id;
267
+ }
268
+ }
269
+
270
+ return null;
271
+ }
272
+
273
+ function findMappedCompositionHost(
274
+ target: HTMLElement,
275
+ timelineElements: TimelineElement[],
276
+ compIdToSrc: Map<string, string>,
277
+ fileTree: string[],
278
+ ): { host: HTMLElement; compositionSrc: string } | null {
279
+ const rootCompositionId =
280
+ target.ownerDocument
281
+ .querySelector("[data-composition-id]")
282
+ ?.getAttribute("data-composition-id") ?? null;
283
+
284
+ let nestedCurrent: HTMLElement | null = target;
285
+ while (nestedCurrent) {
286
+ const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
287
+ if (nestedCompId && nestedCompId !== rootCompositionId) {
288
+ const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
289
+ if (hostCandidate instanceof HTMLElement) {
290
+ const hostCompId = hostCandidate.getAttribute("data-composition-id");
291
+ const compositionSrc =
292
+ hostCandidate.getAttribute("data-composition-src") ??
293
+ hostCandidate.getAttribute("data-composition-file") ??
294
+ (hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
295
+ compIdToSrc.get(nestedCompId) ??
296
+ fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
297
+ undefined;
298
+ if (compositionSrc) {
299
+ return { host: hostCandidate, compositionSrc };
300
+ }
301
+ }
302
+ }
303
+ nestedCurrent = nestedCurrent.parentElement;
304
+ }
305
+
306
+ let current: HTMLElement | null = target;
307
+ while (current) {
308
+ const compId = current.getAttribute("data-composition-id");
309
+ const directSrc =
310
+ current.getAttribute("data-composition-src") ??
311
+ current.getAttribute("data-composition-file") ??
312
+ undefined;
313
+ const timelineMatch =
314
+ timelineElements.find(
315
+ (element) =>
316
+ Boolean(element.compositionSrc) &&
317
+ (element.domId === current?.id ||
318
+ (current?.id && element.id === current.id) ||
319
+ (compId && element.id === compId)),
320
+ ) ?? null;
321
+ const compositionSrc =
322
+ directSrc ??
323
+ timelineMatch?.compositionSrc ??
324
+ (compId ? compIdToSrc.get(compId) : undefined) ??
325
+ (compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
326
+ if (compositionSrc) {
327
+ return { host: current, compositionSrc };
328
+ }
329
+ current = current.parentElement;
330
+ }
331
+
332
+ return null;
333
+ }
334
+
335
+ function isMoveStyleProperty(property: string): boolean {
336
+ return property === "left" || property === "top";
337
+ }
338
+
339
+ function isResizeStyleProperty(property: string): boolean {
340
+ return property === "width" || property === "height";
341
+ }
342
+
343
+ function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
344
+ const offsetParent = element.offsetParent;
345
+ if (offsetParent instanceof HTMLElement) return offsetParent;
346
+
347
+ let current = element.parentElement;
348
+ while (current) {
349
+ if (current.hasAttribute("data-composition-id")) return current;
350
+ current = current.parentElement;
351
+ }
352
+
353
+ return element.ownerDocument.body;
354
+ }
355
+
356
+ function measureDomDetachRect(element: HTMLElement): {
357
+ left: number;
358
+ top: number;
359
+ width: number;
360
+ height: number;
361
+ } {
362
+ const root = getDomDetachCoordinateRoot(element);
363
+ const rect = element.getBoundingClientRect();
364
+ const rootRect = root.getBoundingClientRect();
365
+
366
+ return {
367
+ left: rect.left - rootRect.left + root.scrollLeft,
368
+ top: rect.top - rootRect.top + root.scrollTop,
369
+ width: rect.width,
370
+ height: rect.height,
371
+ };
372
+ }
373
+
374
+ function getDomSelectionClickKey(
375
+ selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
376
+ ): string {
377
+ if (selection.id) return `id:${selection.id}`;
378
+ return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
379
+ }
380
+
381
+ function getPreviewTargetFromPointer(
382
+ iframe: HTMLIFrameElement,
383
+ clientX: number,
384
+ clientY: number,
385
+ ): HTMLElement | null {
386
+ let doc: Document | null = null;
387
+ let win: Window | null = null;
388
+ try {
389
+ doc = iframe.contentDocument;
390
+ win = iframe.contentWindow;
391
+ } catch {
392
+ return null;
393
+ }
394
+ if (!doc || !win) return null;
395
+
396
+ const iframeRect = iframe.getBoundingClientRect();
397
+ const root =
398
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
399
+ const rootRect = root?.getBoundingClientRect();
400
+ const rootWidth = rootRect?.width || win.innerWidth;
401
+ const rootHeight = rootRect?.height || win.innerHeight;
402
+ if (!rootWidth || !rootHeight) return null;
403
+
404
+ const scaleX = iframeRect.width / rootWidth;
405
+ const scaleY = iframeRect.height / rootHeight;
406
+ const localX = (clientX - iframeRect.left) / scaleX;
407
+ const localY = (clientY - iframeRect.top) / scaleY;
408
+
409
+ return getEventTargetElement(doc.elementFromPoint(localX, localY));
410
+ }
411
+
412
+ // ── Ask Agent Modal ──
413
+
414
+ function AskAgentModal({
415
+ selectionLabel,
416
+ onSubmit,
417
+ onClose,
418
+ }: {
419
+ selectionLabel: string;
420
+ onSubmit: (instruction: string) => void;
421
+ onClose: () => void;
422
+ }) {
423
+ const [value, setValue] = useState("");
424
+ const inputRef = useRef<HTMLTextAreaElement>(null);
425
+
426
+ useMountEffect(() => {
427
+ requestAnimationFrame(() => inputRef.current?.focus());
428
+ });
429
+
430
+ const handleSubmit = () => {
431
+ if (!value.trim()) return;
432
+ onSubmit(value.trim());
433
+ };
434
+
435
+ return (
436
+ <div
437
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
438
+ onClick={onClose}
439
+ >
440
+ <div
441
+ className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
442
+ onClick={(e) => e.stopPropagation()}
443
+ >
444
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
445
+ <div>
446
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
447
+ <p className="text-xs text-neutral-500 mt-0.5">
448
+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
449
+ </p>
450
+ </div>
451
+ <button
452
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
453
+ onClick={onClose}
454
+ >
455
+ <svg
456
+ width="14"
457
+ height="14"
458
+ viewBox="0 0 24 24"
459
+ fill="none"
460
+ stroke="currentColor"
461
+ strokeWidth="2"
462
+ strokeLinecap="round"
463
+ >
464
+ <line x1="18" y1="6" x2="6" y2="18" />
465
+ <line x1="6" y1="6" x2="18" y2="18" />
466
+ </svg>
467
+ </button>
468
+ </div>
469
+ <div className="px-5 py-4">
470
+ <textarea
471
+ ref={inputRef}
472
+ 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"
473
+ placeholder="Describe what you want to change…"
474
+ value={value}
475
+ onChange={(e) => setValue(e.target.value)}
476
+ onKeyDown={(e) => {
477
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
478
+ if (e.key === "Escape") onClose();
479
+ }}
480
+ />
481
+ </div>
482
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
483
+ <span className="text-[11px] text-neutral-600">
484
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
485
+ </span>
486
+ <button
487
+ 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"
488
+ disabled={!value.trim()}
489
+ onClick={handleSubmit}
490
+ >
491
+ Copy prompt
492
+ </button>
493
+ </div>
494
+ </div>
495
+ </div>
496
+ );
497
+ }
498
+
57
499
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
58
500
  image: 3,
59
501
  video: 5,
@@ -145,6 +587,11 @@ export function StudioApp() {
145
587
  const [rightWidth, setRightWidth] = useState(400);
146
588
  const [leftCollapsed, setLeftCollapsed] = useState(false);
147
589
  const [rightCollapsed, setRightCollapsed] = useState(true);
590
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
591
+ const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
592
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
593
+ const [agentModalOpen, setAgentModalOpen] = useState(false);
594
+ const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
148
595
  // Auto-enter caption edit mode when the iframe contains .caption-group elements.
149
596
  // This is a subscription to external events (postMessage from runtime) — useEffect
150
597
  // is appropriate here. The runtime fires "state"/"timeline" messages after all
@@ -273,6 +720,8 @@ export function StudioApp() {
273
720
  const dragCounterRef = useRef(0);
274
721
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
275
722
  const lastBlockedTimelineToastAtRef = useRef(0);
723
+ const lastBlockedDomMoveToastAtRef = useRef(0);
724
+ const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
276
725
  const previewHotkeyWindowRef = useRef<Window | null>(null);
277
726
  const panelDragRef = useRef<{
278
727
  side: "left" | "right";
@@ -284,11 +733,14 @@ export function StudioApp() {
284
733
  const activePreviewUrl = activeCompPath
285
734
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
286
735
  : null;
736
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
287
737
  const zoomMode = usePlayerStore((s) => s.zoomMode);
288
738
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
289
739
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
290
740
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
741
+ const currentTime = usePlayerStore((s) => s.currentTime);
291
742
  const timelineElements = usePlayerStore((s) => s.elements);
743
+ const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
292
744
  const timelineDuration = usePlayerStore((s) => s.duration);
293
745
  const effectiveTimelineDuration = useMemo(() => {
294
746
  const maxEnd =
@@ -304,9 +756,6 @@ export function StudioApp() {
304
756
  const toggleTimelineVisibility = useCallback(() => {
305
757
  setTimelineVisible((visible) => !visible);
306
758
  }, []);
307
- useMountEffect(() => () => {
308
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
309
- });
310
759
  const dismissTimelineEditorHint = useCallback(() => {
311
760
  setTimelineEditorHintState(true);
312
761
  setTimelineEditorHintDismissed(true);
@@ -374,7 +823,6 @@ export function StudioApp() {
374
823
  label={el.id || el.tag}
375
824
  labelColor={style.label}
376
825
  accentColor={style.clip}
377
- selector={el.selector}
378
826
  seekTime={0}
379
827
  duration={el.duration}
380
828
  />
@@ -390,7 +838,6 @@ export function StudioApp() {
390
838
  label={el.id || el.tag}
391
839
  labelColor={style.label}
392
840
  accentColor={style.clip}
393
- selector={el.selector}
394
841
  seekTime={el.start}
395
842
  duration={el.duration}
396
843
  />
@@ -436,7 +883,6 @@ export function StudioApp() {
436
883
  label={el.id || el.tag}
437
884
  labelColor={style.label}
438
885
  accentColor={style.clip}
439
- selector={el.selector}
440
886
  seekTime={el.start}
441
887
  duration={el.duration}
442
888
  />
@@ -449,6 +895,31 @@ export function StudioApp() {
449
895
  );
450
896
  const timelineToolbar = (
451
897
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
898
+ {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
899
+ <div className="px-3 pt-3">
900
+ <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">
901
+ <div className="min-w-0">
902
+ <div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
903
+ <p className="mt-1 text-[11px] leading-5 text-neutral-300">
904
+ Drag clips to move timing, and drag clip edges to resize them when handles are
905
+ available. Hide the panel anytime and bring it back with{" "}
906
+ <span className="font-mono text-[10px] text-studio-accent">
907
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
908
+ </span>
909
+ .
910
+ </p>
911
+ </div>
912
+ <button
913
+ type="button"
914
+ onClick={dismissTimelineEditorHint}
915
+ 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"
916
+ >
917
+ Dismiss
918
+ </button>
919
+ </div>
920
+ </div>
921
+ )}
922
+
452
923
  <div className="flex items-center justify-between px-3 py-2">
453
924
  <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
454
925
  Timeline
@@ -504,11 +975,20 @@ export function StudioApp() {
504
975
  const projectIdRef = useRef(projectId);
505
976
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
506
977
  const consoleErrorsRef = useRef<LintFinding[]>([]);
978
+ const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
979
+ const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
980
+ const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
981
+ const domEditSaveTimestampRef = useRef(0);
982
+ const domTextCommitVersionRef = useRef(0);
507
983
 
508
984
  // Listen for external file changes (user editing HTML outside the editor).
509
985
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
986
+ // Suppress file-change events that echo back from a recent DOM edit save —
987
+ // those changes are already applied to the iframe DOM and a full reload
988
+ // would flash the preview.
510
989
  useMountEffect(() => {
511
990
  const handler = () => {
991
+ if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
512
992
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
513
993
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
514
994
  };
@@ -522,6 +1002,7 @@ export function StudioApp() {
522
1002
  return () => es.close();
523
1003
  });
524
1004
  projectIdRef.current = projectId;
1005
+ domEditSelectionRef.current = domEditSelection;
525
1006
 
526
1007
  // Load file tree when projectId changes.
527
1008
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -906,6 +1387,707 @@ export function StudioApp() {
906
1387
  [showToast],
907
1388
  );
908
1389
 
1390
+ const handleBlockedDomMove = useCallback(
1391
+ (selection: DomEditSelection) => {
1392
+ const now = Date.now();
1393
+ if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1394
+ lastBlockedDomMoveToastAtRef.current = now;
1395
+ showToast(
1396
+ selection.capabilities.canDetachFromLayout
1397
+ ? "This layer is controlled by layout. Use Make movable in the panel to detach it."
1398
+ : (selection.capabilities.reasonIfDisabled ??
1399
+ "This element can’t be moved directly from the preview."),
1400
+ "info",
1401
+ );
1402
+ },
1403
+ [showToast],
1404
+ );
1405
+
1406
+ const applyDomSelection = useCallback(
1407
+ (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
1408
+ setDomEditSelection(selection);
1409
+ setCopiedAgentPrompt(false);
1410
+ if (selection) {
1411
+ if (options?.revealPanel !== false) {
1412
+ setRightCollapsed(false);
1413
+ setRightPanelTab("design");
1414
+ }
1415
+ const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
1416
+ setSelectedTimelineElementId(nextSelectedTimelineId);
1417
+ return;
1418
+ }
1419
+
1420
+ setSelectedTimelineElementId(null);
1421
+ },
1422
+ [setSelectedTimelineElementId, timelineElements],
1423
+ );
1424
+
1425
+ const clearDomSelection = useCallback(() => {
1426
+ applyDomSelection(null, { revealPanel: false });
1427
+ }, [applyDomSelection]);
1428
+
1429
+ const buildDomSelectionFromTarget = useCallback(
1430
+ (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
1431
+ if (isMasterView) {
1432
+ const mappedHost = findMappedCompositionHost(
1433
+ target,
1434
+ timelineElements,
1435
+ compIdToSrc,
1436
+ fileTree,
1437
+ );
1438
+ if (mappedHost) {
1439
+ const hostSelection = resolveDomEditSelection(mappedHost.host, {
1440
+ activeCompositionPath: activeCompPath,
1441
+ isMasterView,
1442
+ preferClipAncestor: options?.preferClipAncestor,
1443
+ });
1444
+ if (!hostSelection) return null;
1445
+ return {
1446
+ ...hostSelection,
1447
+ compositionSrc: mappedHost.compositionSrc,
1448
+ isCompositionHost: true,
1449
+ capabilities: resolveDomEditCapabilities({
1450
+ selector: hostSelection.selector,
1451
+ tagName: hostSelection.tagName,
1452
+ className: hostSelection.element.className,
1453
+ inlineStyles: hostSelection.inlineStyles,
1454
+ computedStyles: hostSelection.computedStyles,
1455
+ isCompositionHost: true,
1456
+ isMasterView: true,
1457
+ }),
1458
+ } satisfies DomEditSelection;
1459
+ }
1460
+ }
1461
+
1462
+ return resolveDomEditSelection(target, {
1463
+ activeCompositionPath: activeCompPath,
1464
+ isMasterView,
1465
+ preferClipAncestor: options?.preferClipAncestor,
1466
+ });
1467
+ },
1468
+ [activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
1469
+ );
1470
+
1471
+ const resolveImportedFontAsset = useCallback(
1472
+ (fontFamilyValue: string): ImportedFontAsset | null => {
1473
+ const family = primaryFontFamilyValue(fontFamilyValue);
1474
+ if (!family) return null;
1475
+ const imported = importedFontAssetsRef.current.find(
1476
+ (font) => font.family.toLowerCase() === family.toLowerCase(),
1477
+ );
1478
+ if (imported) return imported;
1479
+ const asset = fileTree.find(
1480
+ (path) =>
1481
+ FONT_EXT.test(path) &&
1482
+ fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
1483
+ );
1484
+ if (!asset) return null;
1485
+ return {
1486
+ family: fontFamilyFromAssetPath(asset),
1487
+ path: asset,
1488
+ url: `/api/projects/${projectId}/preview/${asset}`,
1489
+ };
1490
+ },
1491
+ [fileTree, projectId],
1492
+ );
1493
+
1494
+ const persistDomEditOperations = useCallback(
1495
+ async (
1496
+ selection: DomEditSelection,
1497
+ operations: Parameters<typeof applyPatchByTarget>[2][],
1498
+ options?: {
1499
+ skipRefresh?: boolean;
1500
+ prepareContent?: (html: string, sourceFile: string) => string;
1501
+ shouldSave?: () => boolean;
1502
+ },
1503
+ ) => {
1504
+ const pid = projectIdRef.current;
1505
+ if (!pid) throw new Error("No active project");
1506
+ if (options?.shouldSave && !options.shouldSave()) return;
1507
+
1508
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1509
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1510
+ if (!response.ok) {
1511
+ throw new Error(`Failed to read ${targetPath}`);
1512
+ }
1513
+
1514
+ const data = (await response.json()) as { content?: string };
1515
+ const originalContent = data.content;
1516
+ if (typeof originalContent !== "string") {
1517
+ throw new Error(`Missing file contents for ${targetPath}`);
1518
+ }
1519
+
1520
+ let patchedContent = originalContent;
1521
+ for (const operation of operations) {
1522
+ patchedContent = applyPatchByTarget(patchedContent, selection, operation);
1523
+ }
1524
+ if (options?.prepareContent) {
1525
+ patchedContent = options.prepareContent(patchedContent, targetPath);
1526
+ }
1527
+ if (options?.shouldSave && !options.shouldSave()) return;
1528
+
1529
+ if (patchedContent === originalContent) {
1530
+ throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
1531
+ }
1532
+
1533
+ const saveResponse = await fetch(
1534
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1535
+ {
1536
+ method: "PUT",
1537
+ headers: { "Content-Type": "text/plain" },
1538
+ body: patchedContent,
1539
+ },
1540
+ );
1541
+ if (!saveResponse.ok) {
1542
+ throw new Error(`Failed to save ${targetPath}`);
1543
+ }
1544
+
1545
+ if (editingPathRef.current === targetPath) {
1546
+ setEditingFile({ path: targetPath, content: patchedContent });
1547
+ }
1548
+
1549
+ if (options?.skipRefresh) {
1550
+ domEditSaveTimestampRef.current = Date.now();
1551
+ } else {
1552
+ setRefreshKey((k) => k + 1);
1553
+ }
1554
+ },
1555
+ [activeCompPath],
1556
+ );
1557
+
1558
+ const handleDomMoveCommit = useCallback(
1559
+ async (selection: DomEditSelection, next: { left: number; top: number }) => {
1560
+ await persistDomEditOperations(
1561
+ selection,
1562
+ [
1563
+ ...buildDomEditMovePatchOperations(next.left, next.top),
1564
+ ...buildOppositeEdgePatchOperations(selection, "both"),
1565
+ ],
1566
+ { skipRefresh: true },
1567
+ );
1568
+ },
1569
+ [persistDomEditOperations],
1570
+ );
1571
+
1572
+ const handleDomResizeCommit = useCallback(
1573
+ async (selection: DomEditSelection, next: { width: number; height: number }) => {
1574
+ if (shouldDetachOppositeEdges(selection)) {
1575
+ selection.element.style.right = "auto";
1576
+ selection.element.style.bottom = "auto";
1577
+ }
1578
+ await persistDomEditOperations(
1579
+ selection,
1580
+ [
1581
+ ...buildDomEditResizePatchOperations(next.width, next.height),
1582
+ ...buildOppositeEdgePatchOperations(selection, "both"),
1583
+ ],
1584
+ { skipRefresh: true },
1585
+ );
1586
+ },
1587
+ [persistDomEditOperations],
1588
+ );
1589
+
1590
+ const handleDomDetachFromLayout = useCallback(async () => {
1591
+ const selection = domEditSelection;
1592
+ if (!selection?.capabilities.canDetachFromLayout) return;
1593
+
1594
+ const doc = previewIframeRef.current?.contentDocument;
1595
+ const element = doc
1596
+ ? findElementForSelection(doc, selection, selection.sourceFile)
1597
+ : selection.element;
1598
+ if (!element) {
1599
+ showToast("Could not find the selected layer in the preview.", "info");
1600
+ return;
1601
+ }
1602
+
1603
+ const rect = measureDomDetachRect(element);
1604
+ const operations = buildDomEditDetachPatchOperations(rect);
1605
+
1606
+ for (const operation of operations) {
1607
+ element.style.setProperty(operation.property, operation.value);
1608
+ }
1609
+
1610
+ await persistDomEditOperations(selection, operations, { skipRefresh: true });
1611
+
1612
+ const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
1613
+ if (refreshed) {
1614
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1615
+ if (nextSelection) {
1616
+ applyDomSelection(nextSelection, { revealPanel: false });
1617
+ }
1618
+ }
1619
+ showToast("Layer detached from layout. You can move it now.", "info");
1620
+ }, [
1621
+ applyDomSelection,
1622
+ buildDomSelectionFromTarget,
1623
+ domEditSelection,
1624
+ persistDomEditOperations,
1625
+ showToast,
1626
+ ]);
1627
+
1628
+ const handleDomStyleCommit = useCallback(
1629
+ async (property: string, value: string) => {
1630
+ if (!domEditSelection) return;
1631
+ const isMoveStyle = isMoveStyleProperty(property);
1632
+ const isResizeStyle = isResizeStyleProperty(property);
1633
+ if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
1634
+ if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
1635
+ if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
1636
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1637
+ const iframe = previewIframeRef.current;
1638
+ const doc = iframe?.contentDocument;
1639
+ if (doc) {
1640
+ const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1641
+ if (el) {
1642
+ el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
1643
+ if (property === "font-family") {
1644
+ injectPreviewGoogleFont(doc, value);
1645
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
1646
+ }
1647
+ if (shouldDetachOppositeEdges(domEditSelection)) {
1648
+ if (property === "width") el.style.right = "auto";
1649
+ if (property === "height") el.style.bottom = "auto";
1650
+ }
1651
+ if (property === "background-image" && isImageBackgroundValue(value)) {
1652
+ el.style.setProperty("background-position", "center");
1653
+ el.style.setProperty("background-repeat", "no-repeat");
1654
+ el.style.setProperty("background-size", "contain");
1655
+ }
1656
+ }
1657
+ }
1658
+ const operations: PatchOperation[] = [
1659
+ buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
1660
+ ];
1661
+ if (property === "width") {
1662
+ operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
1663
+ } else if (property === "height") {
1664
+ operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
1665
+ } else if (property === "background-image" && isImageBackgroundValue(value)) {
1666
+ operations.push(
1667
+ buildDomEditStylePatchOperation("background-position", "center"),
1668
+ buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
1669
+ buildDomEditStylePatchOperation("background-size", "contain"),
1670
+ );
1671
+ }
1672
+ await persistDomEditOperations(domEditSelection, operations, {
1673
+ skipRefresh: true,
1674
+ prepareContent: importedFont
1675
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1676
+ : undefined,
1677
+ });
1678
+ },
1679
+ [domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
1680
+ );
1681
+
1682
+ const handleDomTextCommit = useCallback(
1683
+ async (value: string, fieldKey?: string) => {
1684
+ if (!domEditSelection) return;
1685
+ if (!isTextEditableSelection(domEditSelection)) return;
1686
+ const commitVersion = domTextCommitVersionRef.current + 1;
1687
+ domTextCommitVersionRef.current = commitVersion;
1688
+ const nextTextFields =
1689
+ domEditSelection.textFields.length > 0
1690
+ ? domEditSelection.textFields.map((field) =>
1691
+ field.key === fieldKey ? { ...field, value } : field,
1692
+ )
1693
+ : [];
1694
+ const nextContent =
1695
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1696
+ ? serializeDomEditTextFields(nextTextFields)
1697
+ : value;
1698
+ const iframe = previewIframeRef.current;
1699
+ const doc = iframe?.contentDocument;
1700
+ if (doc) {
1701
+ const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1702
+ if (el) {
1703
+ if (
1704
+ nextTextFields.length > 1 ||
1705
+ nextTextFields.some((field) => field.source === "child")
1706
+ ) {
1707
+ el.innerHTML = nextContent;
1708
+ } else {
1709
+ el.textContent = value;
1710
+ }
1711
+ }
1712
+ }
1713
+ await persistDomEditOperations(
1714
+ domEditSelection,
1715
+ [buildDomEditTextPatchOperation(nextContent)],
1716
+ {
1717
+ skipRefresh: true,
1718
+ shouldSave: () => domTextCommitVersionRef.current === commitVersion,
1719
+ },
1720
+ );
1721
+ if (domTextCommitVersionRef.current !== commitVersion) return;
1722
+
1723
+ if (doc) {
1724
+ const refreshed = findElementForSelection(
1725
+ doc,
1726
+ domEditSelection,
1727
+ domEditSelection.sourceFile,
1728
+ );
1729
+ if (refreshed) {
1730
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1731
+ if (nextSelection) {
1732
+ applyDomSelection(nextSelection, { revealPanel: false });
1733
+ }
1734
+ }
1735
+ }
1736
+ },
1737
+ [applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
1738
+ );
1739
+
1740
+ const commitDomTextFields = useCallback(
1741
+ async (
1742
+ selection: DomEditSelection,
1743
+ nextTextFields: DomEditTextField[],
1744
+ options?: { importedFont?: ImportedFontAsset | null },
1745
+ ) => {
1746
+ const nextContent =
1747
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1748
+ ? serializeDomEditTextFields(nextTextFields)
1749
+ : (nextTextFields[0]?.value ?? "");
1750
+
1751
+ const iframe = previewIframeRef.current;
1752
+ const doc = iframe?.contentDocument;
1753
+ if (doc) {
1754
+ const el = findElementForSelection(doc, selection, selection.sourceFile);
1755
+ if (el) {
1756
+ if (
1757
+ nextTextFields.length > 1 ||
1758
+ nextTextFields.some((field) => field.source === "child")
1759
+ ) {
1760
+ el.innerHTML = nextContent;
1761
+ } else {
1762
+ el.textContent = nextContent;
1763
+ }
1764
+ }
1765
+ }
1766
+
1767
+ const importedFont = options?.importedFont ?? null;
1768
+ await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
1769
+ skipRefresh: true,
1770
+ prepareContent: importedFont
1771
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1772
+ : undefined,
1773
+ });
1774
+
1775
+ if (doc) {
1776
+ const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
1777
+ if (refreshed) {
1778
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1779
+ if (nextSelection) {
1780
+ applyDomSelection(nextSelection, { revealPanel: false });
1781
+ }
1782
+ }
1783
+ }
1784
+ },
1785
+ [applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
1786
+ );
1787
+
1788
+ const handleDomTextFieldStyleCommit = useCallback(
1789
+ async (fieldKey: string, property: string, value: string) => {
1790
+ if (!domEditSelection) return;
1791
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1792
+ if (!field) return;
1793
+
1794
+ if (field.source === "self") {
1795
+ await handleDomStyleCommit(property, value);
1796
+ return;
1797
+ }
1798
+
1799
+ const normalizedValue = normalizeDomEditStyleValue(property, value);
1800
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1801
+ if (property === "font-family") {
1802
+ const doc = previewIframeRef.current?.contentDocument;
1803
+ if (doc) {
1804
+ injectPreviewGoogleFont(doc, normalizedValue);
1805
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
1806
+ }
1807
+ }
1808
+ const nextTextFields = domEditSelection.textFields.map((entry) =>
1809
+ entry.key === fieldKey
1810
+ ? {
1811
+ ...entry,
1812
+ inlineStyles: {
1813
+ ...entry.inlineStyles,
1814
+ [property]: normalizedValue,
1815
+ },
1816
+ computedStyles: {
1817
+ ...entry.computedStyles,
1818
+ [property]: normalizedValue,
1819
+ },
1820
+ }
1821
+ : entry,
1822
+ );
1823
+
1824
+ await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
1825
+ },
1826
+ [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
1827
+ );
1828
+
1829
+ const handleDomAddTextField = useCallback(
1830
+ async (afterFieldKey?: string) => {
1831
+ if (!domEditSelection) return null;
1832
+ if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
1833
+
1834
+ const insertionIndex = domEditSelection.textFields.findIndex(
1835
+ (field) => field.key === afterFieldKey,
1836
+ );
1837
+ const baseField =
1838
+ domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
1839
+ domEditSelection.textFields[0];
1840
+ const nextField = buildDefaultDomEditTextField(baseField);
1841
+ const nextTextFields = [...domEditSelection.textFields];
1842
+ nextTextFields.splice(
1843
+ insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
1844
+ 0,
1845
+ nextField,
1846
+ );
1847
+
1848
+ await commitDomTextFields(domEditSelection, nextTextFields);
1849
+ return nextField.key;
1850
+ },
1851
+ [commitDomTextFields, domEditSelection],
1852
+ );
1853
+
1854
+ const handleDomRemoveTextField = useCallback(
1855
+ async (fieldKey: string) => {
1856
+ if (!domEditSelection) return;
1857
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1858
+ if (!field) return;
1859
+
1860
+ if (field.source === "self") {
1861
+ await handleDomTextCommit("", fieldKey);
1862
+ return;
1863
+ }
1864
+
1865
+ const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
1866
+ await commitDomTextFields(domEditSelection, nextTextFields);
1867
+ },
1868
+ [commitDomTextFields, domEditSelection, handleDomTextCommit],
1869
+ );
1870
+
1871
+ const handleAskAgent = useCallback(() => {
1872
+ if (!domEditSelection) return;
1873
+ setAgentModalOpen(true);
1874
+ }, [domEditSelection]);
1875
+
1876
+ const handleAgentModalSubmit = useCallback(
1877
+ async (userInstruction: string) => {
1878
+ if (!domEditSelection) return;
1879
+
1880
+ const pid = projectIdRef.current;
1881
+ if (!pid) return;
1882
+
1883
+ const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
1884
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1885
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
1886
+
1887
+ const data = (await response.json()) as { content?: string };
1888
+ const html = data.content;
1889
+ const tagSnippet =
1890
+ typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
1891
+ const prompt = buildElementAgentPrompt({
1892
+ selection: domEditSelection,
1893
+ currentTime,
1894
+ tagSnippet,
1895
+ userInstruction,
1896
+ });
1897
+
1898
+ try {
1899
+ await navigator.clipboard.writeText(prompt);
1900
+ } catch {
1901
+ const textarea = document.createElement("textarea");
1902
+ textarea.value = prompt;
1903
+ textarea.setAttribute("readonly", "true");
1904
+ textarea.style.position = "fixed";
1905
+ textarea.style.opacity = "0";
1906
+ document.body.appendChild(textarea);
1907
+ textarea.select();
1908
+ document.execCommand("copy");
1909
+ document.body.removeChild(textarea);
1910
+ }
1911
+
1912
+ setAgentModalOpen(false);
1913
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
1914
+ setCopiedAgentPrompt(true);
1915
+ copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
1916
+ },
1917
+ [activeCompPath, currentTime, domEditSelection],
1918
+ );
1919
+
1920
+ const handlePreviewIframeRef = useCallback(
1921
+ (iframe: HTMLIFrameElement | null) => {
1922
+ previewIframeRef.current = iframe;
1923
+ setPreviewIframe(iframe);
1924
+ syncPreviewTimelineHotkey(iframe);
1925
+ consoleErrorsRef.current = [];
1926
+ setConsoleErrors(null);
1927
+ },
1928
+ [syncPreviewTimelineHotkey],
1929
+ );
1930
+
1931
+ const handlePreviewCanvasMouseDown = useCallback(
1932
+ (e: React.MouseEvent<HTMLDivElement>) => {
1933
+ const iframe = previewIframeRef.current;
1934
+ if (!iframe || captionEditMode) return;
1935
+ const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
1936
+ if (!target) {
1937
+ lastPreviewClickRef.current = null;
1938
+ applyDomSelection(null, { revealPanel: false });
1939
+ return;
1940
+ }
1941
+ e.preventDefault();
1942
+ e.stopPropagation();
1943
+ const nextSelection = buildDomSelectionFromTarget(target, {
1944
+ preferClipAncestor: true,
1945
+ });
1946
+ if (!nextSelection) {
1947
+ lastPreviewClickRef.current = null;
1948
+ applyDomSelection(null, { revealPanel: false });
1949
+ return;
1950
+ }
1951
+ if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
1952
+ const key = getDomSelectionClickKey(nextSelection);
1953
+ const last = lastPreviewClickRef.current;
1954
+ const now = Date.now();
1955
+ if (last && last.key === key && now - last.at < 350) {
1956
+ lastPreviewClickRef.current = null;
1957
+ applyDomSelection(null, { revealPanel: false });
1958
+ setActiveCompPath(nextSelection.compositionSrc);
1959
+ return;
1960
+ }
1961
+ lastPreviewClickRef.current = { key, at: now };
1962
+ } else {
1963
+ lastPreviewClickRef.current = null;
1964
+ }
1965
+ applyDomSelection(nextSelection);
1966
+ },
1967
+ [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
1968
+ );
1969
+
1970
+ const handlePreviewCanvasDoubleClick = useCallback(
1971
+ (e: React.MouseEvent<HTMLDivElement>) => {
1972
+ const iframe = previewIframeRef.current;
1973
+ if (!iframe || captionEditMode) return;
1974
+ const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
1975
+ if (!target) return;
1976
+ const nextSelection = buildDomSelectionFromTarget(target, {
1977
+ preferClipAncestor: false,
1978
+ });
1979
+ if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
1980
+ return;
1981
+ }
1982
+ e.preventDefault();
1983
+ e.stopPropagation();
1984
+ lastPreviewClickRef.current = null;
1985
+ applyDomSelection(null, { revealPanel: false });
1986
+ setActiveCompPath(nextSelection.compositionSrc);
1987
+ },
1988
+ [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
1989
+ );
1990
+
1991
+ const handleSelectedOverlayDoubleClick = useCallback(() => {
1992
+ const selection = domEditSelectionRef.current;
1993
+ if (!selection?.isCompositionHost || !selection.compositionSrc) return;
1994
+ applyDomSelection(null, { revealPanel: false });
1995
+ setActiveCompPath(selection.compositionSrc);
1996
+ }, [applyDomSelection]);
1997
+
1998
+ // eslint-disable-next-line no-restricted-syntax
1999
+ useEffect(() => {
2000
+ if (!previewIframe || captionEditMode) return;
2001
+
2002
+ const syncSelectionFromDocument = () => {
2003
+ const currentSelection = domEditSelectionRef.current;
2004
+ if (!currentSelection) return;
2005
+ let doc: Document | null = null;
2006
+ try {
2007
+ doc = previewIframe.contentDocument;
2008
+ } catch {
2009
+ return;
2010
+ }
2011
+ if (!doc) return;
2012
+
2013
+ const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
2014
+ if (!nextElement) {
2015
+ applyDomSelection(null, { revealPanel: false });
2016
+ return;
2017
+ }
2018
+
2019
+ const nextSelection = buildDomSelectionFromTarget(nextElement);
2020
+ if (nextSelection) {
2021
+ applyDomSelection(nextSelection, { revealPanel: false });
2022
+ }
2023
+ };
2024
+
2025
+ const attachErrorCapture = () => {
2026
+ try {
2027
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
2028
+ if (!win) return;
2029
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
2030
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
2031
+ const origError = win.console.error.bind(win.console);
2032
+ win.console.error = function (...args: unknown[]) {
2033
+ origError(...args);
2034
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
2035
+ if (text.includes("favicon")) return;
2036
+ consoleErrorsRef.current = [
2037
+ ...consoleErrorsRef.current,
2038
+ { severity: "error", message: text },
2039
+ ];
2040
+ setConsoleErrors([...consoleErrorsRef.current]);
2041
+ };
2042
+ win.addEventListener("error", (e: ErrorEvent) => {
2043
+ const text = e.message || String(e);
2044
+ consoleErrorsRef.current = [
2045
+ ...consoleErrorsRef.current,
2046
+ { severity: "error", message: text },
2047
+ ];
2048
+ setConsoleErrors([...consoleErrorsRef.current]);
2049
+ });
2050
+ } catch {
2051
+ // same-origin only
2052
+ }
2053
+ };
2054
+
2055
+ attachErrorCapture();
2056
+ syncSelectionFromDocument();
2057
+
2058
+ const handleLoad = () => {
2059
+ consoleErrorsRef.current = [];
2060
+ setConsoleErrors(null);
2061
+ attachErrorCapture();
2062
+ syncSelectionFromDocument();
2063
+ };
2064
+
2065
+ previewIframe.addEventListener("load", handleLoad);
2066
+ return () => {
2067
+ previewIframe.removeEventListener("load", handleLoad);
2068
+ };
2069
+ }, [
2070
+ activeCompPath,
2071
+ applyDomSelection,
2072
+ buildDomSelectionFromTarget,
2073
+ captionEditMode,
2074
+ previewIframe,
2075
+ ]);
2076
+
2077
+ // eslint-disable-next-line no-restricted-syntax
2078
+ useEffect(() => {
2079
+ if (!captionEditMode) return;
2080
+ applyDomSelection(null, { revealPanel: false });
2081
+ }, [applyDomSelection, captionEditMode]);
2082
+
2083
+ // eslint-disable-next-line no-restricted-syntax
2084
+ useEffect(
2085
+ () => () => {
2086
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
2087
+ },
2088
+ [],
2089
+ );
2090
+
909
2091
  const refreshFileTree = useCallback(async () => {
910
2092
  const pid = projectIdRef.current;
911
2093
  if (!pid) return;
@@ -1039,6 +2221,7 @@ export function StudioApp() {
1039
2221
  duration: normalizedDuration,
1040
2222
  track: placement.track,
1041
2223
  zIndex: trackZIndices.get(placement.track) ?? 1,
2224
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
1042
2225
  }),
1043
2226
  );
1044
2227
 
@@ -1223,7 +2406,33 @@ export function StudioApp() {
1223
2406
 
1224
2407
  const handleImportFiles = useCallback(
1225
2408
  async (files: FileList | File[], dir?: string) => {
1226
- void uploadProjectFiles(Array.from(files), dir);
2409
+ return uploadProjectFiles(Array.from(files), dir);
2410
+ },
2411
+ [uploadProjectFiles],
2412
+ );
2413
+
2414
+ const handleImportFonts = useCallback(
2415
+ async (files: FileList | File[]) => {
2416
+ const uploaded = await uploadProjectFiles(
2417
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
2418
+ "assets/fonts",
2419
+ );
2420
+ const pid = projectIdRef.current;
2421
+ const imported = uploaded
2422
+ .filter((asset) => FONT_EXT.test(asset))
2423
+ .map((asset) => ({
2424
+ family: fontFamilyFromAssetPath(asset),
2425
+ path: asset,
2426
+ url: `/api/projects/${pid}/preview/${asset}`,
2427
+ }));
2428
+ importedFontAssetsRef.current = [
2429
+ ...imported,
2430
+ ...importedFontAssetsRef.current.filter(
2431
+ (existing) =>
2432
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
2433
+ ),
2434
+ ];
2435
+ return imported;
1227
2436
  },
1228
2437
  [uploadProjectFiles],
1229
2438
  );
@@ -1295,6 +2504,17 @@ export function StudioApp() {
1295
2504
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
1296
2505
  [fileTree],
1297
2506
  );
2507
+ const fontAssets = useMemo<ImportedFontAsset[]>(
2508
+ () =>
2509
+ assets
2510
+ .filter((asset) => FONT_EXT.test(asset))
2511
+ .map((asset) => ({
2512
+ family: fontFamilyFromAssetPath(asset),
2513
+ path: asset,
2514
+ url: `/api/projects/${projectId}/preview/${asset}`,
2515
+ })),
2516
+ [assets, projectId],
2517
+ );
1298
2518
 
1299
2519
  if (resolving || !projectId) {
1300
2520
  return (
@@ -1390,7 +2610,15 @@ export function StudioApp() {
1390
2610
  <span>Timeline</span>
1391
2611
  </button>
1392
2612
  <button
1393
- onClick={() => setRightCollapsed((v) => !v)}
2613
+ onClick={() => {
2614
+ if (rightCollapsed || rightPanelTab !== "design") {
2615
+ setRightPanelTab("design");
2616
+ setRightCollapsed(false);
2617
+ return;
2618
+ }
2619
+ clearDomSelection();
2620
+ setRightCollapsed(true);
2621
+ }}
1394
2622
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1395
2623
  !rightCollapsed
1396
2624
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -1408,8 +2636,7 @@ export function StudioApp() {
1408
2636
  <circle cx="12" cy="12" r="10" />
1409
2637
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
1410
2638
  </svg>
1411
- Renders
1412
- {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
2639
+ Inspector
1413
2640
  </button>
1414
2641
  </div>
1415
2642
  </div>
@@ -1500,56 +2727,24 @@ export function StudioApp() {
1500
2727
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
1501
2728
  setActiveCompPath(compPath);
1502
2729
  }}
1503
- onIframeRef={(iframe) => {
1504
- previewIframeRef.current = iframe;
1505
- syncPreviewTimelineHotkey(iframe);
1506
- consoleErrorsRef.current = [];
1507
- setConsoleErrors(null);
1508
- if (!iframe) return;
1509
-
1510
- // Attach error capture after each iframe load (content resets on navigation)
1511
- const attachErrorCapture = () => {
1512
- try {
1513
- const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1514
- if (!win) return;
1515
- // Guard against double-patching
1516
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1517
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1518
- const origError = win.console.error.bind(win.console);
1519
- win.console.error = function (...args: unknown[]) {
1520
- origError(...args);
1521
- const text = args
1522
- .map((a) => (a instanceof Error ? a.message : String(a)))
1523
- .join(" ");
1524
- if (text.includes("favicon")) return;
1525
- consoleErrorsRef.current = [
1526
- ...consoleErrorsRef.current,
1527
- { severity: "error", message: text },
1528
- ];
1529
- setConsoleErrors([...consoleErrorsRef.current]);
1530
- };
1531
- win.addEventListener("error", (e: ErrorEvent) => {
1532
- const text = e.message || String(e);
1533
- consoleErrorsRef.current = [
1534
- ...consoleErrorsRef.current,
1535
- { severity: "error", message: text },
1536
- ];
1537
- setConsoleErrors([...consoleErrorsRef.current]);
1538
- });
1539
- } catch {
1540
- // cross-origin — can't attach
1541
- }
1542
- };
1543
- // Attach now (iframe may already be loaded) and on future loads
1544
- attachErrorCapture();
1545
- iframe.addEventListener("load", () => {
1546
- consoleErrorsRef.current = [];
1547
- setConsoleErrors(null);
1548
- attachErrorCapture();
1549
- });
1550
- }}
2730
+ onIframeRef={handlePreviewIframeRef}
1551
2731
  previewOverlay={
1552
- captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
2732
+ captionEditMode ? (
2733
+ <CaptionOverlay iframeRef={previewIframeRef} />
2734
+ ) : (
2735
+ <DomEditOverlay
2736
+ iframeRef={previewIframeRef}
2737
+ selection={
2738
+ !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
2739
+ }
2740
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
2741
+ onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
2742
+ onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
2743
+ onBlockedMove={handleBlockedDomMove}
2744
+ onMoveCommit={handleDomMoveCommit}
2745
+ onResizeCommit={handleDomResizeCommit}
2746
+ />
2747
+ )
1553
2748
  }
1554
2749
  timelineFooter={
1555
2750
  captionEditMode ? (
@@ -1590,26 +2785,73 @@ export function StudioApp() {
1590
2785
  {captionEditMode ? (
1591
2786
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
1592
2787
  ) : (
1593
- <RenderQueue
1594
- jobs={renderQueue.jobs}
1595
- projectId={projectId}
1596
- onDelete={renderQueue.deleteRender}
1597
- onClearCompleted={renderQueue.clearCompleted}
1598
- onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
1599
- isRendering={renderQueue.isRendering}
1600
- />
2788
+ <>
2789
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
2790
+ <button
2791
+ type="button"
2792
+ onClick={() => setRightPanelTab("design")}
2793
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2794
+ rightPanelTab === "design"
2795
+ ? "bg-neutral-800 text-white"
2796
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2797
+ }`}
2798
+ >
2799
+ Design
2800
+ </button>
2801
+ <button
2802
+ type="button"
2803
+ onClick={() => setRightPanelTab("renders")}
2804
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2805
+ rightPanelTab === "renders"
2806
+ ? "bg-neutral-800 text-white"
2807
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2808
+ }`}
2809
+ >
2810
+ {renderQueue.jobs.length > 0
2811
+ ? `Renders (${renderQueue.jobs.length})`
2812
+ : "Renders"}
2813
+ </button>
2814
+ </div>
2815
+ <div className="min-h-0 flex-1">
2816
+ {rightPanelTab === "design" ? (
2817
+ <PropertyPanel
2818
+ projectId={projectId}
2819
+ assets={assets}
2820
+ element={domEditSelection}
2821
+ copiedAgentPrompt={copiedAgentPrompt}
2822
+ onClearSelection={clearDomSelection}
2823
+ onSetStyle={handleDomStyleCommit}
2824
+ onSetText={handleDomTextCommit}
2825
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
2826
+ onAddTextField={handleDomAddTextField}
2827
+ onRemoveTextField={handleDomRemoveTextField}
2828
+ onDetachFromLayout={handleDomDetachFromLayout}
2829
+ onAskAgent={handleAskAgent}
2830
+ onCopyAgentInstruction={handleAgentModalSubmit}
2831
+ onImportAssets={handleImportFiles}
2832
+ fontAssets={fontAssets}
2833
+ onImportFonts={handleImportFonts}
2834
+ />
2835
+ ) : (
2836
+ <RenderQueue
2837
+ jobs={renderQueue.jobs}
2838
+ projectId={projectId}
2839
+ onDelete={renderQueue.deleteRender}
2840
+ onClearCompleted={renderQueue.clearCompleted}
2841
+ onStartRender={(format, quality) =>
2842
+ renderQueue.startRender(30, quality, format)
2843
+ }
2844
+ isRendering={renderQueue.isRendering}
2845
+ />
2846
+ )}
2847
+ </div>
2848
+ </>
1601
2849
  )}
1602
2850
  </div>
1603
2851
  </>
1604
2852
  )}
1605
2853
  </div>
1606
2854
 
1607
- {timelineElements.length > 0 && !timelineEditorHintDismissed && (
1608
- <div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
1609
- <TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
1610
- </div>
1611
- )}
1612
-
1613
2855
  {/* Lint modal */}
1614
2856
  {lintModal !== null && projectId && (
1615
2857
  <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
@@ -1624,6 +2866,15 @@ export function StudioApp() {
1624
2866
  />
1625
2867
  )}
1626
2868
 
2869
+ {/* Ask agent modal */}
2870
+ {agentModalOpen && domEditSelection && (
2871
+ <AskAgentModal
2872
+ selectionLabel={domEditSelection.label}
2873
+ onSubmit={handleAgentModalSubmit}
2874
+ onClose={() => setAgentModalOpen(false)}
2875
+ />
2876
+ )}
2877
+
1627
2878
  {/* Global drag-drop overlay */}
1628
2879
  {globalDragOver && (
1629
2880
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">