@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
@@ -0,0 +1,264 @@
1
+ // @vitest-environment jsdom
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ patchElementInHtml,
7
+ type PatchOperation,
8
+ type SourceMutationTarget,
9
+ } from "@hyperframes/studio-server/source-mutation";
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ collectDomEditTextFields,
13
+ buildDomEditPatchTarget,
14
+ buildDomEditStylePatchOperation,
15
+ buildDomEditTextPatchOperation,
16
+ } from "./domEditingLayers";
17
+ import { buildPathOffsetPatches } from "./manualEditsDomPatches";
18
+ import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
19
+ import { makeSelection } from "../../hooks/domSelectionTestHarness";
20
+ import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";
21
+
22
+ const testDir = dirname(fileURLToPath(import.meta.url));
23
+ const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa");
24
+
25
+ function readFixture(relativePath: string): string {
26
+ return readFileSync(join(fixtureDir, relativePath), "utf-8");
27
+ }
28
+
29
+ function createSelection(input: {
30
+ id: string;
31
+ hfId: string;
32
+ tagName: string;
33
+ }): ReturnType<typeof makeSelection> {
34
+ const element = document.createElement(input.tagName);
35
+ element.id = input.id;
36
+ element.setAttribute("data-hf-id", input.hfId);
37
+ return {
38
+ ...makeSelection(input.id, element),
39
+ hfId: input.hfId,
40
+ };
41
+ }
42
+
43
+ function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget {
44
+ return buildDomEditPatchTarget(createSelection(input));
45
+ }
46
+
47
+ function patchAndExpectChange(
48
+ sourceHtml: string,
49
+ target: SourceMutationTarget,
50
+ operations: PatchOperation[],
51
+ ): string {
52
+ const result = patchElementInHtml(sourceHtml, target, operations);
53
+ expect(result.matched).toBe(true);
54
+ expect(result.html).not.toBe(sourceHtml);
55
+ return result.html;
56
+ }
57
+
58
+ function parseHtml(html: string): Document {
59
+ return new DOMParser().parseFromString(html, "text/html");
60
+ }
61
+
62
+ function findElementInHtml(html: string, selector: string): Element {
63
+ const document = parseHtml(html);
64
+ const directMatch = document.querySelector(selector);
65
+ if (directMatch) return directMatch;
66
+
67
+ for (const template of Array.from(document.querySelectorAll("template"))) {
68
+ const templateMatch = template.content.querySelector(selector);
69
+ if (templateMatch) return templateMatch;
70
+ }
71
+
72
+ throw new Error(`Expected selector ${selector} to match`);
73
+ }
74
+
75
+ function findByHfId(html: string, hfId: string): Element {
76
+ return findElementInHtml(html, `[data-hf-id="${hfId}"]`);
77
+ }
78
+
79
+ function countOccurrences(value: string, needle: string): number {
80
+ return value.split(needle).length - 1;
81
+ }
82
+
83
+ describe("persist seam source mutation", () => {
84
+ const indexHtml = readFixture("index.html");
85
+ const subHtml = readFixture("compositions/qa-sub.html");
86
+
87
+ it("persists qa-headline text font-size style operation", () => {
88
+ const html = patchAndExpectChange(
89
+ indexHtml,
90
+ clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }),
91
+ [buildDomEditStylePatchOperation("font-size", "64px")],
92
+ );
93
+
94
+ expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px");
95
+ });
96
+
97
+ it("persists qa-shape fill style operation", () => {
98
+ const html = patchAndExpectChange(
99
+ indexHtml,
100
+ clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
101
+ [buildDomEditStylePatchOperation("background-color", "#ff0000")],
102
+ );
103
+
104
+ expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain(
105
+ "background-color: #ff0000",
106
+ );
107
+ });
108
+
109
+ it("persists qa-multi text color style operation", () => {
110
+ const html = patchAndExpectChange(
111
+ indexHtml,
112
+ clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }),
113
+ [buildDomEditStylePatchOperation("color", "#00ff00")],
114
+ );
115
+
116
+ expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00");
117
+ });
118
+
119
+ it("persists qa-image opacity style operation", () => {
120
+ const html = patchAndExpectChange(
121
+ indexHtml,
122
+ clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }),
123
+ [buildDomEditStylePatchOperation("opacity", "0.4")],
124
+ );
125
+
126
+ expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
127
+ });
128
+
129
+ it("persists detached jsdom path offset operations", () => {
130
+ const element = document.createElement("div");
131
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");
132
+
133
+ const html = patchAndExpectChange(
134
+ indexHtml,
135
+ clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
136
+ buildPathOffsetPatches(element),
137
+ );
138
+ const shape = findByHfId(html, "qa-shape");
139
+
140
+ expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
141
+ expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
142
+ expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
143
+ });
144
+
145
+ it("persists timeline data-start attribute operation", () => {
146
+ const html = patchAndExpectChange(
147
+ indexHtml,
148
+ clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }),
149
+ [{ type: "attribute", property: "start", value: "2.5" }],
150
+ );
151
+
152
+ expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5");
153
+ expect(countOccurrences(html, 'data-start="2.5"')).toBe(1);
154
+ });
155
+
156
+ it("persists media volume data attribute operation", () => {
157
+ const html = patchAndExpectChange(
158
+ indexHtml,
159
+ clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }),
160
+ [{ type: "attribute", property: "volume", value: "0.75" }],
161
+ );
162
+
163
+ expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75");
164
+ expect(html).not.toContain('data-volume="0.5"');
165
+ });
166
+
167
+ it("returns matched false and unchanged html for a missing hfId target", () => {
168
+ const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [
169
+ buildDomEditStylePatchOperation("font-size", "64px"),
170
+ ]);
171
+
172
+ expect(result.matched).toBe(false);
173
+ expect(result.html).toBe(indexHtml);
174
+ });
175
+
176
+ it("persists sub-composition child style operation inside a template", () => {
177
+ const html = patchAndExpectChange(
178
+ subHtml,
179
+ clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }),
180
+ [buildDomEditStylePatchOperation("font-size", "50px")],
181
+ );
182
+
183
+ expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px");
184
+ });
185
+
186
+ it("returns matched false for runtime-generated caption words absent from static source", () => {
187
+ const result = patchElementInHtml(
188
+ indexHtml,
189
+ { selector: "#qa-caption-host span", selectorIndex: 0 },
190
+ [buildDomEditStylePatchOperation("color", "#ffffff")],
191
+ );
192
+
193
+ expect(result.matched).toBe(false);
194
+ expect(result.html).toBe(indexHtml);
195
+ });
196
+
197
+ it("fixes U4: child text-field style persists as an inline style on the correct child span", () => {
198
+ const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
199
+ buildDomEditStylePatchOperation("color", "#0000ff", {
200
+ childSelector: ":scope > span",
201
+ childIndex: 0,
202
+ }),
203
+ ]);
204
+
205
+ const lineA = findElementInHtml(html, ".qa-line-a");
206
+ const lineB = findElementInHtml(html, ".qa-line-b");
207
+ expect(lineA.getAttribute("style")).toContain("color: #0000ff");
208
+ expect(lineB.getAttribute("style")).toBeNull();
209
+ expect(lineB.textContent).toBe("Second styled line");
210
+ expect(html).not.toContain("&lt;span");
211
+ });
212
+
213
+ it("targets the second direct child when siblings share the same tag and class", () => {
214
+ const source = `<div data-hf-id="dups"><span class="dup">First</span><span class="dup">Second</span></div>`;
215
+ const html = patchAndExpectChange(source, { hfId: "dups" }, [
216
+ buildDomEditStylePatchOperation("color", "#0000ff", {
217
+ childSelector: ":scope > span",
218
+ childIndex: 1,
219
+ }),
220
+ ]);
221
+
222
+ const document = parseHtml(html);
223
+ const spans = Array.from(document.querySelectorAll(".dup"));
224
+ expect(spans[0]?.getAttribute("style")).toBeNull();
225
+ expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff");
226
+ });
227
+
228
+ it("persists a child text-field content edit as plain text", () => {
229
+ const value = "A < B & C";
230
+ const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
231
+ buildDomEditTextPatchOperation(value, {
232
+ childSelector: ":scope > span",
233
+ childIndex: 0,
234
+ }),
235
+ ]);
236
+
237
+ expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value);
238
+ expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line");
239
+ expect(html).not.toContain("&lt;span");
240
+ });
241
+
242
+ it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => {
243
+ const source = `<div data-hf-id="mixed"><span class="leaf-a">First</span><span class="wrapper"><b>Wrapper</b></span><span class="leaf-b">Second</span></div>`;
244
+ const previewHost = document.createElement("div");
245
+ previewHost.innerHTML = source;
246
+ const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]');
247
+ if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target");
248
+
249
+ const originalFields = collectDomEditTextFields(previewTarget);
250
+ const secondField = originalFields.find((field) => field.value === "Second");
251
+ if (!secondField) throw new Error("Expected second text field");
252
+ const nextFields = originalFields.map((field) =>
253
+ field.key === secondField.key ? { ...field, value: "Second updated" } : field,
254
+ );
255
+ const operations = buildTextFieldChildOperations(originalFields, nextFields);
256
+ if (!operations) throw new Error("Expected child operations");
257
+
258
+ const html = patchAndExpectChange(source, { hfId: "mixed" }, operations);
259
+
260
+ expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First");
261
+ expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper");
262
+ expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated");
263
+ });
264
+ });
@@ -371,7 +371,10 @@ export function Section({
371
371
  );
372
372
 
373
373
  return (
374
- <section className="min-w-0 border-t border-panel-border">
374
+ <section
375
+ className="min-w-0 border-t border-panel-border"
376
+ data-panel-section={slugifyPanelSectionTitle(title)}
377
+ >
375
378
  <div className="flex w-full items-center gap-2 px-4 py-2.5">
376
379
  <button
377
380
  type="button"
@@ -387,3 +390,14 @@ export function Section({
387
390
  </section>
388
391
  );
389
392
  }
393
+
394
+ // Stable hook for e2e/automation to locate a section without depending on the
395
+ // display copy (h3 textContent matching breaks on wording tweaks or, if this
396
+ // panel is ever localized, on translation).
397
+ function slugifyPanelSectionTitle(title: string): string {
398
+ return title
399
+ .trim()
400
+ .toLowerCase()
401
+ .replace(/[^a-z0-9]+/g, "-")
402
+ .replace(/(^-|-$)/g, "");
403
+ }
@@ -369,6 +369,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
369
369
  opts.suppressNextBoxClickRef.current = true;
370
370
  opts.onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
371
371
  preferClipAncestor: false,
372
+ hoverSelection: opts.hoverSelectionRef.current,
372
373
  });
373
374
  return;
374
375
  }
@@ -0,0 +1,46 @@
1
+ interface DomEditCommitRunnerConfig {
2
+ capture: () => void;
3
+ apply: () => void;
4
+ persist: () => Promise<void>;
5
+ shouldRevert: (error: unknown) => boolean;
6
+ revert: () => void;
7
+ onError: (error: unknown) => void;
8
+ shouldResync: () => boolean;
9
+ resync: () => void | Promise<void>;
10
+ }
11
+
12
+ interface CommitVersionRef {
13
+ current: number;
14
+ }
15
+
16
+ export function bumpDomEditCommitVersion(versionRef: CommitVersionRef): () => boolean {
17
+ const commitVersion = versionRef.current + 1;
18
+ versionRef.current = commitVersion;
19
+ return () => versionRef.current === commitVersion;
20
+ }
21
+
22
+ export function bumpDomEditCommitMapVersion<TKey>(
23
+ versionMap: Map<TKey, number>,
24
+ versionKey: TKey,
25
+ ): () => boolean {
26
+ const commitVersion = (versionMap.get(versionKey) ?? 0) + 1;
27
+ versionMap.set(versionKey, commitVersion);
28
+ return () => versionMap.get(versionKey) === commitVersion;
29
+ }
30
+
31
+ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promise<void> {
32
+ config.capture();
33
+ config.apply();
34
+
35
+ try {
36
+ await config.persist();
37
+ } catch (error) {
38
+ if (config.shouldRevert(error)) {
39
+ config.revert();
40
+ }
41
+ config.onError(error);
42
+ }
43
+
44
+ if (!config.shouldResync()) return;
45
+ await config.resync();
46
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { PatchOperation } from "../utils/sourcePatcher";
3
+ import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
4
+ import {
5
+ DomEditPersistUnsafeValueError,
6
+ reportDomEditPersistFailure,
7
+ warnDomEditPersistNoOp,
8
+ } from "./domEditPersistFailure";
9
+
10
+ const selection = {
11
+ label: "Hero title",
12
+ hfId: "hf-hero",
13
+ id: "hero",
14
+ selector: ".hero",
15
+ selectorIndex: 0,
16
+ sourceFile: "index.html",
17
+ };
18
+
19
+ const operations: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
20
+
21
+ describe("reportDomEditPersistFailure", () => {
22
+ it("toasts with the selected label and underlying error detail", () => {
23
+ const showToast = vi.fn<(message: string, tone?: "error" | "info") => void>();
24
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
25
+
26
+ reportDomEditPersistFailure(selection, operations, new Error("network down"), showToast);
27
+
28
+ expect(showToast).toHaveBeenCalledWith('Couldn\'t save "Hero title": network down', "error");
29
+ expect(warnSpy).toHaveBeenCalledWith(
30
+ "[Studio] DOM edit persist failed",
31
+ expect.objectContaining({
32
+ target: {
33
+ hfId: "hf-hero",
34
+ id: "hero",
35
+ selector: ".hero",
36
+ selectorIndex: 0,
37
+ sourceFile: "index.html",
38
+ },
39
+ operations: "inline-style:color",
40
+ error: "network down",
41
+ }),
42
+ );
43
+
44
+ warnSpy.mockRestore();
45
+ });
46
+
47
+ it("toasts StudioSaveHttpError and unmarked unsafe errors", () => {
48
+ const showToast = vi.fn<(message: string, tone?: "error" | "info") => void>();
49
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
50
+
51
+ reportDomEditPersistFailure(
52
+ selection,
53
+ operations,
54
+ new StudioSaveHttpError("Failed to patch index.html (500)", 500),
55
+ showToast,
56
+ );
57
+ reportDomEditPersistFailure(
58
+ selection,
59
+ operations,
60
+ new DomEditPersistUnsafeValueError("DOM patch contains unsafe values: style.width"),
61
+ showToast,
62
+ );
63
+
64
+ expect(showToast).toHaveBeenCalledTimes(2);
65
+ expect(showToast).toHaveBeenCalledWith(
66
+ expect.stringContaining("Failed to patch index.html"),
67
+ "error",
68
+ );
69
+ expect(showToast).toHaveBeenCalledWith(
70
+ expect.stringContaining("DOM patch contains unsafe values: style.width"),
71
+ "error",
72
+ );
73
+ expect(warnSpy).toHaveBeenCalledTimes(2);
74
+
75
+ warnSpy.mockRestore();
76
+ });
77
+
78
+ it("does not toast errors explicitly marked as already toasted", () => {
79
+ const showToast = vi.fn<(message: string, tone?: "error" | "info") => void>();
80
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
81
+ const error = new DomEditPersistUnsafeValueError(
82
+ "DOM patch contains unsafe values: style.width",
83
+ );
84
+ Object.defineProperty(error, "alreadyToasted", { value: true });
85
+
86
+ reportDomEditPersistFailure(selection, operations, error, showToast);
87
+
88
+ expect(showToast).not.toHaveBeenCalled();
89
+ expect(warnSpy).toHaveBeenCalledTimes(1);
90
+ expect(warnSpy).toHaveBeenCalledWith(
91
+ "[Studio] DOM edit persist failed",
92
+ expect.objectContaining({
93
+ target: expect.objectContaining({ hfId: "hf-hero", sourceFile: "index.html" }),
94
+ }),
95
+ );
96
+
97
+ warnSpy.mockRestore();
98
+ });
99
+ });
100
+
101
+ describe("warnDomEditPersistNoOp", () => {
102
+ it("logs a structured breadcrumb without requiring a toast callback", () => {
103
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
104
+
105
+ warnDomEditPersistNoOp(selection, operations);
106
+
107
+ expect(warnSpy).toHaveBeenCalledWith(
108
+ "[Studio] DOM edit persist no-op",
109
+ expect.objectContaining({
110
+ target: {
111
+ hfId: "hf-hero",
112
+ id: "hero",
113
+ selector: ".hero",
114
+ selectorIndex: 0,
115
+ sourceFile: "index.html",
116
+ },
117
+ operations: "inline-style:color",
118
+ }),
119
+ );
120
+
121
+ warnSpy.mockRestore();
122
+ });
123
+ });
@@ -0,0 +1,89 @@
1
+ import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
3
+ import type { PatchOperation } from "../utils/sourcePatcher";
4
+
5
+ export class DomEditPersistUnresolvableError extends Error {
6
+ constructor(targetPath: string) {
7
+ super(`Couldn't find this element in the source file (${targetPath})`);
8
+ this.name = "DomEditPersistUnresolvableError";
9
+ }
10
+ }
11
+
12
+ export class DomEditPersistUnsafeValueError extends Error {
13
+ readonly alreadyToasted: boolean;
14
+
15
+ constructor(message: string, options: { alreadyToasted?: boolean } = {}) {
16
+ super(message);
17
+ this.name = "DomEditPersistUnsafeValueError";
18
+ this.alreadyToasted = options.alreadyToasted ?? false;
19
+ }
20
+ }
21
+
22
+ export class DomEditPersistUnsupportedTextStructureError extends Error {
23
+ constructor() {
24
+ super("Couldn't save this text structure change");
25
+ this.name = "DomEditPersistUnsupportedTextStructureError";
26
+ }
27
+ }
28
+
29
+ export type DomEditPersistFailureSelection = Pick<
30
+ DomEditSelection,
31
+ "label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
32
+ >;
33
+
34
+ function summarizeOperations(operations: PatchOperation[]): string {
35
+ return operations.map((op) => `${op.type}:${op.property}`).join(", ");
36
+ }
37
+
38
+ function getTargetTuple(selection: DomEditPersistFailureSelection) {
39
+ return {
40
+ hfId: selection.hfId,
41
+ id: selection.id,
42
+ selector: selection.selector,
43
+ selectorIndex: selection.selectorIndex,
44
+ sourceFile: selection.sourceFile,
45
+ };
46
+ }
47
+
48
+ function getErrorDetail(error: unknown): string {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+
52
+ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
53
+ return selection.label || selection.selector || selection.id || "this element";
54
+ }
55
+
56
+ export function reportDomEditPersistFailure(
57
+ selection: DomEditPersistFailureSelection,
58
+ operations: PatchOperation[],
59
+ error: unknown,
60
+ showToast: (message: string, tone?: "error" | "info") => void,
61
+ ): void {
62
+ const detail = getErrorDetail(error);
63
+ console.warn("[Studio] DOM edit persist failed", {
64
+ target: getTargetTuple(selection),
65
+ operations: summarizeOperations(operations),
66
+ error: detail,
67
+ });
68
+
69
+ const wasAlreadyToasted =
70
+ (error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
71
+ error.alreadyToasted;
72
+ if (wasAlreadyToasted) {
73
+ return;
74
+ }
75
+
76
+ showToast(`Couldn't save "${getSelectionLabel(selection)}": ${detail}`, "error");
77
+ }
78
+
79
+ export function warnDomEditPersistNoOp(
80
+ selection: DomEditPersistFailureSelection,
81
+ operations: PatchOperation[],
82
+ ): void {
83
+ console.warn("[Studio] DOM edit persist no-op", {
84
+ target: getTargetTuple(selection),
85
+ operations: summarizeOperations(operations),
86
+ detail:
87
+ "Server matched the target but reported no change even though the client believed the value changed.",
88
+ });
89
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DomEditTextField } from "../components/editor/domEditing";
3
+ import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
4
+
5
+ function textField(input: {
6
+ key: string;
7
+ value: string;
8
+ tagName?: string;
9
+ inlineStyles?: Record<string, string>;
10
+ source?: DomEditTextField["source"];
11
+ sourceChildIndex?: number;
12
+ }): DomEditTextField {
13
+ return {
14
+ key: input.key,
15
+ label: input.key,
16
+ value: input.value,
17
+ tagName: input.tagName ?? "span",
18
+ attributes: [],
19
+ inlineStyles: input.inlineStyles ?? {},
20
+ computedStyles: {},
21
+ source: input.source ?? "child",
22
+ ...(input.sourceChildIndex == null ? {} : { sourceChildIndex: input.sourceChildIndex }),
23
+ };
24
+ }
25
+
26
+ describe("buildTextFieldChildOperations", () => {
27
+ it("builds child-scoped text and style operations for changed child fields", () => {
28
+ const originalFields = [
29
+ textField({
30
+ key: "first",
31
+ value: "First",
32
+ inlineStyles: { color: "red" },
33
+ sourceChildIndex: 0,
34
+ }),
35
+ textField({
36
+ key: "second",
37
+ value: "Second",
38
+ inlineStyles: { "font-size": "24px" },
39
+ sourceChildIndex: 1,
40
+ }),
41
+ ];
42
+ const nextFields = [
43
+ originalFields[0],
44
+ textField({
45
+ key: "second",
46
+ value: "Second < &",
47
+ inlineStyles: { "font-size": "24px", color: "#0000ff" },
48
+ sourceChildIndex: 1,
49
+ }),
50
+ ];
51
+
52
+ expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
53
+ {
54
+ type: "text-content",
55
+ property: "text",
56
+ value: "Second < &",
57
+ childSelector: ":scope > span",
58
+ childIndex: 1,
59
+ },
60
+ {
61
+ type: "inline-style",
62
+ property: "color",
63
+ value: "#0000ff",
64
+ childSelector: ":scope > span",
65
+ childIndex: 1,
66
+ },
67
+ ]);
68
+ });
69
+
70
+ it("emits null for a removed inline style", () => {
71
+ const originalFields = [
72
+ textField({
73
+ key: "first",
74
+ value: "First",
75
+ inlineStyles: { color: "red" },
76
+ sourceChildIndex: 0,
77
+ }),
78
+ ];
79
+ const nextFields = [
80
+ textField({ key: "first", value: "First", inlineStyles: {}, sourceChildIndex: 0 }),
81
+ ];
82
+
83
+ expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
84
+ {
85
+ type: "inline-style",
86
+ property: "color",
87
+ value: null,
88
+ childSelector: ":scope > span",
89
+ childIndex: 0,
90
+ },
91
+ ]);
92
+ });
93
+
94
+ it("returns null for structural changes, reordered fields, and text nodes", () => {
95
+ const originalFields = [
96
+ textField({ key: "first", value: "First" }),
97
+ textField({ key: "second", value: "Second" }),
98
+ ];
99
+
100
+ expect(buildTextFieldChildOperations(originalFields, [originalFields[0]])).toBeNull();
101
+ expect(
102
+ buildTextFieldChildOperations(originalFields, [originalFields[1], originalFields[0]]),
103
+ ).toBeNull();
104
+ expect(
105
+ buildTextFieldChildOperations(originalFields, [
106
+ originalFields[0],
107
+ textField({ key: "second", value: "Second", tagName: "#text", source: "text-node" }),
108
+ ]),
109
+ ).toBeNull();
110
+ });
111
+ });