@hyperframes/studio 0.5.0-alpha.14 → 0.5.0-alpha.15

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 (33) hide show
  1. package/dist/assets/{hyperframes-player-vibA20NC.js → hyperframes-player-Cd8vYWxP.js} +2 -2
  2. package/dist/assets/index-DFLVGWTx.js +106 -0
  3. package/dist/assets/index-mXJ-UH9F.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +785 -377
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  13. package/src/components/editor/DomEditOverlay.tsx +970 -115
  14. package/src/components/editor/PropertyPanel.tsx +91 -83
  15. package/src/components/editor/domEditing.test.ts +161 -29
  16. package/src/components/editor/domEditing.ts +84 -113
  17. package/src/components/editor/manualEdits.test.ts +945 -0
  18. package/src/components/editor/manualEdits.ts +1397 -0
  19. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  20. package/src/components/editor/manualOffsetDrag.ts +307 -0
  21. package/src/components/renders/RenderQueue.tsx +10 -3
  22. package/src/hooks/usePersistentEditHistory.test.ts +1 -0
  23. package/src/hooks/usePersistentEditHistory.ts +3 -2
  24. package/src/player/components/CompositionThumbnail.test.ts +1 -1
  25. package/src/player/components/CompositionThumbnail.tsx +1 -1
  26. package/src/player/components/Player.tsx +54 -9
  27. package/src/player/hooks/useTimelinePlayer.test.ts +1 -0
  28. package/src/utils/clipboard.test.ts +1 -0
  29. package/src/utils/frameCapture.ts +3 -1
  30. package/src/utils/projectRouting.test.ts +87 -0
  31. package/src/utils/projectRouting.ts +27 -0
  32. package/dist/assets/index-JhhmFie-.js +0 -105
  33. package/dist/assets/index-KioPDrX6.css +0 -1
package/src/App.tsx CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  shouldHandleTimelineToggleHotkey,
62
62
  } from "./utils/timelineDiscovery";
63
63
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
64
+ import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
64
65
  import { Camera } from "./icons/SystemIcons";
65
66
  import { PropertyPanel } from "./components/editor/PropertyPanel";
66
67
  import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
@@ -69,23 +70,38 @@ import {
69
70
  importedFontFaceCss,
70
71
  type ImportedFontAsset,
71
72
  } from "./components/editor/fontAssets";
72
- import { DomEditOverlay } from "./components/editor/DomEditOverlay";
73
+ import {
74
+ DomEditOverlay,
75
+ type DomEditGroupPathOffsetCommit,
76
+ } from "./components/editor/DomEditOverlay";
73
77
  import {
74
78
  buildDefaultDomEditTextField,
75
- buildDomEditDetachPatchOperations,
76
- buildDomEditMovePatchOperations,
77
- buildDomEditResizePatchOperations,
78
79
  buildDomEditStylePatchOperation,
79
80
  buildDomEditTextPatchOperation,
80
81
  buildElementAgentPrompt,
81
82
  findElementForSelection,
83
+ getDomEditTargetKey,
82
84
  isTextEditableSelection,
83
85
  serializeDomEditTextFields,
84
- resolveDomEditCapabilities,
85
86
  resolveDomEditSelection,
86
87
  type DomEditTextField,
87
88
  type DomEditSelection,
88
89
  } from "./components/editor/domEditing";
90
+ import {
91
+ STUDIO_MANUAL_EDITS_PATH,
92
+ applyStudioManualEditManifest,
93
+ emptyStudioManualEditManifest,
94
+ installStudioManualEditSeekReapply,
95
+ isStudioManualEditManifestPath,
96
+ parseStudioManualEditManifest,
97
+ readStudioFileChangePath,
98
+ removeStudioManualEditsForSelection,
99
+ serializeStudioManualEditManifest,
100
+ type StudioManualEditManifest,
101
+ upsertStudioBoxSizeEdit,
102
+ upsertStudioPathOffsetEdit,
103
+ upsertStudioRotationEdit,
104
+ } from "./components/editor/manualEdits";
89
105
  import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
90
106
 
91
107
  interface EditingFile {
@@ -226,10 +242,7 @@ function normalizeDomEditStyleValue(property: string, value: string): string {
226
242
  const trimmed = value.trim();
227
243
  if (!trimmed) return trimmed;
228
244
 
229
- if (
230
- ["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
231
- /^-?\d+(\.\d+)?$/.test(trimmed)
232
- ) {
245
+ if (["border-radius", "font-size"].includes(property) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
233
246
  return `${trimmed}px`;
234
247
  }
235
248
 
@@ -240,27 +253,6 @@ function isImageBackgroundValue(value: string): boolean {
240
253
  return /^url\(/i.test(value.trim());
241
254
  }
242
255
 
243
- function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
244
- return Boolean(
245
- selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
246
- );
247
- }
248
-
249
- function buildOppositeEdgePatchOperations(
250
- selection: DomEditSelection,
251
- dimension: "width" | "height" | "both",
252
- ): PatchOperation[] {
253
- if (!shouldDetachOppositeEdges(selection)) return [];
254
- const operations: PatchOperation[] = [];
255
- if (dimension === "width" || dimension === "both") {
256
- operations.push({ type: "inline-style", property: "right", value: "auto" });
257
- }
258
- if (dimension === "height" || dimension === "both") {
259
- operations.push({ type: "inline-style", property: "bottom", value: "auto" });
260
- }
261
- return operations;
262
- }
263
-
264
256
  function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
265
257
  if (!target || typeof target !== "object") return null;
266
258
  const maybeNode = target as {
@@ -289,14 +281,6 @@ function getHistoryShortcutLabel(action: "undo" | "redo"): string {
289
281
  return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
290
282
  }
291
283
 
292
- function getDomEditCoalesceKey(
293
- selection: Pick<DomEditSelection, "id" | "selector" | "sourceFile">,
294
- action: "move" | "resize",
295
- ): string {
296
- const target = selection.id || selection.selector || "selection";
297
- return `${action}:${selection.sourceFile || "index.html"}:${target}`;
298
- }
299
-
300
284
  function findMatchingTimelineElementId(
301
285
  selection: Pick<
302
286
  DomEditSelection,
@@ -304,8 +288,14 @@ function findMatchingTimelineElementId(
304
288
  >,
305
289
  elements: TimelineElement[],
306
290
  ): string | null {
291
+ const selectionSourceFile = selection.sourceFile || "index.html";
307
292
  for (const element of elements) {
308
- if (selection.id && element.domId === selection.id) {
293
+ const elementSourceFile = element.sourceFile || "index.html";
294
+ if (
295
+ selection.id &&
296
+ element.domId === selection.id &&
297
+ elementSourceFile === selectionSourceFile
298
+ ) {
309
299
  return element.key ?? element.id;
310
300
  }
311
301
  if (
@@ -328,112 +318,8 @@ function findMatchingTimelineElementId(
328
318
  return null;
329
319
  }
330
320
 
331
- function findMappedCompositionHost(
332
- target: HTMLElement,
333
- timelineElements: TimelineElement[],
334
- compIdToSrc: Map<string, string>,
335
- fileTree: string[],
336
- ): { host: HTMLElement; compositionSrc: string } | null {
337
- const rootCompositionId =
338
- target.ownerDocument
339
- .querySelector("[data-composition-id]")
340
- ?.getAttribute("data-composition-id") ?? null;
341
-
342
- let nestedCurrent: HTMLElement | null = target;
343
- while (nestedCurrent) {
344
- const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
345
- if (nestedCompId && nestedCompId !== rootCompositionId) {
346
- const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
347
- if (hostCandidate instanceof HTMLElement) {
348
- const hostCompId = hostCandidate.getAttribute("data-composition-id");
349
- const compositionSrc =
350
- hostCandidate.getAttribute("data-composition-src") ??
351
- hostCandidate.getAttribute("data-composition-file") ??
352
- (hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
353
- compIdToSrc.get(nestedCompId) ??
354
- fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
355
- undefined;
356
- if (compositionSrc) {
357
- return { host: hostCandidate, compositionSrc };
358
- }
359
- }
360
- }
361
- nestedCurrent = nestedCurrent.parentElement;
362
- }
363
-
364
- let current: HTMLElement | null = target;
365
- while (current) {
366
- const compId = current.getAttribute("data-composition-id");
367
- const directSrc =
368
- current.getAttribute("data-composition-src") ??
369
- current.getAttribute("data-composition-file") ??
370
- undefined;
371
- const timelineMatch =
372
- timelineElements.find(
373
- (element) =>
374
- Boolean(element.compositionSrc) &&
375
- (element.domId === current?.id ||
376
- (current?.id && element.id === current.id) ||
377
- (compId && element.id === compId)),
378
- ) ?? null;
379
- const compositionSrc =
380
- directSrc ??
381
- timelineMatch?.compositionSrc ??
382
- (compId ? compIdToSrc.get(compId) : undefined) ??
383
- (compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
384
- if (compositionSrc) {
385
- return { host: current, compositionSrc };
386
- }
387
- current = current.parentElement;
388
- }
389
-
390
- return null;
391
- }
392
-
393
- function isMoveStyleProperty(property: string): boolean {
394
- return property === "left" || property === "top";
395
- }
396
-
397
- function isResizeStyleProperty(property: string): boolean {
398
- return property === "width" || property === "height";
399
- }
400
-
401
- function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
402
- const offsetParent = element.offsetParent;
403
- if (offsetParent instanceof HTMLElement) return offsetParent;
404
-
405
- let current = element.parentElement;
406
- while (current) {
407
- if (current.hasAttribute("data-composition-id")) return current;
408
- current = current.parentElement;
409
- }
410
-
411
- return element.ownerDocument.body;
412
- }
413
-
414
- function measureDomDetachRect(element: HTMLElement): {
415
- left: number;
416
- top: number;
417
- width: number;
418
- height: number;
419
- } {
420
- const root = getDomDetachCoordinateRoot(element);
421
- const rect = element.getBoundingClientRect();
422
- const rootRect = root.getBoundingClientRect();
423
-
424
- return {
425
- left: rect.left - rootRect.left + root.scrollLeft,
426
- top: rect.top - rootRect.top + root.scrollTop,
427
- width: rect.width,
428
- height: rect.height,
429
- };
430
- }
431
-
432
- function getDomSelectionClickKey(
433
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
434
- ): string {
435
- if (selection.id) return `id:${selection.id}`;
436
- return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
321
+ function isManualGeometryStyleProperty(property: string): boolean {
322
+ return property === "left" || property === "top" || property === "width" || property === "height";
437
323
  }
438
324
 
439
325
  function getPreviewTargetFromPointer(
@@ -467,6 +353,108 @@ function getPreviewTargetFromPointer(
467
353
  return getEventTargetElement(doc.elementFromPoint(localX, localY));
468
354
  }
469
355
 
356
+ function domEditSelectionsTargetSame(
357
+ a: DomEditSelection | null,
358
+ b: DomEditSelection | null,
359
+ ): boolean {
360
+ if (a === b) return true;
361
+ if (!a || !b) return false;
362
+ return getDomEditTargetKey(a) === getDomEditTargetKey(b);
363
+ }
364
+
365
+ function domEditSelectionInGroup(
366
+ group: DomEditSelection[],
367
+ selection: DomEditSelection | null,
368
+ ): boolean {
369
+ if (!selection) return false;
370
+ return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
371
+ }
372
+
373
+ function toggleDomEditGroupSelection(
374
+ group: DomEditSelection[],
375
+ selection: DomEditSelection,
376
+ ): DomEditSelection[] {
377
+ if (domEditSelectionInGroup(group, selection)) {
378
+ return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
379
+ }
380
+ return [...group, selection];
381
+ }
382
+
383
+ function replaceDomEditGroupSelection(
384
+ group: DomEditSelection[],
385
+ selection: DomEditSelection,
386
+ ): DomEditSelection[] {
387
+ let replaced = false;
388
+ const nextGroup = group.map((entry) => {
389
+ if (!domEditSelectionsTargetSame(entry, selection)) return entry;
390
+ replaced = true;
391
+ return selection;
392
+ });
393
+ return replaced ? nextGroup : [...group, selection];
394
+ }
395
+
396
+ function seedDomEditGroupWithSelection(
397
+ group: DomEditSelection[],
398
+ selection: DomEditSelection | null,
399
+ ): DomEditSelection[] {
400
+ if (!selection || domEditSelectionInGroup(group, selection)) return group;
401
+ return [selection, ...group];
402
+ }
403
+
404
+ function objectLike(value: unknown): object | null {
405
+ return value && (typeof value === "object" || typeof value === "function") ? value : null;
406
+ }
407
+
408
+ function callPlaybackMethod(target: object | null, key: string): void {
409
+ const method = target ? Reflect.get(target, key) : null;
410
+ if (typeof method !== "function") return;
411
+ try {
412
+ method.call(target);
413
+ } catch {
414
+ // Best-effort playback freeze; drag should still work if playback control is unavailable.
415
+ }
416
+ }
417
+
418
+ function readPlaybackTime(target: object | null, key: string): number | null {
419
+ const method = target ? Reflect.get(target, key) : null;
420
+ if (typeof method !== "function") return null;
421
+ try {
422
+ const value = method.call(target);
423
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
429
+ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
430
+ const win = iframe?.contentWindow;
431
+ if (!win) return null;
432
+
433
+ try {
434
+ let pausedTime: number | null = null;
435
+ const player = objectLike(Reflect.get(win, "__player"));
436
+ pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
437
+ callPlaybackMethod(player, "pause");
438
+
439
+ const timeline = objectLike(Reflect.get(win, "__timeline"));
440
+ pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
441
+ callPlaybackMethod(timeline, "pause");
442
+
443
+ const timelines = objectLike(Reflect.get(win, "__timelines"));
444
+ if (timelines) {
445
+ for (const value of Object.values(timelines)) {
446
+ const timelineRecord = objectLike(value);
447
+ pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
448
+ callPlaybackMethod(timelineRecord, "pause");
449
+ }
450
+ }
451
+
452
+ return pausedTime;
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
470
458
  // ── Ask Agent Modal ──
471
459
 
472
460
  function AskAgentModal({
@@ -611,9 +599,9 @@ export function StudioApp() {
611
599
  const [resolving, setResolving] = useState(true);
612
600
 
613
601
  useMountEffect(() => {
614
- const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
615
- if (hashMatch) {
616
- setProjectId(hashMatch[1]);
602
+ const hashProjectId = parseProjectIdFromHash(window.location.hash);
603
+ if (hashProjectId) {
604
+ setProjectId(hashProjectId);
617
605
  setResolving(false);
618
606
  return;
619
607
  }
@@ -624,7 +612,7 @@ export function StudioApp() {
624
612
  const first = (data.projects ?? [])[0];
625
613
  if (first) {
626
614
  setProjectId(first.id);
627
- window.location.hash = `#project/${first.id}`;
615
+ window.location.hash = buildProjectHash(first.id);
628
616
  }
629
617
  })
630
618
  .catch(() => {})
@@ -648,6 +636,8 @@ export function StudioApp() {
648
636
  const [rightCollapsed, setRightCollapsed] = useState(true);
649
637
  const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
650
638
  const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
639
+ const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
640
+ const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
651
641
  const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
652
642
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
653
643
  const [agentModalOpen, setAgentModalOpen] = useState(false);
@@ -784,6 +774,7 @@ export function StudioApp() {
784
774
  const lastBlockedDomMoveToastAtRef = useRef(0);
785
775
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
786
776
  const previewHotkeyWindowRef = useRef<Window | null>(null);
777
+ const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
787
778
  const panelDragRef = useRef<{
788
779
  side: "left" | "right";
789
780
  startX: number;
@@ -1102,9 +1093,37 @@ export function StudioApp() {
1102
1093
  const consoleErrorsRef = useRef<LintFinding[]>([]);
1103
1094
  const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1104
1095
  const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
1105
- const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
1096
+ const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
1097
+ const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
1106
1098
  const domEditSaveTimestampRef = useRef(0);
1107
1099
  const domTextCommitVersionRef = useRef(0);
1100
+ const domEditSaveQueueRef = useRef(Promise.resolve());
1101
+ const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
1102
+ emptyStudioManualEditManifest(),
1103
+ );
1104
+ const studioManualEditRevisionRef = useRef(0);
1105
+ const applyStudioManualEditsToPreviewRef = useRef<
1106
+ (
1107
+ iframe?: HTMLIFrameElement | null,
1108
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1109
+ ) => Promise<void>
1110
+ >(async () => {});
1111
+ const studioManualEditProjectRef = useRef<string | null>(projectId);
1112
+ const activeCompPathRef = useRef(activeCompPath);
1113
+ activeCompPathRef.current = activeCompPath;
1114
+
1115
+ const queueDomEditSave = useCallback((save: () => Promise<void>) => {
1116
+ const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
1117
+ domEditSaveQueueRef.current = queuedSave.then(
1118
+ () => undefined,
1119
+ () => undefined,
1120
+ );
1121
+ return queuedSave;
1122
+ }, []);
1123
+
1124
+ const waitForPendingDomEditSaves = useCallback(async () => {
1125
+ await domEditSaveQueueRef.current.catch(() => undefined);
1126
+ }, []);
1108
1127
 
1109
1128
  // Listen for external file changes (user editing HTML outside the editor).
1110
1129
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
@@ -1112,8 +1131,18 @@ export function StudioApp() {
1112
1131
  // those changes are already applied to the iframe DOM and a full reload
1113
1132
  // would flash the preview.
1114
1133
  useMountEffect(() => {
1115
- const handler = () => {
1116
- if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
1134
+ const handler = (payload?: unknown) => {
1135
+ const changedPath = readStudioFileChangePath(payload);
1136
+ const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
1137
+ if (isStudioManualEditManifestPath(changedPath)) {
1138
+ if (!recentDomEditSave) {
1139
+ void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
1140
+ forceFromDisk: true,
1141
+ });
1142
+ }
1143
+ return;
1144
+ }
1145
+ if (recentDomEditSave) return;
1117
1146
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1118
1147
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
1119
1148
  };
@@ -1128,6 +1157,17 @@ export function StudioApp() {
1128
1157
  });
1129
1158
  projectIdRef.current = projectId;
1130
1159
  domEditSelectionRef.current = domEditSelection;
1160
+ domEditGroupSelectionsRef.current = domEditGroupSelections;
1161
+ domEditHoverSelectionRef.current = domEditHoverSelection;
1162
+
1163
+ // eslint-disable-next-line no-restricted-syntax
1164
+ useEffect(() => {
1165
+ const previousProjectId = studioManualEditProjectRef.current;
1166
+ studioManualEditProjectRef.current = projectId;
1167
+ if (!previousProjectId || previousProjectId === projectId) return;
1168
+ studioManualEditManifestRef.current = emptyStudioManualEditManifest();
1169
+ studioManualEditRevisionRef.current += 1;
1170
+ }, [projectId]);
1131
1171
 
1132
1172
  // Load file tree when projectId changes.
1133
1173
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -1199,6 +1239,16 @@ export function StudioApp() {
1199
1239
  }
1200
1240
  }, []);
1201
1241
 
1242
+ const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
1243
+ const pid = projectIdRef.current;
1244
+ if (!pid) throw new Error("No active project");
1245
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
1246
+ if (response.status === 404) return "";
1247
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
1248
+ const data = (await response.json()) as { content?: string };
1249
+ return typeof data.content === "string" ? data.content : "";
1250
+ }, []);
1251
+
1202
1252
  const handleContentChange = useCallback(
1203
1253
  (content: string) => {
1204
1254
  const pid = projectIdRef.current;
@@ -1419,6 +1469,7 @@ export function StudioApp() {
1419
1469
 
1420
1470
  const currentTime = usePlayerStore.getState().currentTime;
1421
1471
  setCaptureFrameTime(currentTime);
1472
+ await waitForPendingDomEditSaves();
1422
1473
  const href = buildFrameCaptureUrl({
1423
1474
  projectId,
1424
1475
  compositionPath: activeCompPath,
@@ -1445,7 +1496,7 @@ export function StudioApp() {
1445
1496
  showToast(message);
1446
1497
  }
1447
1498
  },
1448
- [activeCompPath, projectId, showToast],
1499
+ [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
1449
1500
  );
1450
1501
 
1451
1502
  const handleTimelineElementDelete = useCallback(
@@ -1572,10 +1623,8 @@ export function StudioApp() {
1572
1623
  if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1573
1624
  lastBlockedDomMoveToastAtRef.current = now;
1574
1625
  showToast(
1575
- selection.capabilities.canDetachFromLayout
1576
- ? "This layer is controlled by layout. Use Make movable in the panel to detach it."
1577
- : (selection.capabilities.reasonIfDisabled ??
1578
- "This element can’t be moved directly from the preview."),
1626
+ selection.capabilities.reasonIfDisabled ??
1627
+ "This element can’t be adjusted directly from the preview.",
1579
1628
  "info",
1580
1629
  );
1581
1630
  },
@@ -1583,16 +1632,57 @@ export function StudioApp() {
1583
1632
  );
1584
1633
 
1585
1634
  const applyDomSelection = useCallback(
1586
- (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
1587
- setDomEditSelection(selection);
1635
+ (
1636
+ selection: DomEditSelection | null,
1637
+ options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1638
+ ) => {
1588
1639
  setAgentPromptTagSnippet(undefined);
1589
1640
  setCopiedAgentPrompt(false);
1590
- if (selection) {
1641
+ if (!selection) {
1642
+ domEditSelectionRef.current = null;
1643
+ domEditGroupSelectionsRef.current = [];
1644
+ setDomEditSelection(null);
1645
+ setDomEditGroupSelections([]);
1646
+ setSelectedTimelineElementId(null);
1647
+ return;
1648
+ }
1649
+
1650
+ const isAdditiveSelection = Boolean(options?.additive);
1651
+ const currentSelection = domEditSelectionRef.current;
1652
+ const previousGroup = domEditGroupSelectionsRef.current;
1653
+ const currentGroup = isAdditiveSelection
1654
+ ? seedDomEditGroupWithSelection(previousGroup, currentSelection)
1655
+ : previousGroup;
1656
+ const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
1657
+ const nextGroup = options?.preserveGroup
1658
+ ? replaceDomEditGroupSelection(currentGroup, selection)
1659
+ : isAdditiveSelection
1660
+ ? toggleDomEditGroupSelection(currentGroup, selection)
1661
+ : [selection];
1662
+ const nextSelection = options?.preserveGroup
1663
+ ? selection
1664
+ : isAdditiveSelection && wasInGroup
1665
+ ? domEditSelectionsTargetSame(currentSelection, selection)
1666
+ ? (nextGroup[0] ?? null)
1667
+ : domEditSelectionInGroup(nextGroup, currentSelection)
1668
+ ? currentSelection
1669
+ : (nextGroup[0] ?? null)
1670
+ : selection;
1671
+
1672
+ domEditSelectionRef.current = nextSelection;
1673
+ domEditGroupSelectionsRef.current = nextGroup;
1674
+ setDomEditSelection(nextSelection);
1675
+ setDomEditGroupSelections(nextGroup);
1676
+
1677
+ if (nextSelection) {
1591
1678
  if (options?.revealPanel !== false) {
1592
1679
  setRightCollapsed(false);
1593
1680
  setRightPanelTab("design");
1594
1681
  }
1595
- const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
1682
+ const nextSelectedTimelineId = findMatchingTimelineElementId(
1683
+ nextSelection,
1684
+ timelineElements,
1685
+ );
1596
1686
  setSelectedTimelineElementId(nextSelectedTimelineId);
1597
1687
  return;
1598
1688
  }
@@ -1606,10 +1696,194 @@ export function StudioApp() {
1606
1696
  applyDomSelection(null, { revealPanel: false });
1607
1697
  }, [applyDomSelection]);
1608
1698
 
1699
+ const readHistoryProjectFile = useCallback(
1700
+ async (path: string): Promise<string> => {
1701
+ return path === STUDIO_MANUAL_EDITS_PATH
1702
+ ? readOptionalProjectFile(path)
1703
+ : readProjectFile(path);
1704
+ },
1705
+ [readOptionalProjectFile, readProjectFile],
1706
+ );
1707
+
1708
+ const writeHistoryProjectFile = useCallback(
1709
+ async (path: string, content: string): Promise<void> => {
1710
+ await writeProjectFile(path, content);
1711
+ if (path === STUDIO_MANUAL_EDITS_PATH) {
1712
+ domEditSaveTimestampRef.current = Date.now();
1713
+ }
1714
+ },
1715
+ [writeProjectFile],
1716
+ );
1717
+
1718
+ const applyCurrentStudioManualEditsToPreview = useCallback(
1719
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
1720
+ if (!iframe) return;
1721
+ let doc: Document | null = null;
1722
+ try {
1723
+ doc = iframe.contentDocument;
1724
+ } catch {
1725
+ return;
1726
+ }
1727
+ if (!doc) return;
1728
+ const previewDoc = doc;
1729
+
1730
+ const applyManifest = () => {
1731
+ applyStudioManualEditManifest(
1732
+ previewDoc,
1733
+ studioManualEditManifestRef.current,
1734
+ activeCompPathRef.current,
1735
+ );
1736
+ };
1737
+ const applyAndInstallSeekHooks = () => {
1738
+ applyManifest();
1739
+ if (iframe.contentWindow) {
1740
+ installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
1741
+ }
1742
+ };
1743
+
1744
+ const win = iframe.contentWindow;
1745
+ applyAndInstallSeekHooks();
1746
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
1747
+ win?.setTimeout?.(applyAndInstallSeekHooks, 80);
1748
+ win?.setTimeout?.(applyAndInstallSeekHooks, 250);
1749
+ win?.setTimeout?.(applyAndInstallSeekHooks, 500);
1750
+ win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
1751
+ win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
1752
+ },
1753
+ [],
1754
+ );
1755
+
1756
+ const applyStudioManualEditsToPreview = useCallback(
1757
+ async (
1758
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
1759
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1760
+ ) => {
1761
+ const readRevision = studioManualEditRevisionRef.current;
1762
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
1763
+ if (!readFromDiskFirst) {
1764
+ applyCurrentStudioManualEditsToPreview(iframe);
1765
+ }
1766
+ let content: string;
1767
+ try {
1768
+ content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
1769
+ } catch (error) {
1770
+ const message =
1771
+ error instanceof Error ? error.message : "Failed to read manual edit manifest";
1772
+ showToast(message);
1773
+ if (readFromDiskFirst) {
1774
+ applyCurrentStudioManualEditsToPreview(iframe);
1775
+ }
1776
+ return;
1777
+ }
1778
+ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
1779
+ studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
1780
+ if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
1781
+ applyCurrentStudioManualEditsToPreview(iframe);
1782
+ return;
1783
+ }
1784
+ if (readFromDiskFirst) {
1785
+ applyCurrentStudioManualEditsToPreview(iframe);
1786
+ }
1787
+ },
1788
+ [applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
1789
+ );
1790
+ applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
1791
+
1792
+ const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
1793
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
1794
+ applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
1795
+ [applyStudioManualEditsToPreview],
1796
+ );
1797
+
1798
+ const commitStudioManualEditManifestOptimistically = useCallback(
1799
+ (
1800
+ updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
1801
+ options: { label: string; coalesceKey: string },
1802
+ ) => {
1803
+ const previousManifest = studioManualEditManifestRef.current;
1804
+ const nextManifest = updateManifest(previousManifest);
1805
+ const previousContent = serializeStudioManualEditManifest(previousManifest);
1806
+ const nextContent = serializeStudioManualEditManifest(nextManifest);
1807
+ if (nextContent === previousContent) {
1808
+ return;
1809
+ }
1810
+
1811
+ const revision = studioManualEditRevisionRef.current + 1;
1812
+ studioManualEditRevisionRef.current = revision;
1813
+ studioManualEditManifestRef.current = nextManifest;
1814
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
1815
+
1816
+ const save = async () => {
1817
+ const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
1818
+ const diskManifest = parseStudioManualEditManifest(originalContent);
1819
+ const nextDiskManifest = updateManifest(diskManifest);
1820
+ const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
1821
+ if (nextDiskContent === originalContent) {
1822
+ return;
1823
+ }
1824
+
1825
+ const pid = projectIdRef.current;
1826
+ if (!pid) throw new Error("No active project");
1827
+ domEditSaveTimestampRef.current = Date.now();
1828
+ await saveProjectFilesWithHistory({
1829
+ projectId: pid,
1830
+ label: options.label,
1831
+ kind: "manual",
1832
+ coalesceKey: options.coalesceKey,
1833
+ files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
1834
+ readFile: async () => originalContent,
1835
+ writeFile: writeProjectFile,
1836
+ recordEdit: editHistory.recordEdit,
1837
+ });
1838
+ domEditSaveTimestampRef.current = Date.now();
1839
+
1840
+ if (studioManualEditRevisionRef.current === revision) {
1841
+ studioManualEditManifestRef.current = nextDiskManifest;
1842
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
1843
+ }
1844
+ };
1845
+
1846
+ void queueDomEditSave(save).catch((error) => {
1847
+ if (studioManualEditRevisionRef.current === revision) {
1848
+ studioManualEditRevisionRef.current += 1;
1849
+ studioManualEditManifestRef.current = previousManifest;
1850
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
1851
+ }
1852
+ const message = error instanceof Error ? error.message : "Failed to save manual edit";
1853
+ showToast(message);
1854
+ });
1855
+ },
1856
+ [
1857
+ applyCurrentStudioManualEditsToPreview,
1858
+ editHistory.recordEdit,
1859
+ queueDomEditSave,
1860
+ readOptionalProjectFile,
1861
+ showToast,
1862
+ writeProjectFile,
1863
+ ],
1864
+ );
1865
+
1866
+ const syncHistoryPreviewAfterApply = useCallback(
1867
+ async (paths: string[] | undefined) => {
1868
+ const changedPaths = paths ?? [];
1869
+ const manualManifestOnly =
1870
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
1871
+
1872
+ if (manualManifestOnly) {
1873
+ await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
1874
+ return;
1875
+ }
1876
+
1877
+ setRefreshKey((key) => key + 1);
1878
+ },
1879
+ [applyStudioManualEditsToPreview],
1880
+ );
1881
+
1609
1882
  const handleUndo = useCallback(async () => {
1883
+ await waitForPendingDomEditSaves();
1610
1884
  const result = await editHistory.undo({
1611
- readFile: readProjectFile,
1612
- writeFile: writeProjectFile,
1885
+ readFile: readHistoryProjectFile,
1886
+ writeFile: writeHistoryProjectFile,
1613
1887
  });
1614
1888
  if (!result.ok && result.reason === "content-mismatch") {
1615
1889
  showToast("File changed outside Studio. Undo history was not applied.", "info");
@@ -1617,15 +1891,24 @@ export function StudioApp() {
1617
1891
  }
1618
1892
  if (result.ok && result.label) {
1619
1893
  clearDomSelection();
1620
- setRefreshKey((key) => key + 1);
1894
+ await syncHistoryPreviewAfterApply(result.paths);
1621
1895
  showToast(`Undid ${result.label}`, "info");
1622
1896
  }
1623
- }, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
1897
+ }, [
1898
+ clearDomSelection,
1899
+ editHistory,
1900
+ readHistoryProjectFile,
1901
+ showToast,
1902
+ syncHistoryPreviewAfterApply,
1903
+ waitForPendingDomEditSaves,
1904
+ writeHistoryProjectFile,
1905
+ ]);
1624
1906
 
1625
1907
  const handleRedo = useCallback(async () => {
1908
+ await waitForPendingDomEditSaves();
1626
1909
  const result = await editHistory.redo({
1627
- readFile: readProjectFile,
1628
- writeFile: writeProjectFile,
1910
+ readFile: readHistoryProjectFile,
1911
+ writeFile: writeHistoryProjectFile,
1629
1912
  });
1630
1913
  if (!result.ok && result.reason === "content-mismatch") {
1631
1914
  showToast("File changed outside Studio. Redo history was not applied.", "info");
@@ -1633,78 +1916,107 @@ export function StudioApp() {
1633
1916
  }
1634
1917
  if (result.ok && result.label) {
1635
1918
  clearDomSelection();
1636
- setRefreshKey((key) => key + 1);
1919
+ await syncHistoryPreviewAfterApply(result.paths);
1637
1920
  showToast(`Redid ${result.label}`, "info");
1638
1921
  }
1639
- }, [clearDomSelection, editHistory, readProjectFile, showToast, writeProjectFile]);
1922
+ }, [
1923
+ clearDomSelection,
1924
+ editHistory,
1925
+ readHistoryProjectFile,
1926
+ showToast,
1927
+ syncHistoryPreviewAfterApply,
1928
+ waitForPendingDomEditSaves,
1929
+ writeHistoryProjectFile,
1930
+ ]);
1640
1931
 
1641
1932
  const handleUndoRef = useRef(handleUndo);
1642
1933
  const handleRedoRef = useRef(handleRedo);
1643
1934
  handleUndoRef.current = handleUndo;
1644
1935
  handleRedoRef.current = handleRedo;
1645
1936
 
1937
+ const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
1938
+ if (!(event.metaKey || event.ctrlKey)) return;
1939
+ if (shouldIgnoreHistoryShortcut(event.target)) return;
1940
+ const key = event.key.toLowerCase();
1941
+ if (key === "z" && !event.shiftKey) {
1942
+ event.preventDefault();
1943
+ void handleUndoRef.current();
1944
+ return;
1945
+ }
1946
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
1947
+ event.preventDefault();
1948
+ void handleRedoRef.current();
1949
+ }
1950
+ }, []);
1951
+
1646
1952
  // eslint-disable-next-line no-restricted-syntax
1647
1953
  useEffect(() => {
1648
- const handler = (event: KeyboardEvent) => {
1649
- if (!(event.metaKey || event.ctrlKey)) return;
1650
- if (shouldIgnoreHistoryShortcut(event.target)) return;
1651
- const key = event.key.toLowerCase();
1652
- if (key === "z" && !event.shiftKey) {
1653
- event.preventDefault();
1654
- void handleUndoRef.current();
1655
- return;
1656
- }
1657
- if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
1658
- event.preventDefault();
1659
- void handleRedoRef.current();
1954
+ window.addEventListener("keydown", handleHistoryHotkey, true);
1955
+ return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
1956
+ }, [handleHistoryHotkey]);
1957
+
1958
+ const syncPreviewHistoryHotkey = useCallback(
1959
+ (iframe: HTMLIFrameElement | null) => {
1960
+ previewHistoryHotkeyCleanupRef.current?.();
1961
+ previewHistoryHotkeyCleanupRef.current = null;
1962
+
1963
+ const win = iframe?.contentWindow ?? null;
1964
+ let doc: Document | null = null;
1965
+ try {
1966
+ doc = iframe?.contentDocument ?? null;
1967
+ } catch {
1968
+ doc = null;
1660
1969
  }
1661
- };
1662
- window.addEventListener("keydown", handler);
1663
- return () => window.removeEventListener("keydown", handler);
1664
- }, []);
1970
+ if (!win && !doc) return;
1971
+
1972
+ win?.addEventListener("keydown", handleHistoryHotkey, true);
1973
+ doc?.addEventListener("keydown", handleHistoryHotkey, true);
1974
+ previewHistoryHotkeyCleanupRef.current = () => {
1975
+ win?.removeEventListener("keydown", handleHistoryHotkey, true);
1976
+ doc?.removeEventListener("keydown", handleHistoryHotkey, true);
1977
+ };
1978
+ },
1979
+ [handleHistoryHotkey],
1980
+ );
1981
+
1982
+ useEffect(
1983
+ () => () => {
1984
+ previewHistoryHotkeyCleanupRef.current?.();
1985
+ previewHistoryHotkeyCleanupRef.current = null;
1986
+ },
1987
+ [],
1988
+ );
1665
1989
 
1666
1990
  const buildDomSelectionFromTarget = useCallback(
1667
1991
  (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
1668
- if (isMasterView) {
1669
- const mappedHost = findMappedCompositionHost(
1670
- target,
1671
- timelineElements,
1672
- compIdToSrc,
1673
- fileTree,
1674
- );
1675
- if (mappedHost) {
1676
- const hostSelection = resolveDomEditSelection(mappedHost.host, {
1677
- activeCompositionPath: activeCompPath,
1678
- isMasterView,
1679
- preferClipAncestor: options?.preferClipAncestor,
1680
- });
1681
- if (!hostSelection) return null;
1682
- return {
1683
- ...hostSelection,
1684
- compositionSrc: mappedHost.compositionSrc,
1685
- isCompositionHost: true,
1686
- capabilities: resolveDomEditCapabilities({
1687
- selector: hostSelection.selector,
1688
- tagName: hostSelection.tagName,
1689
- className: hostSelection.element.className,
1690
- inlineStyles: hostSelection.inlineStyles,
1691
- computedStyles: hostSelection.computedStyles,
1692
- isCompositionHost: true,
1693
- isMasterView: true,
1694
- }),
1695
- } satisfies DomEditSelection;
1696
- }
1697
- }
1698
-
1699
1992
  return resolveDomEditSelection(target, {
1700
1993
  activeCompositionPath: activeCompPath,
1701
1994
  isMasterView,
1702
1995
  preferClipAncestor: options?.preferClipAncestor,
1703
1996
  });
1704
1997
  },
1705
- [activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
1998
+ [activeCompPath, isMasterView],
1706
1999
  );
1707
2000
 
2001
+ const resolveDomSelectionFromPreviewPoint = useCallback(
2002
+ (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2003
+ const iframe = previewIframeRef.current;
2004
+ if (!iframe || captionEditMode) return null;
2005
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2006
+ if (!target) return null;
2007
+ return buildDomSelectionFromTarget(target, {
2008
+ preferClipAncestor: options?.preferClipAncestor,
2009
+ });
2010
+ },
2011
+ [buildDomSelectionFromTarget, captionEditMode],
2012
+ );
2013
+
2014
+ const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
2015
+ if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
2016
+ domEditHoverSelectionRef.current = selection;
2017
+ setDomEditHoverSelection(selection);
2018
+ }, []);
2019
+
1708
2020
  const preloadAgentPromptSnippet = useCallback(
1709
2021
  async (selection: DomEditSelection) => {
1710
2022
  const pid = projectIdRef.current;
@@ -1817,110 +2129,183 @@ export function StudioApp() {
1817
2129
  [activeCompPath, editHistory.recordEdit, writeProjectFile],
1818
2130
  );
1819
2131
 
1820
- const handleDomMoveCommit = useCallback(
1821
- async (selection: DomEditSelection, next: { left: number; top: number }) => {
1822
- await persistDomEditOperations(
1823
- selection,
1824
- [
1825
- ...buildDomEditMovePatchOperations(next.left, next.top),
1826
- ...buildOppositeEdgePatchOperations(selection, "both"),
1827
- ],
2132
+ const refreshDomEditSelectionFromPreview = useCallback(
2133
+ (selection: DomEditSelection) => {
2134
+ const iframe = previewIframeRef.current;
2135
+ let doc: Document | null = null;
2136
+ try {
2137
+ doc = iframe?.contentDocument ?? null;
2138
+ } catch {
2139
+ return;
2140
+ }
2141
+ if (!doc) return;
2142
+
2143
+ const element = findElementForSelection(doc, selection, activeCompPath);
2144
+ if (!element) return;
2145
+
2146
+ const nextSelection = buildDomSelectionFromTarget(element);
2147
+ if (nextSelection) {
2148
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2149
+ }
2150
+ },
2151
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
2152
+ );
2153
+
2154
+ const refreshDomEditGroupSelectionsFromPreview = useCallback(
2155
+ (selections: DomEditSelection[]) => {
2156
+ const iframe = previewIframeRef.current;
2157
+ let doc: Document | null = null;
2158
+ try {
2159
+ doc = iframe?.contentDocument ?? null;
2160
+ } catch {
2161
+ return;
2162
+ }
2163
+ if (!doc) return;
2164
+
2165
+ const nextGroup: DomEditSelection[] = [];
2166
+ for (const selection of selections) {
2167
+ const element = findElementForSelection(doc, selection, activeCompPath);
2168
+ if (!element) continue;
2169
+ const nextSelection = buildDomSelectionFromTarget(element);
2170
+ if (nextSelection) nextGroup.push(nextSelection);
2171
+ }
2172
+ if (nextGroup.length === 0) return;
2173
+
2174
+ const currentSelection = domEditSelectionRef.current;
2175
+ const nextSelection =
2176
+ nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
2177
+ nextGroup[0] ??
2178
+ null;
2179
+
2180
+ setAgentPromptTagSnippet(undefined);
2181
+ setCopiedAgentPrompt(false);
2182
+ domEditSelectionRef.current = nextSelection;
2183
+ domEditGroupSelectionsRef.current = nextGroup;
2184
+ setDomEditSelection(nextSelection);
2185
+ setDomEditGroupSelections(nextGroup);
2186
+
2187
+ if (nextSelection) {
2188
+ setSelectedTimelineElementId(
2189
+ findMatchingTimelineElementId(nextSelection, timelineElements),
2190
+ );
2191
+ } else {
2192
+ setSelectedTimelineElementId(null);
2193
+ }
2194
+ },
2195
+ [activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
2196
+ );
2197
+
2198
+ const handleDomManualDragStart = useCallback(() => {
2199
+ const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
2200
+ const playerStore = usePlayerStore.getState();
2201
+ playerStore.setIsPlaying(false);
2202
+ if (pausedTime != null) {
2203
+ playerStore.setCurrentTime(pausedTime);
2204
+ liveTime.notify(pausedTime);
2205
+ }
2206
+ }, []);
2207
+
2208
+ const handleDomPathOffsetCommit = useCallback(
2209
+ (selection: DomEditSelection, next: { x: number; y: number }) => {
2210
+ commitStudioManualEditManifestOptimistically(
2211
+ (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
1828
2212
  {
1829
- skipRefresh: true,
1830
2213
  label: "Move layer",
1831
- coalesceKey: getDomEditCoalesceKey(selection, "move"),
2214
+ coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
1832
2215
  },
1833
2216
  );
2217
+ refreshDomEditSelectionFromPreview(selection);
1834
2218
  },
1835
- [persistDomEditOperations],
2219
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
1836
2220
  );
1837
2221
 
1838
- const handleDomResizeCommit = useCallback(
1839
- async (selection: DomEditSelection, next: { width: number; height: number }) => {
1840
- if (shouldDetachOppositeEdges(selection)) {
1841
- selection.element.style.right = "auto";
1842
- selection.element.style.bottom = "auto";
1843
- }
1844
- await persistDomEditOperations(
1845
- selection,
1846
- [
1847
- ...buildDomEditResizePatchOperations(next.width, next.height),
1848
- ...buildOppositeEdgePatchOperations(selection, "both"),
1849
- ],
2222
+ const handleDomGroupPathOffsetCommit = useCallback(
2223
+ (updates: DomEditGroupPathOffsetCommit[]) => {
2224
+ if (updates.length === 0) return;
2225
+ const coalesceKey = updates
2226
+ .map((update) => getDomEditTargetKey(update.selection))
2227
+ .sort()
2228
+ .join(":");
2229
+ commitStudioManualEditManifestOptimistically(
2230
+ (manifest) =>
2231
+ updates.reduce(
2232
+ (nextManifest, update) =>
2233
+ upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
2234
+ manifest,
2235
+ ),
1850
2236
  {
1851
- skipRefresh: true,
1852
- label: "Resize layer",
1853
- coalesceKey: getDomEditCoalesceKey(selection, "resize"),
2237
+ label: `Move ${updates.length} layers`,
2238
+ coalesceKey: `group-path-offset:${coalesceKey}`,
1854
2239
  },
1855
2240
  );
2241
+ refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
1856
2242
  },
1857
- [persistDomEditOperations],
2243
+ [commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
1858
2244
  );
1859
2245
 
1860
- const handleDomDetachFromLayout = useCallback(async () => {
1861
- const selection = domEditSelection;
1862
- if (!selection?.capabilities.canDetachFromLayout) return;
1863
-
1864
- const doc = previewIframeRef.current?.contentDocument;
1865
- const element = doc
1866
- ? findElementForSelection(doc, selection, selection.sourceFile)
1867
- : selection.element;
1868
- if (!element) {
1869
- showToast("Could not find the selected layer in the preview.", "info");
1870
- return;
1871
- }
1872
-
1873
- const rect = measureDomDetachRect(element);
1874
- const operations = buildDomEditDetachPatchOperations(rect);
1875
-
1876
- for (const operation of operations) {
1877
- element.style.setProperty(operation.property, operation.value);
1878
- }
2246
+ const handleDomBoxSizeCommit = useCallback(
2247
+ (selection: DomEditSelection, next: { width: number; height: number }) => {
2248
+ commitStudioManualEditManifestOptimistically(
2249
+ (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
2250
+ {
2251
+ label: "Resize layer box",
2252
+ coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
2253
+ },
2254
+ );
2255
+ refreshDomEditSelectionFromPreview(selection);
2256
+ },
2257
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2258
+ );
1879
2259
 
1880
- await persistDomEditOperations(selection, operations, {
1881
- skipRefresh: true,
1882
- label: "Make layer movable",
1883
- });
2260
+ const handleDomRotationCommit = useCallback(
2261
+ (selection: DomEditSelection, next: { angle: number }) => {
2262
+ commitStudioManualEditManifestOptimistically(
2263
+ (manifest) => upsertStudioRotationEdit(manifest, selection, next),
2264
+ {
2265
+ label: "Rotate layer",
2266
+ coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
2267
+ },
2268
+ );
2269
+ refreshDomEditSelectionFromPreview(selection);
2270
+ },
2271
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2272
+ );
1884
2273
 
1885
- const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
1886
- if (refreshed) {
1887
- const nextSelection = buildDomSelectionFromTarget(refreshed);
1888
- if (nextSelection) {
1889
- applyDomSelection(nextSelection, { revealPanel: false });
1890
- }
1891
- }
1892
- showToast("Layer detached from layout. You can move it now.", "info");
1893
- }, [
1894
- applyDomSelection,
1895
- buildDomSelectionFromTarget,
1896
- domEditSelection,
1897
- persistDomEditOperations,
1898
- showToast,
1899
- ]);
2274
+ const handleDomManualEditsReset = useCallback(
2275
+ (selection: DomEditSelection) => {
2276
+ commitStudioManualEditManifestOptimistically(
2277
+ (manifest) => removeStudioManualEditsForSelection(manifest, selection),
2278
+ {
2279
+ label: "Reset layer edits",
2280
+ coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
2281
+ },
2282
+ );
2283
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2284
+ refreshDomEditSelectionFromPreview(selection);
2285
+ },
2286
+ [
2287
+ applyCurrentStudioManualEditsToPreview,
2288
+ commitStudioManualEditManifestOptimistically,
2289
+ refreshDomEditSelectionFromPreview,
2290
+ ],
2291
+ );
1900
2292
 
1901
2293
  const handleDomStyleCommit = useCallback(
1902
2294
  async (property: string, value: string) => {
1903
2295
  if (!domEditSelection) return;
1904
- const isMoveStyle = isMoveStyleProperty(property);
1905
- const isResizeStyle = isResizeStyleProperty(property);
1906
- if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
1907
- if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
1908
- if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
2296
+ if (isManualGeometryStyleProperty(property)) return;
2297
+ if (!domEditSelection.capabilities.canEditStyles) return;
1909
2298
  const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1910
2299
  const iframe = previewIframeRef.current;
1911
2300
  const doc = iframe?.contentDocument;
1912
2301
  if (doc) {
1913
- const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
2302
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
1914
2303
  if (el) {
1915
2304
  el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
1916
2305
  if (property === "font-family") {
1917
2306
  injectPreviewGoogleFont(doc, value);
1918
2307
  if (importedFont) injectPreviewImportedFont(doc, importedFont);
1919
2308
  }
1920
- if (shouldDetachOppositeEdges(domEditSelection)) {
1921
- if (property === "width") el.style.right = "auto";
1922
- if (property === "height") el.style.bottom = "auto";
1923
- }
1924
2309
  if (property === "background-image" && isImageBackgroundValue(value)) {
1925
2310
  el.style.setProperty("background-position", "center");
1926
2311
  el.style.setProperty("background-repeat", "no-repeat");
@@ -1931,11 +2316,7 @@ export function StudioApp() {
1931
2316
  const operations: PatchOperation[] = [
1932
2317
  buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
1933
2318
  ];
1934
- if (property === "width") {
1935
- operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
1936
- } else if (property === "height") {
1937
- operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
1938
- } else if (property === "background-image" && isImageBackgroundValue(value)) {
2319
+ if (property === "background-image" && isImageBackgroundValue(value)) {
1939
2320
  operations.push(
1940
2321
  buildDomEditStylePatchOperation("background-position", "center"),
1941
2322
  buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
@@ -1950,7 +2331,7 @@ export function StudioApp() {
1950
2331
  : undefined,
1951
2332
  });
1952
2333
  },
1953
- [domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
2334
+ [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
1954
2335
  );
1955
2336
 
1956
2337
  const handleDomTextCommit = useCallback(
@@ -1972,7 +2353,7 @@ export function StudioApp() {
1972
2353
  const iframe = previewIframeRef.current;
1973
2354
  const doc = iframe?.contentDocument;
1974
2355
  if (doc) {
1975
- const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
2356
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
1976
2357
  if (el) {
1977
2358
  if (
1978
2359
  nextTextFields.length > 1 ||
@@ -1996,20 +2377,22 @@ export function StudioApp() {
1996
2377
  if (domTextCommitVersionRef.current !== commitVersion) return;
1997
2378
 
1998
2379
  if (doc) {
1999
- const refreshed = findElementForSelection(
2000
- doc,
2001
- domEditSelection,
2002
- domEditSelection.sourceFile,
2003
- );
2380
+ const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
2004
2381
  if (refreshed) {
2005
2382
  const nextSelection = buildDomSelectionFromTarget(refreshed);
2006
2383
  if (nextSelection) {
2007
- applyDomSelection(nextSelection, { revealPanel: false });
2384
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2008
2385
  }
2009
2386
  }
2010
2387
  }
2011
2388
  },
2012
- [applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
2389
+ [
2390
+ activeCompPath,
2391
+ applyDomSelection,
2392
+ buildDomSelectionFromTarget,
2393
+ domEditSelection,
2394
+ persistDomEditOperations,
2395
+ ],
2013
2396
  );
2014
2397
 
2015
2398
  const commitDomTextFields = useCallback(
@@ -2026,7 +2409,7 @@ export function StudioApp() {
2026
2409
  const iframe = previewIframeRef.current;
2027
2410
  const doc = iframe?.contentDocument;
2028
2411
  if (doc) {
2029
- const el = findElementForSelection(doc, selection, selection.sourceFile);
2412
+ const el = findElementForSelection(doc, selection, activeCompPath);
2030
2413
  if (el) {
2031
2414
  if (
2032
2415
  nextTextFields.length > 1 ||
@@ -2049,16 +2432,16 @@ export function StudioApp() {
2049
2432
  });
2050
2433
 
2051
2434
  if (doc) {
2052
- const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
2435
+ const refreshed = findElementForSelection(doc, selection, activeCompPath);
2053
2436
  if (refreshed) {
2054
2437
  const nextSelection = buildDomSelectionFromTarget(refreshed);
2055
2438
  if (nextSelection) {
2056
- applyDomSelection(nextSelection, { revealPanel: false });
2439
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2057
2440
  }
2058
2441
  }
2059
2442
  }
2060
2443
  },
2061
- [applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
2444
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
2062
2445
  );
2063
2446
 
2064
2447
  const handleDomTextFieldStyleCommit = useCallback(
@@ -2184,84 +2567,93 @@ export function StudioApp() {
2184
2567
  previewIframeRef.current = iframe;
2185
2568
  setPreviewIframe(iframe);
2186
2569
  syncPreviewTimelineHotkey(iframe);
2570
+ syncPreviewHistoryHotkey(iframe);
2187
2571
  consoleErrorsRef.current = [];
2188
2572
  setConsoleErrors(null);
2189
2573
  },
2190
- [syncPreviewTimelineHotkey],
2574
+ [syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
2191
2575
  );
2192
2576
 
2193
2577
  const handlePreviewCanvasMouseDown = useCallback(
2194
2578
  (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
2195
- const iframe = previewIframeRef.current;
2196
- if (!iframe || captionEditMode) return;
2197
- const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
2198
- if (!target) {
2199
- lastPreviewClickRef.current = null;
2200
- applyDomSelection(null, { revealPanel: false });
2201
- return;
2202
- }
2203
- e.preventDefault();
2204
- e.stopPropagation();
2205
- const nextSelection = buildDomSelectionFromTarget(target, {
2579
+ if (captionEditMode) return;
2580
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
2206
2581
  preferClipAncestor: options?.preferClipAncestor ?? true,
2207
2582
  });
2208
2583
  if (!nextSelection) {
2209
- lastPreviewClickRef.current = null;
2210
- applyDomSelection(null, { revealPanel: false });
2584
+ if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
2211
2585
  return;
2212
2586
  }
2213
- if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
2214
- const key = getDomSelectionClickKey(nextSelection);
2215
- const last = lastPreviewClickRef.current;
2216
- const now = Date.now();
2217
- if (last && last.key === key && now - last.at < 350) {
2218
- lastPreviewClickRef.current = null;
2219
- applyDomSelection(null, { revealPanel: false });
2220
- setActiveCompPath(nextSelection.compositionSrc);
2221
- return;
2222
- }
2223
- lastPreviewClickRef.current = { key, at: now };
2224
- } else {
2225
- lastPreviewClickRef.current = null;
2226
- }
2227
- applyDomSelection(nextSelection);
2587
+ e.preventDefault();
2588
+ e.stopPropagation();
2589
+ applyDomSelection(nextSelection, { additive: e.shiftKey });
2228
2590
  },
2229
- [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2591
+ [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
2230
2592
  );
2231
2593
 
2232
- const handlePreviewCanvasDoubleClick = useCallback(
2233
- (e: React.MouseEvent<HTMLDivElement>) => {
2234
- const iframe = previewIframeRef.current;
2235
- if (!iframe || captionEditMode) return;
2236
- const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
2237
- if (!target) return;
2238
- const nextSelection = buildDomSelectionFromTarget(target, {
2239
- preferClipAncestor: false,
2240
- });
2241
- if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
2242
- return;
2594
+ const handlePreviewCanvasPointerMove = useCallback(
2595
+ (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
2596
+ if (captionEditMode) {
2597
+ updateDomEditHoverSelection(null);
2598
+ return null;
2243
2599
  }
2244
- e.preventDefault();
2245
- e.stopPropagation();
2246
- lastPreviewClickRef.current = null;
2247
- applyDomSelection(null, { revealPanel: false });
2248
- setActiveCompPath(nextSelection.compositionSrc);
2600
+
2601
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
2602
+ preferClipAncestor: options?.preferClipAncestor ?? false,
2603
+ });
2604
+ updateDomEditHoverSelection(nextSelection);
2605
+ return nextSelection;
2249
2606
  },
2250
- [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2607
+ [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
2251
2608
  );
2252
2609
 
2253
- const handleSelectedOverlayDoubleClick = useCallback(() => {
2254
- const selection = domEditSelectionRef.current;
2255
- if (!selection?.isCompositionHost || !selection.compositionSrc) return;
2256
- applyDomSelection(null, { revealPanel: false });
2257
- setActiveCompPath(selection.compositionSrc);
2258
- }, [applyDomSelection]);
2610
+ const handlePreviewCanvasPointerLeave = useCallback(() => {
2611
+ updateDomEditHoverSelection(null);
2612
+ }, [updateDomEditHoverSelection]);
2613
+
2614
+ // eslint-disable-next-line no-restricted-syntax
2615
+ useEffect(() => {
2616
+ if (captionEditMode) updateDomEditHoverSelection(null);
2617
+ }, [captionEditMode, updateDomEditHoverSelection]);
2618
+
2619
+ // eslint-disable-next-line no-restricted-syntax
2620
+ useEffect(() => {
2621
+ updateDomEditHoverSelection(null);
2622
+ }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
2623
+
2624
+ // eslint-disable-next-line no-restricted-syntax
2625
+ useEffect(() => {
2626
+ if (!domEditHoverSelection) return;
2627
+ const hoverMatchesSelection = domEditSelectionsTargetSame(
2628
+ domEditHoverSelection,
2629
+ domEditSelection,
2630
+ );
2631
+ const hoverMatchesGroup = domEditSelectionInGroup(
2632
+ domEditGroupSelections,
2633
+ domEditHoverSelection,
2634
+ );
2635
+ if (!hoverMatchesSelection && !hoverMatchesGroup) return;
2636
+ updateDomEditHoverSelection(null);
2637
+ }, [
2638
+ domEditGroupSelections,
2639
+ domEditHoverSelection,
2640
+ domEditSelection,
2641
+ updateDomEditHoverSelection,
2642
+ ]);
2259
2643
 
2260
2644
  // eslint-disable-next-line no-restricted-syntax
2261
2645
  useEffect(() => {
2262
- if (!previewIframe || captionEditMode) return;
2646
+ if (!domEditHoverSelection) return;
2647
+ if (domEditHoverSelection.element.isConnected) return;
2648
+ updateDomEditHoverSelection(null);
2649
+ }, [domEditHoverSelection, updateDomEditHoverSelection]);
2650
+
2651
+ // eslint-disable-next-line no-restricted-syntax
2652
+ useEffect(() => {
2653
+ if (!previewIframe) return;
2263
2654
 
2264
2655
  const syncSelectionFromDocument = () => {
2656
+ if (captionEditMode) return;
2265
2657
  const currentSelection = domEditSelectionRef.current;
2266
2658
  if (!currentSelection) return;
2267
2659
  let doc: Document | null = null;
@@ -2280,7 +2672,7 @@ export function StudioApp() {
2280
2672
 
2281
2673
  const nextSelection = buildDomSelectionFromTarget(nextElement);
2282
2674
  if (nextSelection) {
2283
- applyDomSelection(nextSelection, { revealPanel: false });
2675
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2284
2676
  }
2285
2677
  };
2286
2678
 
@@ -2315,12 +2707,16 @@ export function StudioApp() {
2315
2707
  };
2316
2708
 
2317
2709
  attachErrorCapture();
2710
+ syncPreviewHistoryHotkey(previewIframe);
2711
+ void applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
2318
2712
  syncSelectionFromDocument();
2319
2713
 
2320
2714
  const handleLoad = () => {
2321
2715
  consoleErrorsRef.current = [];
2322
2716
  setConsoleErrors(null);
2323
2717
  attachErrorCapture();
2718
+ syncPreviewHistoryHotkey(previewIframe);
2719
+ void applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
2324
2720
  syncSelectionFromDocument();
2325
2721
  };
2326
2722
 
@@ -2331,9 +2727,11 @@ export function StudioApp() {
2331
2727
  }, [
2332
2728
  activeCompPath,
2333
2729
  applyDomSelection,
2730
+ applyStudioManualEditsToPreviewAfterRefresh,
2334
2731
  buildDomSelectionFromTarget,
2335
2732
  captionEditMode,
2336
2733
  previewIframe,
2734
+ syncPreviewHistoryHotkey,
2337
2735
  ]);
2338
2736
 
2339
2737
  // eslint-disable-next-line no-restricted-syntax
@@ -3016,16 +3414,25 @@ export function StudioApp() {
3016
3414
  ) : (
3017
3415
  <DomEditOverlay
3018
3416
  iframeRef={previewIframeRef}
3417
+ activeCompositionPath={activeCompPath}
3418
+ hoverSelection={captionEditMode ? null : domEditHoverSelection}
3019
3419
  selection={
3020
3420
  !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
3021
3421
  }
3022
- allowCanvasMovement={false}
3422
+ groupSelections={
3423
+ !rightCollapsed && rightPanelTab === "design" ? domEditGroupSelections : []
3424
+ }
3425
+ allowCanvasMovement
3023
3426
  onCanvasMouseDown={handlePreviewCanvasMouseDown}
3024
- onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
3025
- onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
3427
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
3428
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
3429
+ onSelectionChange={applyDomSelection}
3026
3430
  onBlockedMove={handleBlockedDomMove}
3027
- onMoveCommit={handleDomMoveCommit}
3028
- onResizeCommit={handleDomResizeCommit}
3431
+ onManualDragStart={handleDomManualDragStart}
3432
+ onPathOffsetCommit={handleDomPathOffsetCommit}
3433
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
3434
+ onBoxSizeCommit={handleDomBoxSizeCommit}
3435
+ onRotationCommit={handleDomRotationCommit}
3029
3436
  />
3030
3437
  )
3031
3438
  }
@@ -3100,21 +3507,21 @@ export function StudioApp() {
3100
3507
  <PropertyPanel
3101
3508
  projectId={projectId}
3102
3509
  assets={assets}
3103
- element={domEditSelection}
3510
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
3104
3511
  copiedAgentPrompt={copiedAgentPrompt}
3105
3512
  onClearSelection={clearDomSelection}
3106
3513
  onSetStyle={handleDomStyleCommit}
3514
+ onSetManualOffset={handleDomPathOffsetCommit}
3515
+ onSetManualSize={handleDomBoxSizeCommit}
3107
3516
  onSetText={handleDomTextCommit}
3108
3517
  onSetTextFieldStyle={handleDomTextFieldStyleCommit}
3109
3518
  onAddTextField={handleDomAddTextField}
3110
3519
  onRemoveTextField={handleDomRemoveTextField}
3111
- onDetachFromLayout={handleDomDetachFromLayout}
3520
+ onResetManualEdits={handleDomManualEditsReset}
3112
3521
  onAskAgent={handleAskAgent}
3113
- onCopyAgentInstruction={handleAgentModalSubmit}
3114
3522
  onImportAssets={handleImportFiles}
3115
3523
  fontAssets={fontAssets}
3116
3524
  onImportFonts={handleImportFonts}
3117
- allowLayoutDetach={false}
3118
3525
  />
3119
3526
  ) : (
3120
3527
  <RenderQueue
@@ -3122,9 +3529,10 @@ export function StudioApp() {
3122
3529
  projectId={projectId}
3123
3530
  onDelete={renderQueue.deleteRender}
3124
3531
  onClearCompleted={renderQueue.clearCompleted}
3125
- onStartRender={(format, quality) =>
3126
- renderQueue.startRender(30, quality, format)
3127
- }
3532
+ onStartRender={async (format, quality) => {
3533
+ await waitForPendingDomEditSaves();
3534
+ await renderQueue.startRender(30, quality, format);
3535
+ }}
3128
3536
  isRendering={renderQueue.isRendering}
3129
3537
  />
3130
3538
  )}