@hyperframes/studio 0.7.28 → 0.7.30

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 (46) hide show
  1. package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
  2. package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
  3. package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
  4. package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
  5. package/dist/chunk-JND3XUJL.js.map +1 -0
  6. package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1145 -675
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/components/editor/DomEditOverlay.test.ts +95 -0
  13. package/src/components/editor/DomEditOverlay.tsx +5 -3
  14. package/src/components/editor/domEditOverlayGestures.ts +3 -4
  15. package/src/components/editor/domEditing.ts +1 -0
  16. package/src/components/editor/domEditingLayers.test.ts +52 -0
  17. package/src/components/editor/domEditingLayers.ts +55 -19
  18. package/src/components/editor/domEditingTypes.ts +1 -0
  19. package/src/components/editor/persistSeam.integration.test.ts +264 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
  21. package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
  22. package/src/hooks/domEditCommitRunner.ts +46 -0
  23. package/src/hooks/domEditPersistFailure.test.ts +123 -0
  24. package/src/hooks/domEditPersistFailure.ts +89 -0
  25. package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
  26. package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
  27. package/src/hooks/domSelectionTestHarness.ts +40 -0
  28. package/src/hooks/useDomEditAttributeCommits.ts +227 -0
  29. package/src/hooks/useDomEditCommits.test.tsx +775 -0
  30. package/src/hooks/useDomEditCommits.ts +33 -5
  31. package/src/hooks/useDomEditTextCommits.ts +243 -220
  32. package/src/hooks/useDomSelection.test.ts +134 -0
  33. package/src/hooks/useDomSelection.ts +29 -15
  34. package/src/hooks/usePreviewInteraction.test.ts +260 -0
  35. package/src/hooks/usePreviewInteraction.ts +60 -19
  36. package/src/utils/sdkCutoverEligibility.test.ts +17 -0
  37. package/src/utils/sdkCutoverEligibility.ts +8 -2
  38. package/src/utils/sdkResolverShadow.test.ts +180 -1
  39. package/src/utils/sdkResolverShadow.ts +94 -1
  40. package/src/utils/sourcePatcher.ts +2 -0
  41. package/src/utils/studioPreviewHelpers.test.ts +95 -1
  42. package/src/utils/studioPreviewHelpers.ts +109 -10
  43. package/src/utils/studioSaveDiagnostics.ts +5 -2
  44. package/src/utils/studioTelemetry.ts +27 -20
  45. package/dist/chunk-AN2EWWK3.js.map +0 -1
  46. /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
@@ -10,6 +10,11 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
10
10
  import type { EditHistoryKind } from "../utils/editHistory";
11
11
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
12
12
  import type { PatchOperation } from "../utils/sourcePatcher";
13
+ import {
14
+ DomEditPersistUnsafeValueError,
15
+ DomEditPersistUnresolvableError,
16
+ warnDomEditPersistNoOp,
17
+ } from "./domEditPersistFailure";
13
18
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
14
19
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
15
20
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
@@ -22,6 +27,10 @@ function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
22
27
  return fields.map((field) => field.path).join(", ");
23
28
  }
24
29
 
30
+ function getErrorDetail(error: unknown): string {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+
25
34
  async function readErrorResponseBody(
26
35
  response: Response,
27
36
  ): Promise<{ error?: string; fields?: string[] } | null> {
@@ -168,7 +177,9 @@ export function useDomEditCommits({
168
177
  if (unsafeFields.length > 0) {
169
178
  const fields = formatUnsafeFieldList(unsafeFields);
170
179
  showToast("Couldn't save edit because it contains invalid layout values", "error");
171
- throw new Error(`DOM patch contains unsafe values: ${fields}`);
180
+ throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
181
+ alreadyToasted: true,
182
+ });
172
183
  }
173
184
 
174
185
  // Skip the SDK path when prepareContent is set (e.g. @font-face injection
@@ -203,7 +214,9 @@ export function useDomEditCommits({
203
214
  );
204
215
  if (!patchResponse.ok) {
205
216
  showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
206
- throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
217
+ throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, {
218
+ alreadyToasted: true,
219
+ });
207
220
  }
208
221
 
209
222
  const patchData = (await patchResponse.json()) as {
@@ -225,7 +238,9 @@ export function useDomEditCommits({
225
238
  composition: activeCompPath ?? undefined,
226
239
  });
227
240
  }
241
+ throw new DomEditPersistUnresolvableError(targetPath);
228
242
  }
243
+ warnDomEditPersistNoOp(selection, operations);
229
244
  return;
230
245
  }
231
246
 
@@ -234,9 +249,21 @@ export function useDomEditCommits({
234
249
 
235
250
  let finalContent = patchedContent;
236
251
  if (options?.prepareContent) {
237
- finalContent = options.prepareContent(patchedContent, targetPath);
238
- if (finalContent !== patchedContent) {
239
- await writeProjectFile(targetPath, finalContent);
252
+ const preparedContent = options.prepareContent(patchedContent, targetPath);
253
+ if (preparedContent !== patchedContent) {
254
+ try {
255
+ await writeProjectFile(targetPath, preparedContent);
256
+ finalContent = preparedContent;
257
+ } catch (error) {
258
+ // The patch above already landed on disk — only the prepareContent
259
+ // embellishment (e.g. an injected @font-face) failed to write. Keep
260
+ // the already-persisted patchedContent instead of throwing, which
261
+ // would otherwise revert a change the server already committed.
262
+ showToast(
263
+ `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
264
+ "error",
265
+ );
266
+ }
240
267
  }
241
268
  }
242
269
 
@@ -286,6 +313,7 @@ export function useDomEditCommits({
286
313
  buildDomSelectionFromTarget,
287
314
  persistDomEditOperations,
288
315
  resolveImportedFontAsset,
316
+ showToast,
289
317
  });
290
318
 
291
319
  // ── Position patch helper (shared by geometry + lifecycle hooks) ──
@@ -23,12 +23,24 @@ import {
23
23
  } from "../components/editor/domEditing";
24
24
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
25
25
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
26
+ import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
27
+ import {
28
+ DomEditPersistUnsupportedTextStructureError,
29
+ reportDomEditPersistFailure,
30
+ } from "./domEditPersistFailure";
31
+ import {
32
+ bumpDomEditCommitMapVersion,
33
+ bumpDomEditCommitVersion,
34
+ runDomEditCommit,
35
+ } from "./domEditCommitRunner";
36
+ import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
26
37
 
27
38
  // ── Types ──
28
39
 
29
40
  export interface UseDomEditTextCommitsParams {
30
41
  activeCompPath: string | null;
31
42
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
43
+ showToast: (message: string, tone?: "error" | "info") => void;
32
44
  domEditSelection: DomEditSelection | null;
33
45
  applyDomSelection: (
34
46
  selection: DomEditSelection | null,
@@ -43,31 +55,78 @@ export interface UseDomEditTextCommitsParams {
43
55
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
44
56
  }
45
57
 
46
- function applyPreviewAttribute(
58
+ interface DomTextCommitPlan {
59
+ usesSerializedTextFields: boolean;
60
+ nextContent: string;
61
+ childOperations: PatchOperation[] | null;
62
+ operations: PatchOperation[];
63
+ }
64
+
65
+ function buildDomStyleCommitOperations(
66
+ property: string,
67
+ value: string,
68
+ isImageBackgroundCommit: boolean,
69
+ ): PatchOperation[] {
70
+ const operations: PatchOperation[] = [
71
+ buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
72
+ ];
73
+ if (isImageBackgroundCommit) {
74
+ operations.push(
75
+ buildDomEditStylePatchOperation("background-position", "center"),
76
+ buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
77
+ buildDomEditStylePatchOperation("background-size", "contain"),
78
+ );
79
+ }
80
+ return operations;
81
+ }
82
+
83
+ function buildNextDomTextFields(
84
+ textFields: DomEditTextField[],
85
+ value: string,
86
+ fieldKey?: string,
87
+ ): DomEditTextField[] {
88
+ if (textFields.length === 0) return [];
89
+ return textFields.map((field) => (field.key === fieldKey ? { ...field, value } : field));
90
+ }
91
+
92
+ function planDomTextCommit(
93
+ originalTextFields: DomEditTextField[],
94
+ nextTextFields: DomEditTextField[],
95
+ plainTextContent: string,
96
+ ): DomTextCommitPlan {
97
+ const usesSerializedTextFields =
98
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child");
99
+ const nextContent = usesSerializedTextFields
100
+ ? serializeDomEditTextFields(nextTextFields)
101
+ : plainTextContent;
102
+ const childOperations = usesSerializedTextFields
103
+ ? buildTextFieldChildOperations(originalTextFields, nextTextFields)
104
+ : null;
105
+ const operations =
106
+ childOperations ??
107
+ (usesSerializedTextFields ? [] : [buildDomEditTextPatchOperation(nextContent)]);
108
+
109
+ return {
110
+ usesSerializedTextFields,
111
+ nextContent,
112
+ childOperations,
113
+ operations,
114
+ };
115
+ }
116
+
117
+ async function resyncDomTextSelectionFromPreview(
47
118
  doc: Document | null | undefined,
48
119
  selection: DomEditSelection,
49
120
  activeCompPath: string | null,
50
- attr: string,
51
- value: string | null,
52
- options: { prefixData?: boolean; removeFalse?: boolean } = {},
53
- ): void {
121
+ buildDomSelectionFromTarget: UseDomEditTextCommitsParams["buildDomSelectionFromTarget"],
122
+ applyDomSelection: UseDomEditTextCommitsParams["applyDomSelection"],
123
+ ): Promise<void> {
54
124
  if (!doc) return;
55
- const el = findElementForSelection(doc, selection, activeCompPath);
56
- if (!el) return;
57
- const fullAttr = options.prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
58
- if (value === null || value === "" || (options.removeFalse && value === "false")) {
59
- el.removeAttribute(fullAttr);
60
- } else {
61
- el.setAttribute(fullAttr, value);
62
- }
63
- }
64
-
65
- interface DataAttributeCommitOptions {
66
- label: string;
67
- coalescePrefix: string;
68
- skipRefresh: boolean;
69
- warningMessage: string;
70
- refreshAfter?: boolean;
125
+ const refreshed = findElementForSelection(doc, selection, activeCompPath);
126
+ if (!refreshed) return;
127
+ const nextSelection = await buildDomSelectionFromTarget(refreshed);
128
+ if (!nextSelection) return;
129
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
71
130
  }
72
131
 
73
132
  // ── Hook ──
@@ -75,6 +134,7 @@ interface DataAttributeCommitOptions {
75
134
  export function useDomEditTextCommits({
76
135
  activeCompPath,
77
136
  previewIframeRef,
137
+ showToast,
78
138
  domEditSelection,
79
139
  applyDomSelection,
80
140
  refreshDomEditSelectionFromPreview,
@@ -83,151 +143,91 @@ export function useDomEditTextCommits({
83
143
  resolveImportedFontAsset,
84
144
  }: UseDomEditTextCommitsParams) {
85
145
  const domTextCommitVersionRef = useRef(0);
146
+ const domStyleCommitVersionRef = useRef(new Map<string, number>());
147
+
148
+ const { handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit } =
149
+ useDomEditAttributeCommits({
150
+ activeCompPath,
151
+ previewIframeRef,
152
+ showToast,
153
+ domEditSelection,
154
+ refreshDomEditSelectionFromPreview,
155
+ persistDomEditOperations,
156
+ });
86
157
 
87
158
  const handleDomStyleCommit = useCallback(
88
159
  async (property: string, value: string) => {
89
160
  if (!domEditSelection) return;
90
161
  if (isManualGeometryStyleProperty(property)) return;
91
162
  if (!domEditSelection.capabilities.canEditStyles) return;
163
+ const styleCommitKey = `${getDomEditTargetKey(domEditSelection)}:${property}`;
164
+ const isLatestStyleCommit = bumpDomEditCommitMapVersion(
165
+ domStyleCommitVersionRef.current,
166
+ styleCommitKey,
167
+ );
92
168
  const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
93
169
  const iframe = previewIframeRef.current;
94
170
  const doc = iframe?.contentDocument;
95
- if (doc) {
96
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
97
- if (el) {
98
- el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
99
- if (property === "font-family") {
171
+ const normalizedValue = normalizeDomEditStyleValue(property, value);
172
+ const isImageBackgroundCommit =
173
+ property === "background-image" && isImageBackgroundValue(value);
174
+ let editedElement: HTMLElement | null = null;
175
+ let previousInlineValue: string | null = null;
176
+ const operations = buildDomStyleCommitOperations(property, value, isImageBackgroundCommit);
177
+ const skipRefresh = property !== "z-index";
178
+
179
+ await runDomEditCommit({
180
+ capture: () => {
181
+ if (!doc) return;
182
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
183
+ if (!el) return;
184
+ editedElement = el;
185
+ previousInlineValue = el.style.getPropertyValue(property);
186
+ },
187
+ apply: () => {
188
+ if (!editedElement) return;
189
+ editedElement.style.setProperty(property, normalizedValue);
190
+ if (property === "font-family" && doc) {
100
191
  injectPreviewGoogleFont(doc, value);
101
192
  if (importedFont) injectPreviewImportedFont(doc, importedFont);
102
193
  }
103
- if (property === "background-image" && isImageBackgroundValue(value)) {
104
- el.style.setProperty("background-position", "center");
105
- el.style.setProperty("background-repeat", "no-repeat");
106
- el.style.setProperty("background-size", "contain");
194
+ if (isImageBackgroundCommit) {
195
+ editedElement.style.setProperty("background-position", "center");
196
+ editedElement.style.setProperty("background-repeat", "no-repeat");
197
+ editedElement.style.setProperty("background-size", "contain");
107
198
  }
108
- }
109
- }
110
- const operations: PatchOperation[] = [
111
- buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
112
- ];
113
- if (property === "background-image" && isImageBackgroundValue(value)) {
114
- operations.push(
115
- buildDomEditStylePatchOperation("background-position", "center"),
116
- buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
117
- buildDomEditStylePatchOperation("background-size", "contain"),
118
- );
119
- }
120
- const skipRefresh = property !== "z-index";
121
- try {
122
- await persistDomEditOperations(domEditSelection, operations, {
123
- label: "Edit layer style",
124
- skipRefresh,
125
- prepareContent: importedFont
126
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
127
- : undefined,
128
- });
129
- } catch {}
130
- refreshDomEditSelectionFromPreview(domEditSelection);
131
- },
132
- [
133
- activeCompPath,
134
- domEditSelection,
135
- persistDomEditOperations,
136
- refreshDomEditSelectionFromPreview,
137
- resolveImportedFontAsset,
138
- previewIframeRef,
139
- ],
140
- );
141
-
142
- const commitDataAttribute = useCallback(
143
- async (attr: string, value: string | null, options: DataAttributeCommitOptions) => {
144
- if (!domEditSelection) return;
145
- const iframe = previewIframeRef.current;
146
- applyPreviewAttribute(
147
- iframe?.contentDocument,
148
- domEditSelection,
149
- activeCompPath,
150
- attr,
151
- value,
152
- {
153
- prefixData: true,
154
199
  },
155
- );
156
- const op: PatchOperation = { type: "attribute", property: attr, value };
157
- try {
158
- await persistDomEditOperations(domEditSelection, [op], {
159
- label: options.label,
160
- coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
161
- skipRefresh: options.skipRefresh,
162
- });
163
- } catch {}
164
- if (options.refreshAfter) {
165
- refreshDomEditSelectionFromPreview(domEditSelection);
166
- }
167
- },
168
- [
169
- activeCompPath,
170
- domEditSelection,
171
- persistDomEditOperations,
172
- refreshDomEditSelectionFromPreview,
173
- previewIframeRef,
174
- ],
175
- );
176
-
177
- const handleDomAttributeCommit = useCallback(
178
- async (attr: string, value: string) => {
179
- await commitDataAttribute(attr, value, {
180
- label: `Edit ${attr.replace(/-/g, " ")}`,
181
- coalescePrefix: "attr",
182
- skipRefresh: false,
183
- warningMessage: "[Studio] Attribute persist failed:",
184
- refreshAfter: true,
185
- });
186
- },
187
- [commitDataAttribute],
188
- );
189
-
190
- const handleDomAttributeLiveCommit = useCallback(
191
- async (attr: string, value: string | null) => {
192
- await commitDataAttribute(attr, value, {
193
- label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
194
- coalescePrefix: "attr-live",
195
- skipRefresh: true,
196
- warningMessage: "[Studio] Live attribute persist failed:",
197
- });
198
- },
199
- [commitDataAttribute],
200
- );
201
-
202
- const handleDomHtmlAttributeCommit = useCallback(
203
- async (attr: string, value: string | null) => {
204
- if (!domEditSelection) return;
205
- const iframe = previewIframeRef.current;
206
- applyPreviewAttribute(
207
- iframe?.contentDocument,
208
- domEditSelection,
209
- activeCompPath,
210
- attr,
211
- value,
212
- {
213
- removeFalse: true,
200
+ persist: () =>
201
+ persistDomEditOperations(domEditSelection, operations, {
202
+ label: "Edit layer style",
203
+ skipRefresh,
204
+ prepareContent: importedFont
205
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
206
+ : undefined,
207
+ }),
208
+ shouldRevert: () => isLatestStyleCommit(),
209
+ revert: () => {
210
+ if (!editedElement || previousInlineValue === null) return;
211
+ // ponytail: background-image side-effect styles are not reverted here.
212
+ if (previousInlineValue === "") {
213
+ editedElement.style.removeProperty(property);
214
+ } else {
215
+ editedElement.style.setProperty(property, previousInlineValue);
216
+ }
214
217
  },
215
- );
216
- const op: PatchOperation = { type: "html-attribute", property: attr, value };
217
- try {
218
- await persistDomEditOperations(domEditSelection, [op], {
219
- label: `Edit ${attr}`,
220
- coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
221
- skipRefresh: false,
222
- });
223
- } catch {}
224
- refreshDomEditSelectionFromPreview(domEditSelection);
218
+ onError: (error) =>
219
+ reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
220
+ shouldResync: isLatestStyleCommit,
221
+ resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
222
+ });
225
223
  },
226
224
  [
227
225
  activeCompPath,
228
226
  domEditSelection,
229
227
  persistDomEditOperations,
230
228
  refreshDomEditSelectionFromPreview,
229
+ resolveImportedFontAsset,
230
+ showToast,
231
231
  previewIframeRef,
232
232
  ],
233
233
  );
@@ -236,53 +236,57 @@ export function useDomEditTextCommits({
236
236
  async (value: string, fieldKey?: string) => {
237
237
  if (!domEditSelection) return;
238
238
  if (!isTextEditableSelection(domEditSelection)) return;
239
- const commitVersion = domTextCommitVersionRef.current + 1;
240
- domTextCommitVersionRef.current = commitVersion;
241
- const nextTextFields =
242
- domEditSelection.textFields.length > 0
243
- ? domEditSelection.textFields.map((field) =>
244
- field.key === fieldKey ? { ...field, value } : field,
245
- )
246
- : [];
247
- const nextContent =
248
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
249
- ? serializeDomEditTextFields(nextTextFields)
250
- : value;
239
+ const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
240
+ const nextTextFields = buildNextDomTextFields(domEditSelection.textFields, value, fieldKey);
241
+ const textCommit = planDomTextCommit(domEditSelection.textFields, nextTextFields, value);
251
242
  const iframe = previewIframeRef.current;
252
243
  const doc = iframe?.contentDocument;
253
- if (doc) {
254
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
255
- if (el) {
256
- if (
257
- nextTextFields.length > 1 ||
258
- nextTextFields.some((field) => field.source === "child")
259
- ) {
260
- el.innerHTML = nextContent;
244
+ let editedElement: HTMLElement | null = null;
245
+ let previousInnerHtml: string | null = null;
246
+
247
+ await runDomEditCommit({
248
+ capture: () => {
249
+ if (!doc) return;
250
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
251
+ if (!el) return;
252
+ editedElement = el;
253
+ previousInnerHtml = el.innerHTML;
254
+ },
255
+ apply: () => {
256
+ if (!editedElement) return;
257
+ if (textCommit.usesSerializedTextFields) {
258
+ editedElement.innerHTML = textCommit.nextContent;
261
259
  } else {
262
- el.textContent = value;
260
+ editedElement.textContent = value;
263
261
  }
264
- }
265
- }
266
- await persistDomEditOperations(
267
- domEditSelection,
268
- [buildDomEditTextPatchOperation(nextContent)],
269
- {
270
- label: "Edit text",
271
- skipRefresh: true,
272
- shouldSave: () => domTextCommitVersionRef.current === commitVersion,
273
262
  },
274
- );
275
- if (domTextCommitVersionRef.current !== commitVersion) return;
276
-
277
- if (doc) {
278
- const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
279
- if (refreshed) {
280
- const nextSelection = await buildDomSelectionFromTarget(refreshed);
281
- if (nextSelection) {
282
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
263
+ persist: async () => {
264
+ if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
265
+ throw new DomEditPersistUnsupportedTextStructureError();
283
266
  }
284
- }
285
- }
267
+ await persistDomEditOperations(domEditSelection, textCommit.operations, {
268
+ label: "Edit text",
269
+ skipRefresh: true,
270
+ shouldSave: isLatestTextCommit,
271
+ });
272
+ },
273
+ shouldRevert: () => isLatestTextCommit(),
274
+ revert: () => {
275
+ if (!editedElement || previousInnerHtml === null) return;
276
+ editedElement.innerHTML = previousInnerHtml;
277
+ },
278
+ onError: (error) =>
279
+ reportDomEditPersistFailure(domEditSelection, textCommit.operations, error, showToast),
280
+ shouldResync: isLatestTextCommit,
281
+ resync: () =>
282
+ resyncDomTextSelectionFromPreview(
283
+ doc,
284
+ domEditSelection,
285
+ activeCompPath,
286
+ buildDomSelectionFromTarget,
287
+ applyDomSelection,
288
+ ),
289
+ });
286
290
  },
287
291
  [
288
292
  activeCompPath,
@@ -291,6 +295,7 @@ export function useDomEditTextCommits({
291
295
  domEditSelection,
292
296
  persistDomEditOperations,
293
297
  previewIframeRef,
298
+ showToast,
294
299
  ],
295
300
  );
296
301
 
@@ -300,45 +305,62 @@ export function useDomEditTextCommits({
300
305
  nextTextFields: DomEditTextField[],
301
306
  options?: { importedFont?: ImportedFontAsset | null },
302
307
  ) => {
303
- const nextContent =
304
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
305
- ? serializeDomEditTextFields(nextTextFields)
306
- : (nextTextFields[0]?.value ?? "");
307
-
308
+ const textCommit = planDomTextCommit(
309
+ selection.textFields,
310
+ nextTextFields,
311
+ nextTextFields[0]?.value ?? "",
312
+ );
308
313
  const iframe = previewIframeRef.current;
309
314
  const doc = iframe?.contentDocument;
310
- if (doc) {
311
- const el = findElementForSelection(doc, selection, activeCompPath);
312
- if (el) {
313
- if (
314
- nextTextFields.length > 1 ||
315
- nextTextFields.some((field) => field.source === "child")
316
- ) {
317
- el.innerHTML = nextContent;
318
- } else {
319
- el.textContent = nextContent;
320
- }
321
- }
322
- }
323
-
315
+ let editedElement: HTMLElement | null = null;
316
+ let previousInnerHtml: string | null = null;
324
317
  const importedFont = options?.importedFont ?? null;
325
- await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
326
- label: "Edit text",
327
- skipRefresh: true,
328
- prepareContent: importedFont
329
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
330
- : undefined,
331
- });
332
318
 
333
- if (doc) {
334
- const refreshed = findElementForSelection(doc, selection, activeCompPath);
335
- if (refreshed) {
336
- const nextSelection = await buildDomSelectionFromTarget(refreshed);
337
- if (nextSelection) {
338
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
319
+ await runDomEditCommit({
320
+ capture: () => {
321
+ if (!doc) return;
322
+ const el = findElementForSelection(doc, selection, activeCompPath);
323
+ if (!el) return;
324
+ editedElement = el;
325
+ previousInnerHtml = el.innerHTML;
326
+ },
327
+ apply: () => {
328
+ if (!editedElement) return;
329
+ if (textCommit.usesSerializedTextFields) {
330
+ editedElement.innerHTML = textCommit.nextContent;
331
+ } else {
332
+ editedElement.textContent = textCommit.nextContent;
339
333
  }
340
- }
341
- }
334
+ },
335
+ persist: async () => {
336
+ if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
337
+ throw new DomEditPersistUnsupportedTextStructureError();
338
+ }
339
+ await persistDomEditOperations(selection, textCommit.operations, {
340
+ label: "Edit text",
341
+ skipRefresh: true,
342
+ prepareContent: importedFont
343
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
344
+ : undefined,
345
+ });
346
+ },
347
+ shouldRevert: () => true,
348
+ revert: () => {
349
+ if (!editedElement || previousInnerHtml === null) return;
350
+ editedElement.innerHTML = previousInnerHtml;
351
+ },
352
+ onError: (error) =>
353
+ reportDomEditPersistFailure(selection, textCommit.operations, error, showToast),
354
+ shouldResync: () => true,
355
+ resync: () =>
356
+ resyncDomTextSelectionFromPreview(
357
+ doc,
358
+ selection,
359
+ activeCompPath,
360
+ buildDomSelectionFromTarget,
361
+ applyDomSelection,
362
+ ),
363
+ });
342
364
  },
343
365
  [
344
366
  activeCompPath,
@@ -346,6 +368,7 @@ export function useDomEditTextCommits({
346
368
  buildDomSelectionFromTarget,
347
369
  persistDomEditOperations,
348
370
  previewIframeRef,
371
+ showToast,
349
372
  ],
350
373
  );
351
374