@hyperframes/studio 0.4.22 → 0.5.0-alpha.1

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-Bi30tos-.js +105 -0
  2. package/dist/assets/index-Dm9VsShj.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 +12 -6
  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-D0VntLIQ.js +0 -115
  36. package/dist/assets/index-kT65pCwW.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;
@@ -1033,6 +2215,7 @@ export function StudioApp() {
1033
2215
  duration: normalizedDuration,
1034
2216
  track: placement.track,
1035
2217
  zIndex: trackZIndices.get(placement.track) ?? 1,
2218
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
1036
2219
  }),
1037
2220
  );
1038
2221
 
@@ -1194,7 +2377,33 @@ export function StudioApp() {
1194
2377
 
1195
2378
  const handleImportFiles = useCallback(
1196
2379
  async (files: FileList | File[], dir?: string) => {
1197
- void uploadProjectFiles(Array.from(files), dir);
2380
+ return uploadProjectFiles(Array.from(files), dir);
2381
+ },
2382
+ [uploadProjectFiles],
2383
+ );
2384
+
2385
+ const handleImportFonts = useCallback(
2386
+ async (files: FileList | File[]) => {
2387
+ const uploaded = await uploadProjectFiles(
2388
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
2389
+ "assets/fonts",
2390
+ );
2391
+ const pid = projectIdRef.current;
2392
+ const imported = uploaded
2393
+ .filter((asset) => FONT_EXT.test(asset))
2394
+ .map((asset) => ({
2395
+ family: fontFamilyFromAssetPath(asset),
2396
+ path: asset,
2397
+ url: `/api/projects/${pid}/preview/${asset}`,
2398
+ }));
2399
+ importedFontAssetsRef.current = [
2400
+ ...imported,
2401
+ ...importedFontAssetsRef.current.filter(
2402
+ (existing) =>
2403
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
2404
+ ),
2405
+ ];
2406
+ return imported;
1198
2407
  },
1199
2408
  [uploadProjectFiles],
1200
2409
  );
@@ -1266,6 +2475,17 @@ export function StudioApp() {
1266
2475
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
1267
2476
  [fileTree],
1268
2477
  );
2478
+ const fontAssets = useMemo<ImportedFontAsset[]>(
2479
+ () =>
2480
+ assets
2481
+ .filter((asset) => FONT_EXT.test(asset))
2482
+ .map((asset) => ({
2483
+ family: fontFamilyFromAssetPath(asset),
2484
+ path: asset,
2485
+ url: `/api/projects/${projectId}/preview/${asset}`,
2486
+ })),
2487
+ [assets, projectId],
2488
+ );
1269
2489
 
1270
2490
  if (resolving || !projectId) {
1271
2491
  return (
@@ -1361,7 +2581,15 @@ export function StudioApp() {
1361
2581
  <span>Timeline</span>
1362
2582
  </button>
1363
2583
  <button
1364
- onClick={() => setRightCollapsed((v) => !v)}
2584
+ onClick={() => {
2585
+ if (rightCollapsed || rightPanelTab !== "design") {
2586
+ setRightPanelTab("design");
2587
+ setRightCollapsed(false);
2588
+ return;
2589
+ }
2590
+ clearDomSelection();
2591
+ setRightCollapsed(true);
2592
+ }}
1365
2593
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1366
2594
  !rightCollapsed
1367
2595
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -1379,8 +2607,7 @@ export function StudioApp() {
1379
2607
  <circle cx="12" cy="12" r="10" />
1380
2608
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
1381
2609
  </svg>
1382
- Renders
1383
- {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
2610
+ Inspector
1384
2611
  </button>
1385
2612
  </div>
1386
2613
  </div>
@@ -1471,56 +2698,24 @@ export function StudioApp() {
1471
2698
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
1472
2699
  setActiveCompPath(compPath);
1473
2700
  }}
1474
- onIframeRef={(iframe) => {
1475
- previewIframeRef.current = iframe;
1476
- syncPreviewTimelineHotkey(iframe);
1477
- consoleErrorsRef.current = [];
1478
- setConsoleErrors(null);
1479
- if (!iframe) return;
1480
-
1481
- // Attach error capture after each iframe load (content resets on navigation)
1482
- const attachErrorCapture = () => {
1483
- try {
1484
- const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1485
- if (!win) return;
1486
- // Guard against double-patching
1487
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1488
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1489
- const origError = win.console.error.bind(win.console);
1490
- win.console.error = function (...args: unknown[]) {
1491
- origError(...args);
1492
- const text = args
1493
- .map((a) => (a instanceof Error ? a.message : String(a)))
1494
- .join(" ");
1495
- if (text.includes("favicon")) return;
1496
- consoleErrorsRef.current = [
1497
- ...consoleErrorsRef.current,
1498
- { severity: "error", message: text },
1499
- ];
1500
- setConsoleErrors([...consoleErrorsRef.current]);
1501
- };
1502
- win.addEventListener("error", (e: ErrorEvent) => {
1503
- const text = e.message || String(e);
1504
- consoleErrorsRef.current = [
1505
- ...consoleErrorsRef.current,
1506
- { severity: "error", message: text },
1507
- ];
1508
- setConsoleErrors([...consoleErrorsRef.current]);
1509
- });
1510
- } catch {
1511
- // cross-origin — can't attach
1512
- }
1513
- };
1514
- // Attach now (iframe may already be loaded) and on future loads
1515
- attachErrorCapture();
1516
- iframe.addEventListener("load", () => {
1517
- consoleErrorsRef.current = [];
1518
- setConsoleErrors(null);
1519
- attachErrorCapture();
1520
- });
1521
- }}
2701
+ onIframeRef={handlePreviewIframeRef}
1522
2702
  previewOverlay={
1523
- captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
2703
+ captionEditMode ? (
2704
+ <CaptionOverlay iframeRef={previewIframeRef} />
2705
+ ) : (
2706
+ <DomEditOverlay
2707
+ iframeRef={previewIframeRef}
2708
+ selection={
2709
+ !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
2710
+ }
2711
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
2712
+ onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
2713
+ onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
2714
+ onBlockedMove={handleBlockedDomMove}
2715
+ onMoveCommit={handleDomMoveCommit}
2716
+ onResizeCommit={handleDomResizeCommit}
2717
+ />
2718
+ )
1524
2719
  }
1525
2720
  timelineFooter={
1526
2721
  captionEditMode ? (
@@ -1561,26 +2756,73 @@ export function StudioApp() {
1561
2756
  {captionEditMode ? (
1562
2757
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
1563
2758
  ) : (
1564
- <RenderQueue
1565
- jobs={renderQueue.jobs}
1566
- projectId={projectId}
1567
- onDelete={renderQueue.deleteRender}
1568
- onClearCompleted={renderQueue.clearCompleted}
1569
- onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
1570
- isRendering={renderQueue.isRendering}
1571
- />
2759
+ <>
2760
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
2761
+ <button
2762
+ type="button"
2763
+ onClick={() => setRightPanelTab("design")}
2764
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2765
+ rightPanelTab === "design"
2766
+ ? "bg-neutral-800 text-white"
2767
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2768
+ }`}
2769
+ >
2770
+ Design
2771
+ </button>
2772
+ <button
2773
+ type="button"
2774
+ onClick={() => setRightPanelTab("renders")}
2775
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2776
+ rightPanelTab === "renders"
2777
+ ? "bg-neutral-800 text-white"
2778
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2779
+ }`}
2780
+ >
2781
+ {renderQueue.jobs.length > 0
2782
+ ? `Renders (${renderQueue.jobs.length})`
2783
+ : "Renders"}
2784
+ </button>
2785
+ </div>
2786
+ <div className="min-h-0 flex-1">
2787
+ {rightPanelTab === "design" ? (
2788
+ <PropertyPanel
2789
+ projectId={projectId}
2790
+ assets={assets}
2791
+ element={domEditSelection}
2792
+ copiedAgentPrompt={copiedAgentPrompt}
2793
+ onClearSelection={clearDomSelection}
2794
+ onSetStyle={handleDomStyleCommit}
2795
+ onSetText={handleDomTextCommit}
2796
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
2797
+ onAddTextField={handleDomAddTextField}
2798
+ onRemoveTextField={handleDomRemoveTextField}
2799
+ onDetachFromLayout={handleDomDetachFromLayout}
2800
+ onAskAgent={handleAskAgent}
2801
+ onCopyAgentInstruction={handleAgentModalSubmit}
2802
+ onImportAssets={handleImportFiles}
2803
+ fontAssets={fontAssets}
2804
+ onImportFonts={handleImportFonts}
2805
+ />
2806
+ ) : (
2807
+ <RenderQueue
2808
+ jobs={renderQueue.jobs}
2809
+ projectId={projectId}
2810
+ onDelete={renderQueue.deleteRender}
2811
+ onClearCompleted={renderQueue.clearCompleted}
2812
+ onStartRender={(format, quality) =>
2813
+ renderQueue.startRender(30, quality, format)
2814
+ }
2815
+ isRendering={renderQueue.isRendering}
2816
+ />
2817
+ )}
2818
+ </div>
2819
+ </>
1572
2820
  )}
1573
2821
  </div>
1574
2822
  </>
1575
2823
  )}
1576
2824
  </div>
1577
2825
 
1578
- {timelineElements.length > 0 && !timelineEditorHintDismissed && (
1579
- <div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
1580
- <TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
1581
- </div>
1582
- )}
1583
-
1584
2826
  {/* Lint modal */}
1585
2827
  {lintModal !== null && projectId && (
1586
2828
  <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
@@ -1595,6 +2837,15 @@ export function StudioApp() {
1595
2837
  />
1596
2838
  )}
1597
2839
 
2840
+ {/* Ask agent modal */}
2841
+ {agentModalOpen && domEditSelection && (
2842
+ <AskAgentModal
2843
+ selectionLabel={domEditSelection.label}
2844
+ onSubmit={handleAgentModalSubmit}
2845
+ onClose={() => setAgentModalOpen(false)}
2846
+ />
2847
+ )}
2848
+
1598
2849
  {/* Global drag-drop overlay */}
1599
2850
  {globalDragOver && (
1600
2851
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">