@hyperframes/studio 0.8.80 → 0.8.82

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-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -0,0 +1,78 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import { useAskAgentModal } from "./useAskAgentModal";
8
+
9
+ const copied = vi.hoisted(() => [] as string[]);
10
+ vi.mock("../utils/clipboard", () => ({
11
+ copyTextToClipboard: async (text: string) => copied.push(text) > 0,
12
+ }));
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = "";
18
+ copied.length = 0;
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ describe("useAskAgentModal", () => {
23
+ it("sends the live element without the preview's lazy loading or look-ahead mark, keeping authored loading", async () => {
24
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
25
+ const element = document.createElement("div");
26
+ element.setAttribute("data-hf-upcoming", "");
27
+ element.innerHTML =
28
+ '<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="eager">';
29
+ const selection: DomEditSelection = {
30
+ element,
31
+ label: "Plate",
32
+ tagName: "div",
33
+ sourceFile: "index.html",
34
+ compositionPath: "index.html",
35
+ isCompositionHost: false,
36
+ isInsideLockedComposition: false,
37
+ selector: "div",
38
+ selectorIndex: 0,
39
+ boundingBox: { x: 0, y: 0, width: 10, height: 10 },
40
+ textContent: "",
41
+ dataAttributes: {},
42
+ inlineStyles: {},
43
+ computedStyles: {},
44
+ textFields: [],
45
+ capabilities: {
46
+ canSelect: true,
47
+ canEditStyles: true,
48
+ canCrop: false,
49
+ canMove: true,
50
+ canResize: true,
51
+ canApplyManualOffset: false,
52
+ canApplyManualSize: false,
53
+ canApplyManualRotation: false,
54
+ },
55
+ };
56
+ let hook: ReturnType<typeof useAskAgentModal> | null = null;
57
+ function Harness() {
58
+ hook = useAskAgentModal({
59
+ projectId: "demo",
60
+ activeCompPath: "index.html",
61
+ projectDir: "/demo",
62
+ projectIdRef: { current: "demo" },
63
+ showToast: () => {},
64
+ domEditSelectionRef: { current: selection },
65
+ domEditSelection: selection,
66
+ });
67
+ return null;
68
+ }
69
+ const host = document.body.appendChild(document.createElement("div"));
70
+ await act(async () => createRoot(host).render(<Harness />));
71
+
72
+ await act(async () => hook!.handleAgentModalSubmit("Make it bigger"));
73
+
74
+ expect(copied[0]).toContain('<img src="plate.png"><img src="own.png" loading="eager">');
75
+ expect(copied[0]).not.toContain("data-hf-preview-lazy");
76
+ expect(copied[0]).not.toContain("data-hf-upcoming");
77
+ });
78
+ });
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
4
4
  import { copyTextToClipboard } from "../utils/clipboard";
5
5
  import { readTagSnippetByTarget } from "../utils/sourcePatcher";
6
6
  import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
7
+ import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
7
8
  import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
8
9
  import { usePlayerStore } from "../player";
9
10
 
@@ -89,7 +90,8 @@ export function useAskAgentModal({
89
90
  if (!domEditSelection) return;
90
91
 
91
92
  const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
92
- const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
93
+ const tagSnippet =
94
+ agentPromptTagSnippet ?? liveMarkupWithoutPreviewMarks(domEditSelection.element);
93
95
  const prompt = buildElementAgentPrompt({
94
96
  selection: domEditSelection,
95
97
  currentTime: usePlayerStore.getState().currentTime,
@@ -220,6 +220,27 @@ describe("copy takes a clip's saved markup, not the runtime's live styling", ()
220
220
  });
221
221
  });
222
222
 
223
+ describe("copy of a clip missing from its saved file", () => {
224
+ it("takes the preview's markup without its lazy loading or look-ahead mark, keeping authored loading", async () => {
225
+ selectTitle();
226
+ const { clipboard, writes, files, iframe } = mountClipboard();
227
+ const title = iframe.contentDocument?.querySelector("h1") as Element;
228
+ title.setAttribute("data-hf-upcoming", "");
229
+ title.insertAdjacentHTML(
230
+ "beforeend",
231
+ '<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="lazy">',
232
+ );
233
+ files["index.html"] = SAVED.replace(/<h1[\s\S]*<\/h1>/, "");
234
+ clipboard().handleCopy();
235
+ await clipboard().handlePaste();
236
+ expect(writes[0]).toMatch(
237
+ /<img src="plate.png" data-hf-id="[^"]+"><img src="own.png" loading="lazy"/,
238
+ );
239
+ expect(writes[0]).not.toContain("data-hf-preview-lazy");
240
+ expect(writes[0]).not.toContain("data-hf-upcoming");
241
+ });
242
+ });
243
+
223
244
  const SUB_SELECTION = {
224
245
  hfId: SUB_HF_ID,
225
246
  selector: "h2",
@@ -338,3 +359,14 @@ describe("a copy that fails", () => {
338
359
  expect(deleted).toEqual([]);
339
360
  });
340
361
  });
362
+
363
+ describe("with no DOM edit session", () => {
364
+ it("refuses copy, cut and duplicate when nothing is selected", async () => {
365
+ clearSelection();
366
+ const { clipboard, deleted } = mountClipboard();
367
+ expect(clipboard().handleCopy()).toBe(false);
368
+ expect(await clipboard().handleCut()).toBe(false);
369
+ expect(await clipboard().handleDuplicate()).toBe(false);
370
+ expect(deleted).toEqual([]);
371
+ });
372
+ });
@@ -23,6 +23,7 @@ import {
23
23
  authoredMarkup,
24
24
  findAuthoredElement,
25
25
  findAuthoredElementById,
26
+ liveMarkupWithoutPreviewMarks,
26
27
  parseSavedSource,
27
28
  } from "../utils/authoredSource";
28
29
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
@@ -33,7 +34,7 @@ interface RecordEditInput {
33
34
  files: Record<string, { before: string; after: string }>;
34
35
  }
35
36
 
36
- interface UseClipboardOptions {
37
+ export interface UseClipboardOptions {
37
38
  projectId: string | null;
38
39
  activeCompPath: string | null;
39
40
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
@@ -88,7 +89,9 @@ function getSelectedDomElement(
88
89
 
89
90
  function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
90
91
  const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
91
- return authored ? authoredMarkup(authored, live, sourceFile) : live.outerHTML;
92
+ return authored
93
+ ? authoredMarkup(authored, live, sourceFile)
94
+ : liveMarkupWithoutPreviewMarks(live);
92
95
  }
93
96
 
94
97
  async function readSavedMarkup(
@@ -356,21 +359,17 @@ export function useClipboard({
356
359
 
357
360
  const targetPath = activeCompPath || "index.html";
358
361
  try {
359
- const originalContent = await readFileContent(pid, targetPath);
360
- let patchedContent: string;
361
362
  let pastedIds: string[] = [];
362
-
363
- if (payload.kind === "timeline-clip") {
363
+ const paste = (originalContent: string) => {
364
+ if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
364
365
  const { currentTime, elements } = usePlayerStore.getState();
365
366
  const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
367
+ pastedIds = pasted.ids;
366
368
  // A clip pasted past the current composition end would exist in the
367
369
  // file but never appear on the timeline or in playback/export (the
368
370
  // root's data-duration is what actually bounds the render).
369
- patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
370
- pastedIds = pasted.ids;
371
- } else {
372
- patchedContent = pasteElementHtml(originalContent, payload);
373
- }
371
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
372
+ };
374
373
 
375
374
  const label =
376
375
  payload.kind === "timeline-clip"
@@ -380,8 +379,8 @@ export function useClipboard({
380
379
  await saveProjectFilesWithHistory({
381
380
  projectId: pid,
382
381
  label,
383
- files: { [targetPath]: patchedContent },
384
- readFile: async () => originalContent,
382
+ files: { [targetPath]: paste },
383
+ readFile: (path) => readFileContent(pid, path),
385
384
  writeFile: writeProjectFile,
386
385
  recordEdit,
387
386
  });
@@ -422,17 +421,19 @@ export function useClipboard({
422
421
 
423
422
  try {
424
423
  const clips = await readClips(targets);
425
- const originalContent = await readFileContent(pid, targetPath);
426
- const liveElements = usePlayerStore.getState().elements;
427
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
428
- const patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
429
- const ids = pasted.ids;
424
+ let ids: string[] = [];
425
+ const duplicate = (originalContent: string) => {
426
+ const liveElements = usePlayerStore.getState().elements;
427
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
428
+ ids = pasted.ids;
429
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
430
+ };
430
431
 
431
432
  await saveProjectFilesWithHistory({
432
433
  projectId: pid,
433
434
  label: clipLabel("Duplicate", clips.length),
434
- files: { [targetPath]: patchedContent },
435
- readFile: async () => originalContent,
435
+ files: { [targetPath]: duplicate },
436
+ readFile: (path) => readFileContent(pid, path),
436
437
  writeFile: writeProjectFile,
437
438
  recordEdit,
438
439
  });
@@ -1252,8 +1252,15 @@ describe("useDomEditCommits style persist handling", () => {
1252
1252
 
1253
1253
  const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
1254
1254
  await flushAsyncWork();
1255
- expect(patchCount).toBe(2);
1255
+ // The newer commit waits for the older one in the file's queue.
1256
+ expect(patchCount).toBe(1);
1256
1257
 
1258
+ firstPatch.reject(new Error("server rejected blue"));
1259
+ await firstCommit;
1260
+ expect(element.style.getPropertyValue("color")).toBe("green");
1261
+
1262
+ await flushAsyncWork();
1263
+ expect(patchCount).toBe(2);
1257
1264
  secondPatch.resolve(
1258
1265
  jsonResponse({
1259
1266
  ok: true,
@@ -1263,10 +1270,6 @@ describe("useDomEditCommits style persist handling", () => {
1263
1270
  }),
1264
1271
  );
1265
1272
  await secondCommit;
1266
- expect(element.style.getPropertyValue("color")).toBe("green");
1267
-
1268
- firstPatch.reject(new Error("server rejected blue"));
1269
- await firstCommit;
1270
1273
 
1271
1274
  expect(element.style.getPropertyValue("color")).toBe("green");
1272
1275
  } finally {
@@ -1698,15 +1701,16 @@ describe("useDomEditCommits attribute persist handling", () => {
1698
1701
  try {
1699
1702
  // Older commit's persist stays pending (captures previousValue=null); the
1700
1703
  // newer commit captures previousValue="first-value" (the older commit's
1701
- // optimistic apply) and succeeds before the older one rejects. Without the
1704
+ // optimistic apply) and is still queued when the older one rejects. Without the
1702
1705
  // per-key version guard, the stale rejection would revert to the older
1703
1706
  // commit's own previousValue (null) and stomp the newer commit's value.
1704
1707
  const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1705
1708
  await act(async () => {
1706
- await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1709
+ const secondCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1710
+ first.reject(new Error("stale request failed"));
1711
+ await firstCommit;
1712
+ await secondCommit;
1707
1713
  });
1708
- first.reject(new Error("stale request failed"));
1709
- await firstCommit;
1710
1714
 
1711
1715
  expect(element.getAttribute("muted")).toBe("second-value");
1712
1716
  } finally {
@@ -5,11 +5,7 @@ import { FONT_EXT } from "../utils/mediaTypes";
5
5
 
6
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
7
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
8
- import {
9
- createStudioSaveHttpError,
10
- StudioSaveHttpError,
11
- trackStudioSaveFailure,
12
- } from "../utils/studioSaveDiagnostics";
8
+ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
13
9
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
14
10
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
15
11
  import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
@@ -26,15 +22,15 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
26
22
  import {
27
23
  AtomicElementPatchConvergenceError,
28
24
  batchesAreInlineStyleOnly,
29
- formatPatchRejectionMessage,
30
25
  formatUnsafeFieldList,
31
- getErrorDetail,
32
26
  patchElementBatches,
33
- readErrorResponseBody,
27
+ postPatchElement,
28
+ writePreparedContent,
34
29
  } from "./useDomEditCommitsHelpers";
35
30
  import type { CutoverResult } from "../utils/sdkCutover";
36
- import { studioWriteHeaders } from "../utils/studioFileVersion";
37
31
  import { reseekPreviewRuntime } from "./timelineTrackVisibility";
32
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
33
+ import { readProjectFileContent } from "../utils/studioFileHistory";
38
34
  interface RecordEditInput {
39
35
  label: string;
40
36
  coalesceKey?: string;
@@ -159,23 +155,13 @@ export function useDomEditCommits({
159
155
  return result;
160
156
  };
161
157
 
162
- const readResponse = await fetch(
163
- buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
164
- );
165
- if (!readResponse.ok) {
166
- throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
167
- }
168
- const readData = (await readResponse.json()) as { content?: string };
169
- const originalContent = readData.content;
170
- if (typeof originalContent !== "string") {
171
- throw new Error(`Missing file contents for ${targetPath}`);
172
- }
173
-
174
- if (projectIdRef.current !== expectedProjectId) {
175
- throw new Error("Active project changed before the edit could be saved");
176
- }
177
-
178
- if (options?.shouldSave && !options.shouldSave()) return;
158
+ const readTarget = async (): Promise<string | null> => {
159
+ const content = await readProjectFileContent(pid, targetPath);
160
+ if (projectIdRef.current !== expectedProjectId) {
161
+ throw new Error("Active project changed before the edit could be saved");
162
+ }
163
+ return options?.shouldSave && !options.shouldSave() ? null : content;
164
+ };
179
165
 
180
166
  // Validate layout values BEFORE any persist path runs. The SDK cutover
181
167
  // path (onTrySdkPersist) returns early on success, so leaving this check
@@ -195,7 +181,10 @@ export function useDomEditCommits({
195
181
  // Skip the SDK path when prepareContent is set (e.g. @font-face injection
196
182
  // for a custom font): sdkCutoverPersist serializes only the patched DOM
197
183
  // and would drop the injected content. Let the server path run prepareContent.
184
+ // The SDK joins the file queue itself and re-reads there; this read is only its fallback.
198
185
  if (onTrySdkPersist && !options?.prepareContent) {
186
+ const originalContent = await readTarget();
187
+ if (originalContent === null) return;
199
188
  const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
200
189
  label: options?.label,
201
190
  coalesceKey: options?.coalesceKey,
@@ -212,31 +201,41 @@ export function useDomEditCommits({
212
201
  }
213
202
  }
214
203
 
215
- const patchResponse = await fetch(
216
- buildProjectApiPath(pid, `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`),
217
- {
218
- method: "POST",
219
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
220
- body: JSON.stringify(patchBody),
221
- },
222
- );
223
- if (!patchResponse.ok) {
224
- showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
225
- throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, {
226
- alreadyToasted: true,
227
- });
228
- }
229
-
230
- const patchData = (await patchResponse.json()) as {
231
- ok?: boolean;
232
- changed?: boolean;
233
- matched?: boolean;
234
- content?: string;
235
- path?: string;
236
- version?: string;
204
+ const history = {
205
+ label: options?.label ?? "Edit layer",
206
+ coalesceKey: options?.coalesceKey,
207
+ coalesceMs: options?.coalesceMs,
237
208
  };
209
+ const prepare = options?.prepareContent;
210
+ // Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
211
+ const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
212
+ const originalContent = await readTarget();
213
+ if (originalContent === null) return null;
214
+ const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
215
+ if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
216
+
217
+ const patchedContent =
218
+ typeof patchData.content === "string" ? patchData.content : originalContent;
219
+ const finalContent = prepare
220
+ ? await writePreparedContent(
221
+ targetPath,
222
+ patchedContent,
223
+ prepare,
224
+ writeProjectFile,
225
+ showToast,
226
+ )
227
+ : patchedContent;
228
+
229
+ await editHistory.recordEdit({
230
+ ...history,
231
+ files: { [targetPath]: { before: originalContent, after: finalContent } },
232
+ });
233
+ return { patchData, patchedContent, finalContent };
234
+ });
235
+ if (saved === null) return;
236
+ const { patchData, patchedContent, finalContent } = saved;
238
237
 
239
- if (!patchData.changed) {
238
+ if (finalContent === null) {
240
239
  if (patchData.matched === false) {
241
240
  const targetKey = selection.selector ?? selection.id ?? "selection";
242
241
  if (!reportedUnresolvableRef.current.has(targetKey)) {
@@ -258,36 +257,6 @@ export function useDomEditCommits({
258
257
  false,
259
258
  );
260
259
  }
261
-
262
- const patchedContent =
263
- typeof patchData.content === "string" ? patchData.content : originalContent;
264
-
265
- let finalContent = patchedContent;
266
- if (options?.prepareContent) {
267
- const preparedContent = options.prepareContent(patchedContent, targetPath);
268
- if (preparedContent !== patchedContent) {
269
- try {
270
- await writeProjectFile(targetPath, preparedContent, patchedContent);
271
- finalContent = preparedContent;
272
- } catch (error) {
273
- // The patch above already landed on disk — only the prepareContent
274
- // embellishment (e.g. an injected @font-face) failed to write. Keep
275
- // the already-persisted patchedContent instead of throwing, which
276
- // would otherwise revert a change the server already committed.
277
- showToast(
278
- `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
279
- "error",
280
- );
281
- }
282
- }
283
- }
284
-
285
- await editHistory.recordEdit({
286
- label: options?.label ?? "Edit layer",
287
- coalesceKey: options?.coalesceKey,
288
- coalesceMs: options?.coalesceMs,
289
- files: { [targetPath]: { before: originalContent, after: finalContent } },
290
- });
291
260
  forceReloadSdkSession?.();
292
261
 
293
262
  if (!options?.skipRefresh) {
@@ -350,24 +319,38 @@ export function useDomEditCommits({
350
319
  );
351
320
  }
352
321
 
353
- const atomicResult = await patchElementBatches(pid, batches);
354
- const allMatched =
355
- atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
356
- const files = Object.fromEntries(
357
- atomicResult.files
358
- .filter((result) => result.changed)
359
- .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
322
+ const sourceFiles = batches.map((batch) => batch.sourceFile);
323
+ // The server patch and its history entry hold every touched file's queue.
324
+ const { allMatched, changed } = await serializeStudioFileMutations(
325
+ writeProjectFile,
326
+ sourceFiles,
327
+ async () => {
328
+ const atomicResult = await patchElementBatches(pid, batches);
329
+ const files = Object.fromEntries(
330
+ atomicResult.files
331
+ .filter((result) => result.changed)
332
+ .map((result) => [
333
+ result.sourceFile,
334
+ { before: result.before, after: result.after },
335
+ ]),
336
+ );
337
+ const anyChanged = Object.keys(files).length > 0;
338
+ if (anyChanged) {
339
+ await editHistory.recordEdit({
340
+ label: options.label,
341
+ coalesceKey: options.coalesceKey,
342
+ coalesceMs: options.coalesceMs,
343
+ files,
344
+ });
345
+ }
346
+ return {
347
+ allMatched:
348
+ atomicResult.durable && atomicResult.files.every((result) => result.allMatched),
349
+ changed: anyChanged,
350
+ };
351
+ },
360
352
  );
361
- const changed = Object.keys(files).length > 0;
362
- if (changed) {
363
- await editHistory.recordEdit({
364
- label: options.label,
365
- coalesceKey: options.coalesceKey,
366
- coalesceMs: options.coalesceMs,
367
- files,
368
- });
369
- forceReloadSdkSession?.();
370
- }
353
+ if (changed) forceReloadSdkSession?.();
371
354
  const durable = allMatched;
372
355
  // A z-only reorder already applied its inline styles to the live iframe
373
356
  // DOM (and the store) synchronously, so remounting the iframe here only
@@ -399,7 +382,15 @@ export function useDomEditCommits({
399
382
  throw error;
400
383
  });
401
384
  },
402
- [editHistory, forceReloadSdkSession, projectIdRef, queueDomEditSave, reloadPreview, showToast],
385
+ [
386
+ editHistory,
387
+ forceReloadSdkSession,
388
+ projectIdRef,
389
+ queueDomEditSave,
390
+ reloadPreview,
391
+ showToast,
392
+ writeProjectFile,
393
+ ],
403
394
  );
404
395
 
405
396
  // ── Text & style commits (delegated to useDomEditTextCommits) ──
@@ -1,4 +1,9 @@
1
- import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
1
+ import {
2
+ createStudioSaveHttpError,
3
+ StudioSaveHttpError,
4
+ trackStudioSaveFailure,
5
+ } from "../utils/studioSaveDiagnostics";
6
+ import { buildProjectApiPath } from "../utils/projectRouting";
2
7
  import type { DomEditPatchBatch } from "./domEditCommitTypes";
3
8
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
4
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
@@ -7,11 +12,11 @@ export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
7
12
  return fields.map((field) => field.path).join(", ");
8
13
  }
9
14
 
10
- export function getErrorDetail(error: unknown): string {
15
+ function getErrorDetail(error: unknown): string {
11
16
  return error instanceof Error ? error.message : String(error);
12
17
  }
13
18
 
14
- export async function readErrorResponseBody(
19
+ async function readErrorResponseBody(
15
20
  response: Response,
16
21
  ): Promise<{ error?: string; fields?: string[] } | null> {
17
22
  const contentType = response.headers.get("content-type") ?? "";
@@ -19,9 +24,7 @@ export async function readErrorResponseBody(
19
24
  return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
20
25
  }
21
26
 
22
- export function formatPatchRejectionMessage(
23
- body: { error?: string; fields?: string[] } | null,
24
- ): string {
27
+ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
25
28
  if (!body?.error) return "Couldn't save edit";
26
29
  return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
27
30
  }
@@ -157,3 +160,63 @@ export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean
157
160
  batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
158
161
  );
159
162
  }
163
+
164
+ export interface PatchElementResponse {
165
+ ok?: boolean;
166
+ changed?: boolean;
167
+ matched?: boolean;
168
+ content?: string;
169
+ path?: string;
170
+ version?: string;
171
+ }
172
+
173
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
174
+
175
+ export async function postPatchElement(
176
+ projectId: string,
177
+ targetPath: string,
178
+ body: unknown,
179
+ showToast: ShowToast,
180
+ ): Promise<PatchElementResponse> {
181
+ const response = await fetch(
182
+ buildProjectApiPath(
183
+ projectId,
184
+ `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
185
+ ),
186
+ {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
189
+ body: JSON.stringify(body),
190
+ },
191
+ );
192
+ if (!response.ok) {
193
+ showToast(formatPatchRejectionMessage(await readErrorResponseBody(response)), "error");
194
+ throw await createStudioSaveHttpError(response, `Failed to patch ${targetPath}`, {
195
+ alreadyToasted: true,
196
+ });
197
+ }
198
+ return (await response.json()) as PatchElementResponse;
199
+ }
200
+
201
+ /** Writes `prepare`'s embellishment over the server's patch; returns what the file ends up holding. */
202
+ export async function writePreparedContent(
203
+ targetPath: string,
204
+ patchedContent: string,
205
+ prepare: (html: string, sourceFile: string) => string,
206
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
207
+ showToast: ShowToast,
208
+ ): Promise<string> {
209
+ const preparedContent = prepare(patchedContent, targetPath);
210
+ if (preparedContent === patchedContent) return patchedContent;
211
+ try {
212
+ await writeProjectFile(targetPath, preparedContent, patchedContent);
213
+ return preparedContent;
214
+ } catch (error) {
215
+ // The patch already landed on disk; keep it rather than revert a committed change.
216
+ showToast(
217
+ `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
218
+ "error",
219
+ );
220
+ return patchedContent;
221
+ }
222
+ }
@@ -107,7 +107,7 @@ export function useEditorSave({
107
107
  projectId: candidate.projectId,
108
108
  label: "Edit source",
109
109
  coalesceKey: `source:${candidate.path}`,
110
- files: { [candidate.path]: candidate.content },
110
+ files: { [candidate.path]: () => candidate.content },
111
111
  readFile: readProjectFile,
112
112
  writeFile: writeProjectFile,
113
113
  recordEdit,