@hyperframes/studio 0.8.106 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
  3. package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
  5. package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
  6. package/dist/chunk-4OZC426H.js.map +1 -0
  7. package/dist/index.d.ts +367 -11
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1064 -714
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +5 -3
  14. package/src/agentToolsExport.test.ts +8 -0
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/components/StudioRightPanels.tsx +2 -0
  17. package/src/components/TimelineToolbar.test.tsx +27 -0
  18. package/src/components/TimelineToolbar.tsx +2 -5
  19. package/src/components/editor/DomEditOverlay.tsx +12 -18
  20. package/src/components/editor/PropertyPanel.tsx +1 -14
  21. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  22. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  23. package/src/components/editor/SnapToolbar.tsx +3 -4
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  25. package/src/components/editor/fontAssets.test.ts +12 -22
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  27. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  28. package/src/components/editor/manualEdits.test.ts +4 -3
  29. package/src/components/editor/manualEditsDom.ts +28 -5
  30. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  31. package/src/components/editor/manualOffsetDrag.ts +4 -1
  32. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  33. package/src/components/editor/mediaAudioEdits.ts +253 -0
  34. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  36. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  37. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  40. package/src/components/editor/useDomEditNudge.ts +41 -23
  41. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  42. package/src/components/panels/SlideshowPanel.tsx +12 -7
  43. package/src/contexts/DomEditContext.test.tsx +62 -0
  44. package/src/contexts/DomEditContext.tsx +94 -77
  45. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  46. package/src/hooks/domEditCommitTypes.ts +10 -0
  47. package/src/hooks/domEditPersistFailure.ts +12 -1
  48. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  49. package/src/hooks/domStyleCommit.ts +2 -8
  50. package/src/hooks/gsapDragCommit.ts +3 -8
  51. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  52. package/src/hooks/gsapEditOutcome.ts +2 -1
  53. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  55. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  56. package/src/hooks/gsapResizeIntercept.ts +6 -2
  57. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  58. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  59. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  60. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  61. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  62. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  63. package/src/hooks/useAppHotkeys.test.ts +17 -0
  64. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  65. package/src/hooks/useAppHotkeys.ts +3 -3
  66. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  67. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  68. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  69. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  70. package/src/hooks/useDomEditCommits.ts +2 -0
  71. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  72. package/src/hooks/useDomEditPersist.ts +28 -13
  73. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  74. package/src/hooks/useDomEditSession.ts +2 -3
  75. package/src/hooks/useDomEditTextCommits.ts +4 -8
  76. package/src/hooks/useDomGeometryCommits.ts +11 -2
  77. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  78. package/src/hooks/useGsapAwareEditing.ts +6 -6
  79. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  80. package/src/hooks/useGsapScriptCommits.ts +5 -4
  81. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  82. package/src/hooks/useInlineTextEdit.ts +9 -7
  83. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  84. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  85. package/src/hooks/usePreviewPersistence.ts +6 -0
  86. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  87. package/src/index.ts +7 -0
  88. package/src/player/components/Timeline.test.ts +0 -21
  89. package/src/player/components/Timeline.tsx +0 -1
  90. package/src/player/components/timelineLayout.ts +0 -28
  91. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  92. package/src/player/lib/playbackShortcuts.ts +3 -7
  93. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  94. package/src/player/lib/timelineElementHelpers.ts +16 -2
  95. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  96. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  97. package/src/utils/gsapSoftReload.ts +3 -2
  98. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  99. package/src/utils/gsapUndoRestore.test.ts +145 -1
  100. package/src/utils/gsapUndoRestore.ts +70 -21
  101. package/src/utils/sourcePatcher.ts +1 -1
  102. package/src/utils/studioFontHelpers.ts +2 -23
  103. package/src/utils/studioHelpers.test.ts +30 -0
  104. package/src/utils/studioHelpers.ts +18 -4
  105. package/src/utils/studioPendingEdits.test.ts +63 -0
  106. package/src/utils/studioPendingEdits.ts +16 -7
  107. package/src/utils/typingTarget.test.ts +13 -0
  108. package/src/utils/typingTarget.ts +5 -2
  109. package/src/webmcp/useStudioAgentTools.ts +3 -1
  110. package/dist/chunk-ICTZJPQQ.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) => {
@@ -170,7 +170,6 @@ export function useDomEditSession({
170
170
  refreshDomEditSelectionFromPreview,
171
171
  });
172
172
  // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
173
-
174
173
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
175
174
 
176
175
  const {
@@ -202,6 +201,7 @@ export function useDomEditSession({
202
201
  handleDomAttributeQuietCommit,
203
202
  handleDomHtmlAttributeCommit,
204
203
  handleDomAttributesCommit,
204
+ handleDomAttributeBatchCommit,
205
205
  handleDomTextCommit,
206
206
  handleDomTextCommitForSelection,
207
207
  handleDomRichTextCommit,
@@ -288,7 +288,6 @@ export function useDomEditSession({
288
288
  });
289
289
 
290
290
  // ── Element groups (wrap selected elements in a data-hf-group div) ──
291
-
292
291
  const { groupSelection, ungroupSelection } = useGroupCommits({
293
292
  activeCompPath,
294
293
  showToast,
@@ -363,7 +362,6 @@ export function useDomEditSession({
363
362
  }, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
364
363
 
365
364
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
366
-
367
365
  const {
368
366
  onClickToSource,
369
367
  selectedGsapAnimations,
@@ -496,6 +494,7 @@ export function useDomEditSession({
496
494
  handleDomAttributeQuietCommit,
497
495
  handleDomHtmlAttributeCommit,
498
496
  handleDomAttributesCommit,
497
+ handleDomAttributeBatchCommit,
499
498
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
500
499
  handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
501
500
  handleDomZIndexReorderCommit,
@@ -1,10 +1,6 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
3
- import {
4
- injectPreviewGoogleFont,
5
- injectPreviewImportedFont,
6
- ensureImportedFontFace,
7
- } from "../utils/studioFontHelpers";
3
+ import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
8
4
  import {
9
5
  buildDomEditRichTextPatchOperation,
10
6
  findElementForSelection,
@@ -99,6 +95,7 @@ export function useDomEditTextCommits({
99
95
  handleDomAttributeQuietCommit,
100
96
  handleDomHtmlAttributeCommit,
101
97
  handleDomAttributesCommit,
98
+ handleDomAttributeBatchCommit,
102
99
  } = useDomEditAttributeCommits({
103
100
  activeCompPath,
104
101
  previewIframeRef,
@@ -354,9 +351,7 @@ export function useDomEditTextCommits({
354
351
  await persistDomEditOperations(selection, textCommit.operations, {
355
352
  label: "Edit text",
356
353
  skipRefresh: true,
357
- prepareContent: importedFont
358
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
359
- : undefined,
354
+ importedFont: importedFont ?? undefined,
360
355
  });
361
356
  },
362
357
  shouldRevert: () => isLatestTextCommit(),
@@ -485,6 +480,7 @@ export function useDomEditTextCommits({
485
480
  handleDomAttributeQuietCommit,
486
481
  handleDomHtmlAttributeCommit,
487
482
  handleDomAttributesCommit,
483
+ handleDomAttributeBatchCommit,
488
484
  handleDomTextCommit,
489
485
  handleDomTextCommitForSelection,
490
486
  handleDomRichTextCommit,
@@ -32,7 +32,13 @@ export interface UseDomGeometryCommitsParams {
32
32
  commitPositionPatchToHtml: (
33
33
  selection: DomEditSelection,
34
34
  patches: PatchOperation[],
35
- options: { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean },
35
+ options: {
36
+ label: string;
37
+ coalesceKey: string;
38
+ coalesceMs?: number;
39
+ skipRefresh?: boolean;
40
+ deferRender?: boolean;
41
+ },
36
42
  ) => Promise<void>;
37
43
  readOnlyPreview: boolean;
38
44
  }
@@ -65,6 +71,7 @@ export function useDomGeometryCommits({
65
71
  next: { width: number; height: number },
66
72
  offset?: { x: number; y: number },
67
73
  restore?: () => void,
74
+ undoKey?: string,
68
75
  ) => {
69
76
  if (readOnlyPreview) return Promise.resolve();
70
77
  const element = selection.element;
@@ -80,7 +87,9 @@ export function useDomGeometryCommits({
80
87
  if (offset) patches.push(...writePlainMove(element, offset));
81
88
  return commitPositionPatchToHtml(selection, patches, {
82
89
  label: "Resize layer box",
83
- coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
90
+ ...(undoKey
91
+ ? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
92
+ : { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
84
93
  }).catch((error) => {
85
94
  restoreStudioBoxSize(element, beforeSize);
86
95
  if (offset) restoreStudioPathOffset(element, beforeOffset);