@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
@@ -7,7 +7,6 @@ import type { MutableRefObject } from "react";
7
7
  import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
8
8
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
9
9
  import { usePlayerStore } from "../player";
10
- import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
11
10
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
12
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
13
12
  import type { CutoverResult } from "../utils/sdkCutover";
@@ -1318,101 +1317,57 @@ describe("useDomEditCommits style persist handling", () => {
1318
1317
  }
1319
1318
  });
1320
1319
 
1321
- it("keeps the already-persisted patch and toasts once when the prepareContent write fails", async () => {
1322
- stubPatchFetch(
1323
- {
1324
- ok: true,
1325
- changed: true,
1326
- matched: true,
1327
- content:
1328
- '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
1329
- },
1330
- '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
1331
- );
1332
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1333
- const { iframe, element } = createPreviewElement();
1334
- const selection = createSelection(element, {
1335
- textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1336
- });
1337
- const rendered = renderDomEditCommits(createSelection(element), iframe, {
1338
- writeProjectFile: async () => {
1339
- throw new StudioSaveHttpError("Failed to save index.html (500)", 500);
1340
- },
1341
- });
1342
-
1343
- try {
1344
- await act(async () => {
1345
- await rendered.hook.commitDomTextFields(
1346
- selection,
1347
- [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1348
- {
1349
- importedFont: {
1350
- family: "Imported",
1351
- path: "fonts/Imported.woff2",
1352
- url: "/api/projects/p1/preview/fonts/Imported.woff2",
1353
- },
1354
- },
1355
- );
1356
- });
1357
-
1358
- // The base patch already landed server-side before the font-face write
1359
- // failed, so this is recorded as a completed edit (not reverted/re-toasted
1360
- // as a full failure) — only the font embellishment is reported as lost.
1361
- expect(rendered.showToast).toHaveBeenCalledTimes(1);
1362
- expect(rendered.showToast).toHaveBeenCalledWith(
1363
- expect.stringContaining("Saved, but couldn't finish updating index.html"),
1364
- "error",
1365
- );
1366
- expect(rendered.showToast).toHaveBeenCalledWith(
1367
- expect.stringContaining("Failed to save index.html (500)"),
1368
- "error",
1320
+ it.each(["style", "text"])(
1321
+ "a %s edit with an imported font is one patch request that carries its @font-face",
1322
+ async (kind) => {
1323
+ const patchedContent =
1324
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
1325
+ const fetchMock = stubPatchFetch(
1326
+ { ok: true, changed: true, matched: true, content: patchedContent },
1327
+ patchedContent,
1369
1328
  );
1370
- expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
1371
- } finally {
1372
- warnSpy.mockRestore();
1373
- rendered.cleanup();
1374
- }
1375
- });
1329
+ const { iframe, element } = createPreviewElement();
1330
+ const selection = createSelection(element, {
1331
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1332
+ });
1333
+ const importedFont = {
1334
+ family: "Imported",
1335
+ path: "fonts/Imported.woff2",
1336
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
1337
+ };
1338
+ const writeProjectFile = vi.fn(async () => {});
1339
+ const rendered = renderDomEditCommits(selection, iframe, {
1340
+ writeProjectFile,
1341
+ importedFontAssets: [importedFont],
1342
+ });
1376
1343
 
1377
- it("uses the patched server content as the custom-font write precondition", async () => {
1378
- const patchedContent =
1379
- '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
1380
- stubPatchFetch(
1381
- { ok: true, changed: true, matched: true, content: patchedContent },
1382
- patchedContent,
1383
- );
1384
- const { iframe, element } = createPreviewElement();
1385
- const selection = createSelection(element, {
1386
- textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1387
- });
1388
- const writeProjectFile = vi.fn(async () => {});
1389
- const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
1344
+ try {
1345
+ await act(async () => {
1346
+ if (kind === "style") await rendered.hook.handleDomStyleCommit("font-family", "Imported");
1347
+ else
1348
+ await rendered.hook.commitDomTextFields(
1349
+ selection,
1350
+ [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1351
+ { importedFont },
1352
+ );
1353
+ });
1390
1354
 
1391
- try {
1392
- await act(async () => {
1393
- await rendered.hook.commitDomTextFields(
1394
- selection,
1395
- [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1396
- {
1397
- importedFont: {
1398
- family: "Imported",
1399
- path: "fonts/Imported.woff2",
1400
- url: "/api/projects/p1/preview/fonts/Imported.woff2",
1401
- },
1402
- },
1355
+ const writes = fetchMock.mock.calls.filter(
1356
+ ([, init]) => init?.method === "POST" || init?.method === "PUT",
1403
1357
  );
1404
- });
1405
-
1406
- expect(writeProjectFile).toHaveBeenCalledWith(
1407
- "index.html",
1408
- expect.stringContaining("@font-face"),
1409
- patchedContent,
1410
- );
1411
- expect(rendered.showToast).not.toHaveBeenCalled();
1412
- } finally {
1413
- rendered.cleanup();
1414
- }
1415
- });
1358
+ expect(writes).toHaveLength(1);
1359
+ expect(requestUrl(writes[0]![0])).toContain("/file-mutations/patch-element/index.html");
1360
+ const body = JSON.parse(String(writes[0]![1]?.body)) as { fontFaceCss?: string };
1361
+ expect(body.fontFaceCss).toMatch(
1362
+ /^@font-face \{ font-family: "Imported"; src: url\("fonts\/Imported.woff2"\)/,
1363
+ );
1364
+ expect(writeProjectFile).not.toHaveBeenCalled();
1365
+ expect(rendered.showToast).not.toHaveBeenCalled();
1366
+ } finally {
1367
+ rendered.cleanup();
1368
+ }
1369
+ },
1370
+ );
1416
1371
 
1417
1372
  it("keeps a rejected patch request (HTTP error) to one toast", async () => {
1418
1373
  const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
@@ -1736,3 +1691,128 @@ describe("useDomEditCommits attribute persist handling", () => {
1736
1691
  }
1737
1692
  });
1738
1693
  });
1694
+
1695
+ describe("useDomEditCommits attribute batch commit", () => {
1696
+ beforeEach(() => {
1697
+ ensureCssEscape();
1698
+ vi.clearAllMocks();
1699
+ vi.unstubAllGlobals();
1700
+ document.body.replaceChildren();
1701
+ });
1702
+
1703
+ const original = '<video data-hf-id="hf-card" data-has-audio="true"></video>';
1704
+ const patched = '<video data-hf-id="hf-card" muted></video>';
1705
+ const mute = [
1706
+ { type: "html-attribute" as const, property: "muted", value: "true" },
1707
+ { type: "attribute" as const, property: "has-audio", value: null },
1708
+ ];
1709
+
1710
+ it("sends mixed html and data attribute ops in ONE patch and ONE undo entry", async () => {
1711
+ const fetchMock = stubPatchFetch(
1712
+ {
1713
+ ok: true,
1714
+ changed: true,
1715
+ matched: true,
1716
+ content: patched,
1717
+ path: "index.html",
1718
+ version: "v2",
1719
+ },
1720
+ original,
1721
+ );
1722
+ const { iframe, element } = createPreviewElement(original);
1723
+ const rendered = renderDomEditCommits(createSelection(element, { tagName: "video" }), iframe);
1724
+
1725
+ try {
1726
+ let ok = false;
1727
+ await act(async () => {
1728
+ ok = await rendered.hook.handleDomAttributeBatchCommit(
1729
+ createSelection(element, { tagName: "video" }),
1730
+ mute,
1731
+ { label: "Edit muted" },
1732
+ );
1733
+ });
1734
+
1735
+ const patches = fetchMock.mock.calls.filter(([input]) =>
1736
+ requestUrl(input).includes("/file-mutations/patch-element/"),
1737
+ );
1738
+ expect(ok).toBe(true);
1739
+ expect(patches).toHaveLength(1);
1740
+ const body = JSON.parse(String(patches[0]?.[1]?.body));
1741
+ expect(body.operations).toEqual(mute);
1742
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
1743
+ expect(rendered.recordEdit).toHaveBeenCalledWith(
1744
+ expect.objectContaining({
1745
+ files: { "index.html": { before: original, after: patched } },
1746
+ }),
1747
+ );
1748
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
1749
+ expect(element.hasAttribute("muted")).toBe(true);
1750
+ expect(element.hasAttribute("data-has-audio")).toBe(false);
1751
+ } finally {
1752
+ rendered.cleanup();
1753
+ }
1754
+ });
1755
+
1756
+ it("folds a document-level prepareContent into the same single undo entry", async () => {
1757
+ stubPatchFetch(
1758
+ {
1759
+ ok: true,
1760
+ changed: true,
1761
+ matched: true,
1762
+ content: patched,
1763
+ path: "index.html",
1764
+ version: "v2",
1765
+ },
1766
+ original,
1767
+ );
1768
+ const writes: string[] = [];
1769
+ const { iframe, element } = createPreviewElement(original);
1770
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1771
+ writeProjectFile: async (_path, content) => {
1772
+ writes.push(content);
1773
+ },
1774
+ });
1775
+ const withSibling = `<audio id="a"></audio>${patched}`;
1776
+
1777
+ try {
1778
+ await act(async () => {
1779
+ await rendered.hook.handleDomAttributeBatchCommit(createSelection(element), mute, {
1780
+ label: "Remove background",
1781
+ prepareContent: () => withSibling,
1782
+ });
1783
+ });
1784
+
1785
+ expect(writes).toEqual([withSibling]);
1786
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
1787
+ expect(rendered.recordEdit).toHaveBeenCalledWith(
1788
+ expect.objectContaining({
1789
+ label: "Remove background",
1790
+ files: { "index.html": { before: original, after: withSibling } },
1791
+ }),
1792
+ );
1793
+ } finally {
1794
+ rendered.cleanup();
1795
+ }
1796
+ });
1797
+
1798
+ it("reverts every op in the batch and reports failure when the patch rejects", async () => {
1799
+ stubPatchFetch(new Error("network down"), original);
1800
+ const { iframe, element } = createPreviewElement(original);
1801
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1802
+
1803
+ try {
1804
+ let ok = true;
1805
+ await act(async () => {
1806
+ ok = await rendered.hook.handleDomAttributeBatchCommit(createSelection(element), mute, {
1807
+ label: "Edit muted",
1808
+ });
1809
+ });
1810
+
1811
+ expect(ok).toBe(false);
1812
+ expect(element.hasAttribute("muted")).toBe(false);
1813
+ expect(element.getAttribute("data-has-audio")).toBe("true");
1814
+ } finally {
1815
+ rendered.cleanup();
1816
+ }
1817
+ });
1818
+ });
@@ -239,6 +239,7 @@ export function useDomEditCommits({
239
239
  handleDomAttributeQuietCommit,
240
240
  handleDomHtmlAttributeCommit,
241
241
  handleDomAttributesCommit,
242
+ handleDomAttributeBatchCommit,
242
243
  handleDomTextCommit,
243
244
  handleDomTextCommitForSelection,
244
245
  handleDomRichTextCommit,
@@ -305,6 +306,7 @@ export function useDomEditCommits({
305
306
  handleDomAttributeQuietCommit,
306
307
  handleDomHtmlAttributeCommit,
307
308
  handleDomAttributesCommit,
309
+ handleDomAttributeBatchCommit,
308
310
  handleDomTextCommit,
309
311
  handleDomTextCommitForSelection,
310
312
  handleDomRichTextCommit,
@@ -198,25 +198,23 @@ export async function postPatchElement(
198
198
  return (await response.json()) as PatchElementResponse;
199
199
  }
200
200
 
201
- /** Writes `prepare`'s embellishment over the server's patch; returns what the file ends up holding. */
202
201
  export async function writePreparedContent(
203
202
  targetPath: string,
204
203
  patchedContent: string,
205
204
  prepare: (html: string, sourceFile: string) => string,
206
205
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
207
206
  showToast: ShowToast,
208
- ): Promise<string> {
207
+ ): Promise<{ content: string; failed: boolean }> {
209
208
  const preparedContent = prepare(patchedContent, targetPath);
210
- if (preparedContent === patchedContent) return patchedContent;
209
+ if (preparedContent === patchedContent) return { content: patchedContent, failed: false };
211
210
  try {
212
211
  await writeProjectFile(targetPath, preparedContent, patchedContent);
213
- return preparedContent;
212
+ return { content: preparedContent, failed: false };
214
213
  } catch (error) {
215
- // The patch already landed on disk; keep it rather than revert a committed change.
216
214
  showToast(
217
215
  `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
218
216
  "error",
219
217
  );
220
- return patchedContent;
218
+ return { content: patchedContent, failed: true };
221
219
  }
222
220
  }
@@ -6,6 +6,7 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
6
6
  import type { PatchOperation } from "../utils/sourcePatcher";
7
7
  import {
8
8
  DomEditPersistUnsafeValueError,
9
+ DomEditPersistPreparedWriteError,
9
10
  DomEditPersistUnresolvableError,
10
11
  warnDomEditPersistNoOp,
11
12
  } from "./domEditPersistFailure";
@@ -14,6 +15,8 @@ import {
14
15
  postPatchElement,
15
16
  writePreparedContent,
16
17
  } from "./useDomEditCommitsHelpers";
18
+ import { importedFontFaceCssFor } from "../utils/studioFontHelpers";
19
+ import { countStudioManualEditSave } from "../components/editor/manualEditsDom";
17
20
  import type { CutoverResult } from "../utils/sdkCutover";
18
21
  import { reseekPreviewRuntime } from "./timelineTrackVisibility";
19
22
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
@@ -77,7 +80,8 @@ export function useDomEditPersist({
77
80
 
78
81
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
79
82
  const completePersistence = <T>(result: T, changed: boolean): T => {
80
- if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
83
+ if (options?.skipRefresh && changed && !options.deferRender)
84
+ reseekPreviewRuntime(previewIframeRef.current);
81
85
  return result;
82
86
  };
83
87
 
@@ -94,7 +98,12 @@ export function useDomEditPersist({
94
98
  // after it let invalid numeric values bypass the guard whenever the
95
99
  // cutover flag was on.
96
100
  const patchTarget = buildDomEditPatchTarget(selection);
97
- const patchBody = { target: patchTarget, operations };
101
+ const font = options?.importedFont;
102
+ const patchBody = {
103
+ target: patchTarget,
104
+ operations,
105
+ ...(font ? { fontFaceCss: importedFontFaceCssFor(font, targetPath) } : {}),
106
+ };
98
107
  const unsafeFields = findUnsafeDomPatchValues(patchBody);
99
108
  if (unsafeFields.length > 0) {
100
109
  const fields = formatUnsafeFieldList(unsafeFields);
@@ -104,11 +113,9 @@ export function useDomEditPersist({
104
113
  });
105
114
  }
106
115
 
107
- // Skip the SDK path when prepareContent is set (e.g. @font-face injection
108
- // for a custom font): sdkCutoverPersist serializes only the patched DOM
109
- // and would drop the injected content. Let the server path run prepareContent.
110
- // The SDK joins the file queue itself and re-reads there; this read is only its fallback.
111
- if (onTrySdkPersist && !options?.prepareContent) {
116
+ // An imported font or prepareContent takes the server patch; the SDK serializes only the patched
117
+ // DOM. The SDK re-reads in the file queue; this read is its fallback.
118
+ if (onTrySdkPersist && !font && !options?.prepareContent) {
112
119
  const originalContent = await readTarget();
113
120
  if (originalContent === null) return;
114
121
  const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
@@ -133,6 +140,7 @@ export function useDomEditPersist({
133
140
  coalesceMs: options?.coalesceMs,
134
141
  };
135
142
  const prepare = options?.prepareContent;
143
+ let preparedWriteFailed = false;
136
144
  // Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
137
145
  const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
138
146
  const originalContent = await readTarget();
@@ -142,7 +150,7 @@ export function useDomEditPersist({
142
150
 
143
151
  const patchedContent =
144
152
  typeof patchData.content === "string" ? patchData.content : originalContent;
145
- const finalContent = prepare
153
+ const prepared = prepare
146
154
  ? await writePreparedContent(
147
155
  targetPath,
148
156
  patchedContent,
@@ -150,7 +158,9 @@ export function useDomEditPersist({
150
158
  writeProjectFile,
151
159
  showToast,
152
160
  )
153
- : patchedContent;
161
+ : { content: patchedContent, failed: false };
162
+ preparedWriteFailed = prepared.failed;
163
+ const finalContent = prepared.content;
154
164
 
155
165
  await editHistory.recordEdit({
156
166
  ...history,
@@ -188,7 +198,7 @@ export function useDomEditPersist({
188
198
  if (!options?.skipRefresh) {
189
199
  reloadPreview();
190
200
  }
191
- return completePersistence(
201
+ const outcome = completePersistence(
192
202
  finalContent === patchedContent &&
193
203
  typeof patchData.path === "string" &&
194
204
  typeof patchData.version === "string"
@@ -196,6 +206,8 @@ export function useDomEditPersist({
196
206
  : undefined,
197
207
  true,
198
208
  );
209
+ if (preparedWriteFailed) throw new DomEditPersistPreparedWriteError(targetPath);
210
+ return outcome;
199
211
  },
200
212
  [
201
213
  activeCompPath,
@@ -214,9 +226,12 @@ export function useDomEditPersist({
214
226
  (selection, operations, options) => {
215
227
  const expectedProjectId = projectIdRef.current;
216
228
  if (!expectedProjectId) return Promise.reject(new Error("No active project"));
217
- return queueDomEditSave(() =>
218
- performPersistDomEditOperations(selection, operations, options, expectedProjectId),
219
- );
229
+ // Counted, so a preview reload requested before this save settles is not shown over it.
230
+ const save = () =>
231
+ queueDomEditSave(() =>
232
+ performPersistDomEditOperations(selection, operations, options, expectedProjectId),
233
+ );
234
+ return selection.element ? countStudioManualEditSave(selection.element, save) : save();
220
235
  },
221
236
  [performPersistDomEditOperations, projectIdRef, queueDomEditSave],
222
237
  );
@@ -17,6 +17,7 @@ type PositionPatchOptions = {
17
17
  coalesceKey: string;
18
18
  coalesceMs?: number;
19
19
  skipRefresh?: boolean;
20
+ deferRender?: boolean;
20
21
  };
21
22
 
22
23
  export function useDomEditPositionPatchCommit({
@@ -31,6 +32,7 @@ export function useDomEditPositionPatchCommit({
31
32
  coalesceKey: options.coalesceKey,
32
33
  coalesceMs: options.coalesceMs,
33
34
  skipRefresh: options.skipRefresh ?? true,
35
+ deferRender: options.deferRender,
34
36
  })
35
37
  .then(() => undefined)
36
38
  .catch((error) => {
@@ -9,6 +9,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import type { TimelineElement } from "../player";
11
11
  import type { UseDomEditSessionParams } from "./useDomEditSession";
12
+ import { makeSelection } from "./domSelectionTestHarness";
12
13
 
13
14
  const styleOp = (property: string, value: string): PatchOperation => ({
14
15
  type: "inline-style",
@@ -132,26 +133,30 @@ vi.mock("./useDomEditCommits", () => ({
132
133
  };
133
134
  },
134
135
  }));
136
+ const iframesHandedOn: Array<{ hook: string; iframe: unknown }> = [];
135
137
  vi.mock("./useDomSelection", () => ({
136
- useDomSelection: () => ({
137
- domEditSelection: domEditSelectionRef.current,
138
- domEditGroupSelections: [],
139
- domEditHoverSelection: null,
140
- activeGroupElement: null,
141
- domEditSelectionRef,
142
- domEditGroupSelectionsRef,
143
- setActiveGroupElement: vi.fn(),
144
- applyDomSelection: vi.fn(),
145
- clearDomSelection: vi.fn(),
146
- buildDomSelectionFromTarget: vi.fn(),
147
- resolveDomSelectionFromPreviewPoint: vi.fn(),
148
- resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
149
- updateDomEditHoverSelection: vi.fn(),
150
- buildDomSelectionForTimelineElement: vi.fn(),
151
- handleTimelineElementSelect: vi.fn(),
152
- refreshDomEditSelectionFromPreview: vi.fn(),
153
- applyMarqueeSelection: vi.fn(),
154
- }),
138
+ useDomSelection: ({ previewIframe }: { previewIframe: unknown }) => {
139
+ iframesHandedOn.push({ hook: "selection", iframe: previewIframe });
140
+ return {
141
+ domEditSelection: domEditSelectionRef.current,
142
+ domEditGroupSelections: [],
143
+ domEditHoverSelection: null,
144
+ activeGroupElement: null,
145
+ domEditSelectionRef,
146
+ domEditGroupSelectionsRef,
147
+ setActiveGroupElement: vi.fn(),
148
+ applyDomSelection: vi.fn(),
149
+ clearDomSelection: vi.fn(),
150
+ buildDomSelectionFromTarget: vi.fn(),
151
+ resolveDomSelectionFromPreviewPoint: vi.fn(),
152
+ resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
153
+ updateDomEditHoverSelection: vi.fn(),
154
+ buildDomSelectionForTimelineElement: vi.fn(),
155
+ handleTimelineElementSelect: vi.fn(),
156
+ refreshDomEditSelectionFromPreview: vi.fn(),
157
+ applyMarqueeSelection: vi.fn(),
158
+ };
159
+ },
155
160
  }));
156
161
  vi.mock("./useAskAgentModal", () => ({
157
162
  useAskAgentModal: () => ({
@@ -208,33 +213,36 @@ vi.mock("./useGroupCommits", () => ({
208
213
  }),
209
214
  }));
210
215
  vi.mock("./useDomEditWiring", () => ({
211
- useDomEditWiring: () => ({
212
- onClickToSource: vi.fn(),
213
- selectedGsapAnimations: [],
214
- gsapMultipleTimelines: false,
215
- gsapUnsupportedTimelinePattern: false,
216
- trackGsapInteractionFailure: vi.fn(),
217
- makeFetchFallback: vi.fn(),
218
- handleGsapUpdateProperty: vi.fn(),
219
- handleGsapUpdateMeta: vi.fn(),
220
- handleGsapDeleteAnimation: vi.fn(),
221
- handleGsapDeleteAllForElement: vi.fn(),
222
- handleGsapAddAnimation: vi.fn(),
223
- handleGsapAddProperty: vi.fn(),
224
- handleGsapRemoveProperty: vi.fn(),
225
- handleGsapUpdateFromProperty: vi.fn(),
226
- handleGsapAddFromProperty: vi.fn(),
227
- handleGsapRemoveFromProperty: vi.fn(),
228
- handleGsapAddKeyframe: vi.fn(),
229
- handleGsapAddKeyframeBatch: vi.fn(),
230
- handleGsapRemoveKeyframe: vi.fn(),
231
- handleGsapMoveKeyframeToPlayhead: vi.fn(),
232
- handleGsapMoveKeyframe: vi.fn(),
233
- handleGsapResizeKeyframedTween: vi.fn(),
234
- handleGsapConvertToKeyframes: vi.fn(),
235
- handleGsapRemoveAllKeyframes: vi.fn(),
236
- handleResetSelectedElementKeyframes: vi.fn(),
237
- }),
216
+ useDomEditWiring: ({ previewIframe }: { previewIframe: unknown }) => {
217
+ iframesHandedOn.push({ hook: "wiring", iframe: previewIframe });
218
+ return {
219
+ onClickToSource: vi.fn(),
220
+ selectedGsapAnimations: [],
221
+ gsapMultipleTimelines: false,
222
+ gsapUnsupportedTimelinePattern: false,
223
+ trackGsapInteractionFailure: vi.fn(),
224
+ makeFetchFallback: vi.fn(),
225
+ handleGsapUpdateProperty: vi.fn(),
226
+ handleGsapUpdateMeta: vi.fn(),
227
+ handleGsapDeleteAnimation: vi.fn(),
228
+ handleGsapDeleteAllForElement: vi.fn(),
229
+ handleGsapAddAnimation: vi.fn(),
230
+ handleGsapAddProperty: vi.fn(),
231
+ handleGsapRemoveProperty: vi.fn(),
232
+ handleGsapUpdateFromProperty: vi.fn(),
233
+ handleGsapAddFromProperty: vi.fn(),
234
+ handleGsapRemoveFromProperty: vi.fn(),
235
+ handleGsapAddKeyframe: vi.fn(),
236
+ handleGsapAddKeyframeBatch: vi.fn(),
237
+ handleGsapRemoveKeyframe: vi.fn(),
238
+ handleGsapMoveKeyframeToPlayhead: vi.fn(),
239
+ handleGsapMoveKeyframe: vi.fn(),
240
+ handleGsapResizeKeyframedTween: vi.fn(),
241
+ handleGsapConvertToKeyframes: vi.fn(),
242
+ handleGsapRemoveAllKeyframes: vi.fn(),
243
+ handleResetSelectedElementKeyframes: vi.fn(),
244
+ };
245
+ },
238
246
  }));
239
247
  vi.mock("./usePreviewInteraction", () => ({
240
248
  usePreviewInteraction: () => ({
@@ -310,32 +318,9 @@ describe("onReorderShadow source filter", () => {
310
318
  describe("bulk segment ease commits", () => {
311
319
  it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
312
320
  const { useDomEditSession } = await import("./useDomEditSession");
313
- const selection: DomEditSelection = {
314
- id: "hero",
315
- element: document.createElement("div"),
316
- label: "Hero",
317
- tagName: "DIV",
318
- sourceFile: "index.html",
319
- compositionPath: "index.html",
320
- isCompositionHost: false,
321
- isInsideLockedComposition: false,
322
- boundingBox: { x: 0, y: 0, width: 100, height: 100 },
323
- textContent: null,
324
- dataAttributes: {},
325
- inlineStyles: {},
326
- computedStyles: {},
327
- textFields: [],
328
- capabilities: {
329
- canSelect: true,
330
- canEditStyles: true,
331
- canCrop: true,
332
- canMove: true,
333
- canResize: true,
334
- canApplyManualOffset: true,
335
- canApplyManualSize: true,
336
- canApplyManualRotation: true,
337
- },
338
- };
321
+ const hero = document.createElement("div");
322
+ hero.id = "hero";
323
+ const selection = makeSelection("Hero", hero);
339
324
  domEditSelectionRef.current = selection;
340
325
  gsapCommitMutation.mockClear();
341
326
  gsapCommitMutation.batch.mockClear();
@@ -601,3 +586,22 @@ describe("handleDomEditElementDelete routing", () => {
601
586
  expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
602
587
  });
603
588
  });
589
+
590
+ describe("a shadow reload promoted without a host re-render", () => {
591
+ it("hands the promoted iframe to the session's selection and preview sync", async () => {
592
+ const { useDomEditSession } = await import("./useDomEditSession");
593
+ const { announcePreviewPromoted } = await import("../player/sceneSwap");
594
+ const [host, promoted] = [0, 1].map(() => document.createElement("iframe"));
595
+ function Probe() {
596
+ useDomEditSession(createSessionParams({ previewIframe: host! }));
597
+ return null;
598
+ }
599
+ const root = createRoot(document.createElement("div"));
600
+ act(() => root.render(<Probe />));
601
+ act(() => announcePreviewPromoted({ retired: host!, live: promoted! }));
602
+ const last = (hook: string) => iframesHandedOn.findLast((seen) => seen.hook === hook)?.iframe;
603
+ expect(last("selection")).toBe(promoted);
604
+ expect(last("wiring")).toBe(promoted);
605
+ act(() => root.unmount());
606
+ });
607
+ });