@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -30,6 +30,26 @@ import {
30
30
  } from "../utils/authoredSource";
31
31
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
32
32
 
33
+ type InPlace = { inPlace?: boolean };
34
+
35
+ const pasteStart = (
36
+ payload: Extract<ClipboardPayload, { kind: "timeline-clip" }>,
37
+ targetPath: string,
38
+ playhead: number,
39
+ options?: InPlace,
40
+ ) =>
41
+ options?.inPlace && payload.copiedInComposition === targetPath
42
+ ? Math.min(...payload.clips.map((clip) => clip.start))
43
+ : playhead;
44
+
45
+ const pasteWords = (payload: ClipboardPayload) =>
46
+ payload.kind === "timeline-clip"
47
+ ? {
48
+ label: clipLabel("Paste", payload.clips.length),
49
+ toast: clipToast("Pasted", payload.clips.length),
50
+ }
51
+ : { label: "Paste element", toast: "Pasted element" };
52
+
33
53
  interface RecordEditInput {
34
54
  label: string;
35
55
  coalesceKey?: string;
@@ -313,6 +333,7 @@ export function useClipboard({
313
333
  clips,
314
334
  sourceFile,
315
335
  projectId: projectIdRef.current ?? undefined,
336
+ copiedInComposition: activeCompPath || "index.html",
316
337
  };
317
338
  });
318
339
  }, [activeCompPath, findSelectedClips, readClips, showToast]);
@@ -369,136 +390,136 @@ export function useClipboard({
369
390
 
370
391
  // Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
371
392
  // fallow-ignore-next-line complexity
372
- const handlePaste = useCallback(async () => {
373
- const payload = await clipboardRef.current;
374
- if (!payload) {
375
- showToast("Nothing to paste.", "info");
376
- return;
377
- }
378
- const pid = projectIdRef.current;
379
- if (!pid) return;
380
-
381
- const targetPath = activeCompPath || "index.html";
382
- try {
383
- let pastedIds: string[] = [];
384
- const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
385
- const paste = (originalContent: string) => {
386
- if (payload.kind !== "timeline-clip")
387
- return pasteElementHtml(originalContent, payload, fromThisFile);
388
- const { currentTime, elements } = usePlayerStore.getState();
389
- const pasted = pasteTimelineClips(
390
- originalContent,
391
- payload.clips,
392
- currentTime,
393
- elements,
394
- fromThisFile,
395
- );
396
- pastedIds = pasted.ids;
397
- // A clip pasted past the current composition end would exist in the
398
- // file but never appear on the timeline or in playback/export (the
399
- // root's data-duration is what actually bounds the render).
400
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
401
- };
402
-
403
- const label =
404
- payload.kind === "timeline-clip"
405
- ? clipLabel("Paste", payload.clips.length)
406
- : "Paste element";
407
-
408
- await saveProjectFilesWithHistory({
409
- projectId: pid,
410
- label,
411
- files: { [targetPath]: paste },
412
- readFile: (path) => readFileContent(pid, path),
413
- writeFile: writeProjectFile,
414
- recordEdit,
415
- });
416
-
417
- // CapCut: the pasted clip(s) become the selection; the playhead does not
418
- // move. reloadPreview is a bare refresh-key bump with no selection
419
- // snapshot of its own; calling setSelection before it is what makes the
420
- // post-reload store state land on the pasted ids instead of stale ones.
421
- if (pastedIds.length > 0) {
422
- usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
393
+ const handlePaste = useCallback(
394
+ async (options?: InPlace) => {
395
+ const payload = await clipboardRef.current;
396
+ if (!payload) {
397
+ showToast("Nothing to paste.", "info");
398
+ return;
423
399
  }
424
- reloadPreview();
425
- showToast(
426
- payload.kind === "timeline-clip"
427
- ? clipToast("Pasted", payload.clips.length)
428
- : "Pasted element",
429
- "info",
430
- );
431
- } catch (error) {
432
- const message = error instanceof Error ? error.message : "Failed to paste";
433
- showToast(message);
434
- }
435
- }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
400
+ const pid = projectIdRef.current;
401
+ if (!pid) return;
402
+
403
+ const targetPath = activeCompPath || "index.html";
404
+ try {
405
+ let pastedIds: string[] = [];
406
+ const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
407
+ const paste = (originalContent: string) => {
408
+ if (payload.kind !== "timeline-clip")
409
+ return pasteElementHtml(originalContent, payload, fromThisFile);
410
+ const { currentTime, elements } = usePlayerStore.getState();
411
+ const pasted = pasteTimelineClips(
412
+ originalContent,
413
+ payload.clips,
414
+ pasteStart(payload, targetPath, currentTime, options),
415
+ elements,
416
+ fromThisFile,
417
+ );
418
+ pastedIds = pasted.ids;
419
+ // A clip pasted past the current composition end would exist in the
420
+ // file but never appear on the timeline or in playback/export (the
421
+ // root's data-duration is what actually bounds the render).
422
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
423
+ };
436
424
 
437
- // Duplicates the current selection in place, immediately after it, without
438
- // touching the clipboard — a pending copy must survive a Cmd+D. Shares
439
- // pasteTimelineClips with handlePaste; only the anchor and clip source
440
- // differ (the selection's own end here, the playhead there).
441
- const handleDuplicate = useCallback(async (): Promise<boolean> => {
442
- const targets = findSelectedClips();
443
- if (!targets) return false;
444
- const pid = projectIdRef.current;
445
- if (!pid) return false;
446
-
447
- const { elements } = targets;
448
- const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
449
- const targetPath = pathOf(elements[0]!);
450
- // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
451
- const anchorTime = toAuthoredStart(
452
- elements[0]!,
453
- Math.max(...elements.map((el) => el.start + el.duration)),
454
- );
425
+ const { label, toast } = pasteWords(payload);
426
+
427
+ await saveProjectFilesWithHistory({
428
+ projectId: pid,
429
+ label,
430
+ files: { [targetPath]: paste },
431
+ readFile: (path) => readFileContent(pid, path),
432
+ writeFile: writeProjectFile,
433
+ recordEdit,
434
+ });
435
+
436
+ // CapCut: the pasted clip(s) become the selection; the playhead does not
437
+ // move. reloadPreview is a bare refresh-key bump with no selection
438
+ // snapshot of its own; calling setSelection before it is what makes the
439
+ // post-reload store state land on the pasted ids instead of stale ones.
440
+ if (pastedIds.length > 0) {
441
+ usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
442
+ }
443
+ reloadPreview();
444
+ showToast(toast, "info");
445
+ } catch (error) {
446
+ const message = error instanceof Error ? error.message : "Failed to paste";
447
+ showToast(message);
448
+ }
449
+ },
450
+ [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile],
451
+ );
455
452
 
456
- try {
457
- const clips = await readClips(targets);
458
- let ids: string[] = [];
459
- const duplicate = (originalContent: string) => {
460
- const liveElements = usePlayerStore
461
- .getState()
462
- .elements.filter((el) => pathOf(el) === targetPath)
463
- .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
464
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
465
- ids = pasted.ids;
466
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
467
- };
453
+ // Duplicates the selection right after it, or with `inPlace` at its own time on a track of its own,
454
+ // without touching the clipboard — a pending copy must survive a Cmd+D. Shares
455
+ // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the
456
+ // selection's end or start here, the playhead or the copy's own time there).
457
+ const handleDuplicate = useCallback(
458
+ async (options?: InPlace): Promise<boolean> => {
459
+ const targets = findSelectedClips();
460
+ if (!targets) return false;
461
+ const pid = projectIdRef.current;
462
+ if (!pid) return false;
463
+
464
+ const { elements } = targets;
465
+ const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
466
+ const targetPath = pathOf(elements[0]!);
467
+ // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
468
+ const anchorTime = toAuthoredStart(
469
+ elements[0]!,
470
+ options?.inPlace
471
+ ? Math.min(...elements.map((el) => el.start))
472
+ : Math.max(...elements.map((el) => el.start + el.duration)),
473
+ );
468
474
 
469
- await saveProjectFilesWithHistory({
470
- projectId: pid,
471
- label: clipLabel("Duplicate", clips.length),
472
- files: { [targetPath]: duplicate },
473
- readFile: (path) => readFileContent(pid, path),
474
- writeFile: writeProjectFile,
475
- recordEdit,
476
- });
475
+ try {
476
+ const clips = await readClips(targets);
477
+ let ids: string[] = [];
478
+ const duplicate = (originalContent: string) => {
479
+ const liveElements = usePlayerStore
480
+ .getState()
481
+ .elements.filter((el) => pathOf(el) === targetPath)
482
+ .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
483
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
484
+ ids = pasted.ids;
485
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
486
+ };
477
487
 
478
- // The duplicate becomes the selection, mirroring CapCut's own paste
479
- // convention — there is no CapCut Duplicate to match directly (Cmd+D is
480
- // a no-op there); this is our own choice for consistency with paste.
481
- // Select before reloading, same reason as handlePaste above.
482
- if (ids.length > 0) {
483
- usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
488
+ await saveProjectFilesWithHistory({
489
+ projectId: pid,
490
+ label: clipLabel("Duplicate", clips.length),
491
+ files: { [targetPath]: duplicate },
492
+ readFile: (path) => readFileContent(pid, path),
493
+ writeFile: writeProjectFile,
494
+ recordEdit,
495
+ });
496
+
497
+ // The duplicate becomes the selection, mirroring CapCut's own paste
498
+ // convention — there is no CapCut Duplicate to match directly (Cmd+D is
499
+ // a no-op there); this is our own choice for consistency with paste.
500
+ // Select before reloading, same reason as handlePaste above.
501
+ if (ids.length > 0) {
502
+ usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
503
+ }
504
+ reloadPreview();
505
+ showToast(clipToast("Duplicated", clips.length), "info");
506
+ return true;
507
+ } catch (error) {
508
+ const message = error instanceof Error ? error.message : "Failed to duplicate";
509
+ showToast(message);
510
+ return false;
484
511
  }
485
- reloadPreview();
486
- showToast(clipToast("Duplicated", clips.length), "info");
487
- return true;
488
- } catch (error) {
489
- const message = error instanceof Error ? error.message : "Failed to duplicate";
490
- showToast(message);
491
- return false;
492
- }
493
- }, [
494
- activeCompPath,
495
- findSelectedClips,
496
- readClips,
497
- recordEdit,
498
- reloadPreview,
499
- showToast,
500
- writeProjectFile,
501
- ]);
512
+ },
513
+ [
514
+ activeCompPath,
515
+ findSelectedClips,
516
+ readClips,
517
+ recordEdit,
518
+ reloadPreview,
519
+ showToast,
520
+ writeProjectFile,
521
+ ],
522
+ );
502
523
 
503
524
  const handleCut = useCallback(async (): Promise<boolean> => {
504
525
  const selected = getSelectedElements();
@@ -5,8 +5,11 @@ import {
5
5
  getDomEditTargetKey,
6
6
  type DomEditSelection,
7
7
  } from "../components/editor/domEditing";
8
- import type { PersistDomEditOperations } from "./domEditCommitTypes";
9
- import { reportDomEditPersistFailure } from "./domEditPersistFailure";
8
+ import type { CommitDomAttributeBatch, PersistDomEditOperations } from "./domEditCommitTypes";
9
+ import {
10
+ DomEditPersistPreparedWriteError,
11
+ reportDomEditPersistFailure,
12
+ } from "./domEditPersistFailure";
10
13
  import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
11
14
  import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
12
15
  import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
@@ -54,6 +57,15 @@ export function resolveOptimisticAttributeValue(attr: string, value: string | nu
54
57
  return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
55
58
  }
56
59
 
60
+ function previewAttributeName(op: PatchOperation): string {
61
+ return op.type === "attribute" ? resolveFullAttrName(op.property, true) : op.property;
62
+ }
63
+
64
+ function previewAttributeValue(op: PatchOperation): string | null {
65
+ if (op.type === "html-attribute") return resolveOptimisticAttributeValue(op.property, op.value);
66
+ return op.value === "" ? null : op.value;
67
+ }
68
+
57
69
  function setOrRemovePreviewAttribute(
58
70
  el: HTMLElement,
59
71
  fullAttr: string,
@@ -209,7 +221,7 @@ export function useDomEditAttributeCommits({
209
221
  ],
210
222
  );
211
223
 
212
- // Commits several data-* attributes on the SAME element in ONE persist call
224
+ // Commits several data-* and HTML attributes on the SAME element in ONE persist call
213
225
  // — needed when two attributes together describe a single logical value
214
226
  // (e.g. a pinned timing range's start+duration): committing them through two
215
227
  // separate sequential `commitDataAttribute` calls leaves a window where the
@@ -221,32 +233,30 @@ export function useDomEditAttributeCommits({
221
233
  // explicit, caller-supplied `selection` (not the "current" one) closes both
222
234
  // gaps — matching `onCommitAnimatedProperties`'s same-shaped fix for GSAP
223
235
  // property batches.
224
- const commitDataAttributes = useCallback(
236
+ const commitAttributeOps = useCallback(
225
237
  async (
226
238
  selection: DomEditSelection,
227
- attrs: Record<string, string | null>,
228
- options: DataAttributeCommitOptions,
229
- ) => {
239
+ ops: PatchOperation[],
240
+ options: DataAttributeCommitOptions & {
241
+ prepareContent?: (html: string) => string;
242
+ syncAutomation?: boolean;
243
+ },
244
+ ): Promise<boolean> => {
230
245
  const iframe = previewIframeRef.current;
231
- const entries = Object.entries(attrs).map(([attr, value]) => ({
232
- attr,
233
- fullAttr: resolveFullAttrName(attr, true),
234
- value,
246
+ const entries = ops.map((op) => ({
247
+ fullAttr: previewAttributeName(op),
248
+ value: previewAttributeValue(op),
235
249
  }));
236
- const commitKey = `${options.coalescePrefix}:${entries
237
- .map((entry) => entry.attr)
250
+ const commitKey = `${options.coalescePrefix}:${ops
251
+ .map((op) => op.property)
238
252
  .sort()
239
253
  .join(",")}:${getDomEditTargetKey(selection)}`;
240
254
  const isLatestCommit = bumpDomEditCommitMapVersion(
241
255
  domAttributeCommitVersionRef.current,
242
256
  commitKey,
243
257
  );
244
- const ops: PatchOperation[] = entries.map((entry) => ({
245
- type: "attribute",
246
- property: entry.attr,
247
- value: entry.value,
248
- }));
249
258
  let captured: CapturedMultiAttributeElement | null = null;
259
+ let landed = false;
250
260
 
251
261
  await runDomEditCommit({
252
262
  capture: () => {
@@ -260,8 +270,7 @@ export function useDomEditAttributeCommits({
260
270
  apply: () => {
261
271
  if (!captured) return;
262
272
  for (const entry of entries) {
263
- const nextValue = entry.value === null || entry.value === "" ? null : entry.value;
264
- setOrRemovePreviewAttribute(captured.element, entry.fullAttr, nextValue);
273
+ setOrRemovePreviewAttribute(captured.element, entry.fullAttr, entry.value);
265
274
  }
266
275
  },
267
276
  persist: () =>
@@ -269,8 +278,10 @@ export function useDomEditAttributeCommits({
269
278
  label: options.label,
270
279
  coalesceKey: commitKey,
271
280
  skipRefresh: options.skipRefresh,
281
+ prepareContent: options.prepareContent,
272
282
  }),
273
- shouldRevert: () => isLatestCommit(),
283
+ shouldRevert: (error) =>
284
+ isLatestCommit() && !(error instanceof DomEditPersistPreparedWriteError),
274
285
  revert: () => {
275
286
  if (!captured) return;
276
287
  for (const entry of entries) {
@@ -283,10 +294,19 @@ export function useDomEditAttributeCommits({
283
294
  },
284
295
  onError: (error) => reportDomEditPersistFailure(selection, ops, error, showToast),
285
296
  shouldResync: () => isLatestCommit() && !!options.refreshAfter,
286
- resync: () => refreshDomEditSelectionFromPreview(selection),
287
- onSettled: options.onSettled,
297
+ resync: () => {
298
+ refreshDomEditSelectionFromPreview(selection);
299
+ if (options.syncAutomation) {
300
+ syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
301
+ }
302
+ },
303
+ onSettled: (ok) => {
304
+ landed = ok;
305
+ options.onSettled?.(ok);
306
+ },
288
307
  onFinally: isLatestCommit.release,
289
308
  });
309
+ return landed;
290
310
  },
291
311
  [
292
312
  activeCompPath,
@@ -299,14 +319,35 @@ export function useDomEditAttributeCommits({
299
319
 
300
320
  const handleDomAttributesCommit = useCallback(
301
321
  async (selection: DomEditSelection, attrs: Record<string, string>) => {
302
- await commitDataAttributes(selection, attrs, {
303
- label: "Edit timing",
304
- coalescePrefix: "attrs",
322
+ await commitAttributeOps(
323
+ selection,
324
+ Object.entries(attrs).map(([property, value]) => ({
325
+ type: "attribute",
326
+ property,
327
+ value,
328
+ })),
329
+ {
330
+ label: "Edit timing",
331
+ coalescePrefix: "attrs",
332
+ skipRefresh: false,
333
+ refreshAfter: true,
334
+ },
335
+ );
336
+ },
337
+ [commitAttributeOps],
338
+ );
339
+
340
+ const handleDomAttributeBatchCommit: CommitDomAttributeBatch = useCallback(
341
+ (selection, ops, options) =>
342
+ commitAttributeOps(selection, ops, {
343
+ label: options.label,
344
+ coalescePrefix: "attr-batch",
305
345
  skipRefresh: false,
306
346
  refreshAfter: true,
307
- });
308
- },
309
- [commitDataAttributes],
347
+ syncAutomation: true,
348
+ prepareContent: options.prepareContent,
349
+ }),
350
+ [commitAttributeOps],
310
351
  );
311
352
 
312
353
  const handleDomAttributeCommit = useCallback(
@@ -438,5 +479,6 @@ export function useDomEditAttributeCommits({
438
479
  handleDomAttributeQuietCommit,
439
480
  handleDomHtmlAttributeCommit,
440
481
  handleDomAttributesCommit,
482
+ handleDomAttributeBatchCommit,
441
483
  };
442
484
  }