@hyperframes/studio 0.8.23 → 0.8.25
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.
- package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
- package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
- package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
- package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6206 -5011
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +207 -8
- package/src/hooks/useDomEditCommits.ts +54 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
|
@@ -124,6 +124,44 @@ afterEach(() => {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
describe("useDomEditTextCommits", () => {
|
|
127
|
+
it("keeps concurrent text commit ownership isolated by target", async () => {
|
|
128
|
+
const { iframe, element: firstElement } = previewElement(
|
|
129
|
+
"<div id='first'>First</div><div id='second'>Second</div>",
|
|
130
|
+
"first",
|
|
131
|
+
);
|
|
132
|
+
const secondElement = iframe.contentDocument?.getElementById("second") as HTMLElement;
|
|
133
|
+
const firstSelection = selectionFor(firstElement);
|
|
134
|
+
const secondSelection = selectionFor(secondElement);
|
|
135
|
+
const firstPersist = createDeferred<void>();
|
|
136
|
+
const persistDomEditOperations = vi
|
|
137
|
+
.fn()
|
|
138
|
+
.mockImplementationOnce(() => firstPersist.promise)
|
|
139
|
+
.mockResolvedValueOnce(undefined);
|
|
140
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
141
|
+
const hook = renderTextCommitHook(
|
|
142
|
+
commitParams({
|
|
143
|
+
previewIframeRef: { current: iframe },
|
|
144
|
+
domEditSelection: firstSelection,
|
|
145
|
+
persistDomEditOperations,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
let firstCommit: Promise<unknown> | undefined;
|
|
150
|
+
act(() => {
|
|
151
|
+
firstCommit = hook.handleDomTextCommitForSelection(firstSelection, "Pending first", "self");
|
|
152
|
+
});
|
|
153
|
+
await act(async () => {
|
|
154
|
+
await hook.handleDomTextCommitForSelection(secondSelection, "Saved second", "self");
|
|
155
|
+
});
|
|
156
|
+
firstPersist.reject(new Error("first target failed"));
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await firstCommit;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(firstElement.textContent).toBe("First");
|
|
162
|
+
expect(secondElement.textContent).toBe("Saved second");
|
|
163
|
+
});
|
|
164
|
+
|
|
127
165
|
it("does not let a stale failed fields commit revert newer text", async () => {
|
|
128
166
|
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
|
129
167
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -183,8 +221,17 @@ describe("useDomEditTextCommits", () => {
|
|
|
183
221
|
it("reports a successful style commit", async () => {
|
|
184
222
|
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
|
185
223
|
const selection = selectionFor(element);
|
|
224
|
+
const persistence = {
|
|
225
|
+
sourceFile: "index.html",
|
|
226
|
+
version: '"sha256:after"',
|
|
227
|
+
changed: true,
|
|
228
|
+
} as const;
|
|
186
229
|
const hook = renderTextCommitHook(
|
|
187
|
-
commitParams({
|
|
230
|
+
commitParams({
|
|
231
|
+
previewIframeRef: { current: iframe },
|
|
232
|
+
domEditSelection: selection,
|
|
233
|
+
persistDomEditOperations: vi.fn().mockResolvedValue(persistence),
|
|
234
|
+
}),
|
|
188
235
|
);
|
|
189
236
|
|
|
190
237
|
let outcome: unknown;
|
|
@@ -192,7 +239,7 @@ describe("useDomEditTextCommits", () => {
|
|
|
192
239
|
outcome = await hook.handleDomStyleCommit("color", "red");
|
|
193
240
|
});
|
|
194
241
|
|
|
195
|
-
expect(outcome).toEqual({ ok: true });
|
|
242
|
+
expect(outcome).toEqual({ ok: true, persistence });
|
|
196
243
|
});
|
|
197
244
|
|
|
198
245
|
it("declines a style commit with no selection, without reaching the writer", async () => {
|
|
@@ -286,4 +333,41 @@ describe("useDomEditTextCommits", () => {
|
|
|
286
333
|
expect(outcome).toEqual({ ok: false, reason: "no-selection" });
|
|
287
334
|
expect(persistDomEditOperations).not.toHaveBeenCalled();
|
|
288
335
|
});
|
|
336
|
+
|
|
337
|
+
it("preserves text persistence evidence for an explicit selection", async () => {
|
|
338
|
+
const { iframe, element: ambientElement } = previewElement(
|
|
339
|
+
"<div id='ambient'>Ambient</div><div id='agent'>Agent</div>",
|
|
340
|
+
"ambient",
|
|
341
|
+
);
|
|
342
|
+
const agentElement = iframe.contentDocument?.getElementById("agent") as HTMLElement;
|
|
343
|
+
const ambientSelection = selectionFor(ambientElement);
|
|
344
|
+
const agentSelection = selectionFor(agentElement);
|
|
345
|
+
const persistence = {
|
|
346
|
+
sourceFile: "index.html",
|
|
347
|
+
version: '"sha256:text"',
|
|
348
|
+
changed: true,
|
|
349
|
+
} as const;
|
|
350
|
+
const persistDomEditOperations = vi.fn().mockResolvedValue(persistence);
|
|
351
|
+
const hook = renderTextCommitHook(
|
|
352
|
+
commitParams({
|
|
353
|
+
previewIframeRef: { current: iframe },
|
|
354
|
+
domEditSelection: ambientSelection,
|
|
355
|
+
persistDomEditOperations,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
let outcome: unknown;
|
|
360
|
+
await act(async () => {
|
|
361
|
+
outcome = await hook.handleDomTextCommitForSelection(agentSelection, "Edited", "self");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(outcome).toEqual({ ok: true, persistence });
|
|
365
|
+
expect(persistDomEditOperations).toHaveBeenCalledWith(
|
|
366
|
+
agentSelection,
|
|
367
|
+
expect.any(Array),
|
|
368
|
+
expect.any(Object),
|
|
369
|
+
);
|
|
370
|
+
expect(ambientElement.textContent).toBe("Ambient");
|
|
371
|
+
expect(agentElement.textContent).toBe("Edited");
|
|
372
|
+
});
|
|
289
373
|
});
|
|
@@ -13,11 +13,9 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
buildDomEditRichTextPatchOperation,
|
|
15
15
|
buildDomEditStylePatchOperation,
|
|
16
|
-
buildDomEditTextPatchOperation,
|
|
17
16
|
findElementForSelection,
|
|
18
17
|
getDomEditTargetKey,
|
|
19
18
|
isTextEditableSelection,
|
|
20
|
-
serializeDomEditTextFields,
|
|
21
19
|
buildDefaultDomEditTextField,
|
|
22
20
|
type DomEditTextField,
|
|
23
21
|
type DomEditSelection,
|
|
@@ -25,11 +23,10 @@ import {
|
|
|
25
23
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
26
24
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
27
25
|
import { canEditElementTextInline } from "../components/editor/domEditInlineText";
|
|
28
|
-
import {
|
|
26
|
+
import { buildNextDomTextFields, planDomTextCommit } from "./domEditTextCommitPlan";
|
|
29
27
|
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
|
30
28
|
import {
|
|
31
29
|
bumpDomEditCommitMapVersion,
|
|
32
|
-
bumpDomEditCommitVersion,
|
|
33
30
|
domEditCommitDeclined,
|
|
34
31
|
runDomEditCommit,
|
|
35
32
|
runReportedDomEditCommit,
|
|
@@ -58,13 +55,6 @@ export interface UseDomEditTextCommitsParams {
|
|
|
58
55
|
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
interface DomTextCommitPlan {
|
|
62
|
-
usesSerializedTextFields: boolean;
|
|
63
|
-
nextContent: string;
|
|
64
|
-
childOperations: PatchOperation[] | null;
|
|
65
|
-
operations: PatchOperation[];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
58
|
function canCommitInlineTextSelection(selection: DomEditSelection, element: HTMLElement): boolean {
|
|
69
59
|
if (selection.isCompositionHost || selection.isInsideLockedComposition) return false;
|
|
70
60
|
return canEditElementTextInline(element);
|
|
@@ -97,51 +87,6 @@ function buildDomStyleCommitOperations(
|
|
|
97
87
|
return operations;
|
|
98
88
|
}
|
|
99
89
|
|
|
100
|
-
function buildNextDomTextFields(
|
|
101
|
-
textFields: DomEditTextField[],
|
|
102
|
-
value: string,
|
|
103
|
-
fieldKey?: string,
|
|
104
|
-
): DomEditTextField[] {
|
|
105
|
-
if (textFields.length === 0) return [];
|
|
106
|
-
return textFields.map((field) => (field.key === fieldKey ? { ...field, value } : field));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function planDomTextCommit(
|
|
110
|
-
originalTextFields: DomEditTextField[],
|
|
111
|
-
nextTextFields: DomEditTextField[],
|
|
112
|
-
plainTextContent: string,
|
|
113
|
-
): DomTextCommitPlan {
|
|
114
|
-
const usesSerializedTextFields =
|
|
115
|
-
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child");
|
|
116
|
-
const nextContent = usesSerializedTextFields
|
|
117
|
-
? serializeDomEditTextFields(nextTextFields)
|
|
118
|
-
: plainTextContent;
|
|
119
|
-
const childOperations = usesSerializedTextFields
|
|
120
|
-
? buildTextFieldChildOperations(originalTextFields, nextTextFields)
|
|
121
|
-
: null;
|
|
122
|
-
// Per-child operations when the layers still line up one-for-one, and the
|
|
123
|
-
// element's whole markup when they do not.
|
|
124
|
-
//
|
|
125
|
-
// `buildTextFieldChildOperations` can only address children that already
|
|
126
|
-
// exist, so it returns null for any change in how many there are — which is
|
|
127
|
-
// every delete and every add. That used to end here with "Couldn't save this
|
|
128
|
-
// text structure change": the panel offered a remove button and an Add text
|
|
129
|
-
// field row, and neither could ever save. A structure change has one honest
|
|
130
|
-
// operation, which is to write the structure.
|
|
131
|
-
const operations =
|
|
132
|
-
childOperations ??
|
|
133
|
-
(usesSerializedTextFields
|
|
134
|
-
? [buildDomEditRichTextPatchOperation(nextContent)]
|
|
135
|
-
: [buildDomEditTextPatchOperation(nextContent)]);
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
usesSerializedTextFields,
|
|
139
|
-
nextContent,
|
|
140
|
-
childOperations,
|
|
141
|
-
operations,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
90
|
async function resyncDomTextSelectionFromPreview(
|
|
146
91
|
doc: Document | null | undefined,
|
|
147
92
|
selection: DomEditSelection,
|
|
@@ -170,8 +115,8 @@ export function useDomEditTextCommits({
|
|
|
170
115
|
persistDomEditOperations,
|
|
171
116
|
resolveImportedFontAsset,
|
|
172
117
|
}: UseDomEditTextCommitsParams) {
|
|
173
|
-
const domTextCommitVersionRef = useRef(
|
|
174
|
-
const domStyleCommitVersionRef = useRef(new Map<string,
|
|
118
|
+
const domTextCommitVersionRef = useRef(new Map<string, symbol>());
|
|
119
|
+
const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
|
|
175
120
|
|
|
176
121
|
const {
|
|
177
122
|
handleDomAttributeCommit,
|
|
@@ -188,15 +133,18 @@ export function useDomEditTextCommits({
|
|
|
188
133
|
persistDomEditOperations,
|
|
189
134
|
});
|
|
190
135
|
|
|
191
|
-
const
|
|
192
|
-
async (
|
|
193
|
-
|
|
136
|
+
const handleDomStyleCommitForSelection = useCallback(
|
|
137
|
+
async (
|
|
138
|
+
selection: DomEditSelection,
|
|
139
|
+
property: string,
|
|
140
|
+
value: string,
|
|
141
|
+
): Promise<DomEditCommitOutcome> => {
|
|
194
142
|
if (isManualGeometryStyleProperty(property))
|
|
195
143
|
return domEditCommitDeclined("geometry-property");
|
|
196
|
-
if (!
|
|
144
|
+
if (!selection.capabilities.canEditStyles) {
|
|
197
145
|
return domEditCommitDeclined("styles-not-editable");
|
|
198
146
|
}
|
|
199
|
-
const styleCommitKey = `${getDomEditTargetKey(
|
|
147
|
+
const styleCommitKey = `${getDomEditTargetKey(selection)}:${property}`;
|
|
200
148
|
const isLatestStyleCommit = bumpDomEditCommitMapVersion(
|
|
201
149
|
domStyleCommitVersionRef.current,
|
|
202
150
|
styleCommitKey,
|
|
@@ -219,7 +167,7 @@ export function useDomEditTextCommits({
|
|
|
219
167
|
return runReportedDomEditCommit({
|
|
220
168
|
capture: () => {
|
|
221
169
|
if (!doc) return;
|
|
222
|
-
const el = findElementForSelection(doc,
|
|
170
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
223
171
|
if (!el) return;
|
|
224
172
|
editedElement = el;
|
|
225
173
|
previousInlineValue = el.style.getPropertyValue(property);
|
|
@@ -238,7 +186,7 @@ export function useDomEditTextCommits({
|
|
|
238
186
|
}
|
|
239
187
|
},
|
|
240
188
|
persist: () =>
|
|
241
|
-
persistDomEditOperations(
|
|
189
|
+
persistDomEditOperations(selection, operations, {
|
|
242
190
|
label: "Edit layer style",
|
|
243
191
|
skipRefresh,
|
|
244
192
|
prepareContent: importedFont
|
|
@@ -255,15 +203,14 @@ export function useDomEditTextCommits({
|
|
|
255
203
|
editedElement.style.setProperty(property, previousInlineValue);
|
|
256
204
|
}
|
|
257
205
|
},
|
|
258
|
-
onError: (error) =>
|
|
259
|
-
reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
|
|
206
|
+
onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
|
|
260
207
|
shouldResync: isLatestStyleCommit,
|
|
261
|
-
resync: () => refreshDomEditSelectionFromPreview(
|
|
208
|
+
resync: () => refreshDomEditSelectionFromPreview(selection),
|
|
209
|
+
onFinally: isLatestStyleCommit.release,
|
|
262
210
|
});
|
|
263
211
|
},
|
|
264
212
|
[
|
|
265
213
|
activeCompPath,
|
|
266
|
-
domEditSelection,
|
|
267
214
|
persistDomEditOperations,
|
|
268
215
|
refreshDomEditSelectionFromPreview,
|
|
269
216
|
resolveImportedFontAsset,
|
|
@@ -272,15 +219,29 @@ export function useDomEditTextCommits({
|
|
|
272
219
|
],
|
|
273
220
|
);
|
|
274
221
|
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
222
|
+
const handleDomStyleCommit = useCallback(
|
|
223
|
+
(property: string, value: string): Promise<DomEditCommitOutcome> =>
|
|
224
|
+
domEditSelection
|
|
225
|
+
? handleDomStyleCommitForSelection(domEditSelection, property, value)
|
|
226
|
+
: Promise.resolve(domEditCommitDeclined("no-selection")),
|
|
227
|
+
[domEditSelection, handleDomStyleCommitForSelection],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const handleDomTextCommitForSelection = useCallback(
|
|
231
|
+
async (
|
|
232
|
+
selection: DomEditSelection,
|
|
233
|
+
value: string,
|
|
234
|
+
fieldKey?: string,
|
|
235
|
+
): Promise<DomEditCommitOutcome> => {
|
|
236
|
+
if (!isTextEditableSelection(selection)) {
|
|
279
237
|
return domEditCommitDeclined("not-text-editable");
|
|
280
238
|
}
|
|
281
|
-
const isLatestTextCommit =
|
|
282
|
-
|
|
283
|
-
|
|
239
|
+
const isLatestTextCommit = bumpDomEditCommitMapVersion(
|
|
240
|
+
domTextCommitVersionRef.current,
|
|
241
|
+
getDomEditTargetKey(selection),
|
|
242
|
+
);
|
|
243
|
+
const nextTextFields = buildNextDomTextFields(selection.textFields, value, fieldKey);
|
|
244
|
+
const textCommit = planDomTextCommit(selection.textFields, nextTextFields, value);
|
|
284
245
|
const iframe = previewIframeRef.current;
|
|
285
246
|
const doc = iframe?.contentDocument;
|
|
286
247
|
let editedElement: HTMLElement | null = null;
|
|
@@ -289,7 +250,7 @@ export function useDomEditTextCommits({
|
|
|
289
250
|
return runReportedDomEditCommit({
|
|
290
251
|
capture: () => {
|
|
291
252
|
if (!doc) return;
|
|
292
|
-
const el = findElementForSelection(doc,
|
|
253
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
293
254
|
if (!el) return;
|
|
294
255
|
editedElement = el;
|
|
295
256
|
previousInnerHtml = el.innerHTML;
|
|
@@ -302,42 +263,49 @@ export function useDomEditTextCommits({
|
|
|
302
263
|
editedElement.textContent = value;
|
|
303
264
|
}
|
|
304
265
|
},
|
|
305
|
-
persist:
|
|
306
|
-
|
|
266
|
+
persist: () =>
|
|
267
|
+
persistDomEditOperations(selection, textCommit.operations, {
|
|
307
268
|
label: "Edit text",
|
|
308
269
|
skipRefresh: true,
|
|
309
270
|
shouldSave: isLatestTextCommit,
|
|
310
|
-
})
|
|
311
|
-
},
|
|
271
|
+
}),
|
|
312
272
|
shouldRevert: () => isLatestTextCommit(),
|
|
313
273
|
revert: () => {
|
|
314
274
|
if (!editedElement || previousInnerHtml === null) return;
|
|
315
275
|
editedElement.innerHTML = previousInnerHtml;
|
|
316
276
|
},
|
|
317
277
|
onError: (error) =>
|
|
318
|
-
reportDomEditPersistFailure(
|
|
278
|
+
reportDomEditPersistFailure(selection, textCommit.operations, error, showToast),
|
|
319
279
|
shouldResync: isLatestTextCommit,
|
|
320
280
|
resync: () =>
|
|
321
281
|
resyncDomTextSelectionFromPreview(
|
|
322
282
|
doc,
|
|
323
|
-
|
|
283
|
+
selection,
|
|
324
284
|
activeCompPath,
|
|
325
285
|
buildDomSelectionFromTarget,
|
|
326
286
|
applyDomSelection,
|
|
327
287
|
),
|
|
288
|
+
onFinally: isLatestTextCommit.release,
|
|
328
289
|
});
|
|
329
290
|
},
|
|
330
291
|
[
|
|
331
292
|
activeCompPath,
|
|
332
293
|
applyDomSelection,
|
|
333
294
|
buildDomSelectionFromTarget,
|
|
334
|
-
domEditSelection,
|
|
335
295
|
persistDomEditOperations,
|
|
336
296
|
previewIframeRef,
|
|
337
297
|
showToast,
|
|
338
298
|
],
|
|
339
299
|
);
|
|
340
300
|
|
|
301
|
+
const handleDomTextCommit = useCallback(
|
|
302
|
+
(value: string, fieldKey?: string): Promise<DomEditCommitOutcome> =>
|
|
303
|
+
domEditSelection
|
|
304
|
+
? handleDomTextCommitForSelection(domEditSelection, value, fieldKey)
|
|
305
|
+
: Promise.resolve(domEditCommitDeclined("no-selection")),
|
|
306
|
+
[domEditSelection, handleDomTextCommitForSelection],
|
|
307
|
+
);
|
|
308
|
+
|
|
341
309
|
/**
|
|
342
310
|
* Persist an element's own markup, for a text edit that styled part of it.
|
|
343
311
|
*
|
|
@@ -366,7 +334,10 @@ export function useDomEditTextCommits({
|
|
|
366
334
|
// A preview reload replaces the document. Never resolve this commit onto
|
|
367
335
|
// the replacement node: it did not own the edit or its rollback snapshot.
|
|
368
336
|
if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
|
|
369
|
-
const isLatestTextCommit =
|
|
337
|
+
const isLatestTextCommit = bumpDomEditCommitMapVersion(
|
|
338
|
+
domTextCommitVersionRef.current,
|
|
339
|
+
getDomEditTargetKey(domEditSelection),
|
|
340
|
+
);
|
|
370
341
|
const operations = [buildDomEditRichTextPatchOperation(html)];
|
|
371
342
|
let appliedHtml = "";
|
|
372
343
|
|
|
@@ -404,6 +375,7 @@ export function useDomEditTextCommits({
|
|
|
404
375
|
buildDomSelectionFromTarget,
|
|
405
376
|
applyDomSelection,
|
|
406
377
|
),
|
|
378
|
+
onFinally: isLatestTextCommit.release,
|
|
407
379
|
});
|
|
408
380
|
},
|
|
409
381
|
[
|
|
@@ -423,7 +395,10 @@ export function useDomEditTextCommits({
|
|
|
423
395
|
nextTextFields: DomEditTextField[],
|
|
424
396
|
options?: { importedFont?: ImportedFontAsset | null },
|
|
425
397
|
) => {
|
|
426
|
-
const isLatestTextCommit =
|
|
398
|
+
const isLatestTextCommit = bumpDomEditCommitMapVersion(
|
|
399
|
+
domTextCommitVersionRef.current,
|
|
400
|
+
getDomEditTargetKey(selection),
|
|
401
|
+
);
|
|
427
402
|
const textCommit = planDomTextCommit(
|
|
428
403
|
selection.textFields,
|
|
429
404
|
nextTextFields,
|
|
@@ -476,6 +451,7 @@ export function useDomEditTextCommits({
|
|
|
476
451
|
buildDomSelectionFromTarget,
|
|
477
452
|
applyDomSelection,
|
|
478
453
|
),
|
|
454
|
+
onFinally: isLatestTextCommit.release,
|
|
479
455
|
});
|
|
480
456
|
},
|
|
481
457
|
[
|
|
@@ -579,12 +555,14 @@ export function useDomEditTextCommits({
|
|
|
579
555
|
|
|
580
556
|
return {
|
|
581
557
|
handleDomStyleCommit,
|
|
558
|
+
handleDomStyleCommitForSelection,
|
|
582
559
|
handleDomAttributeCommit,
|
|
583
560
|
handleDomAttributeLiveCommit,
|
|
584
561
|
handleDomAttributeQuietCommit,
|
|
585
562
|
handleDomHtmlAttributeCommit,
|
|
586
563
|
handleDomAttributesCommit,
|
|
587
564
|
handleDomTextCommit,
|
|
565
|
+
handleDomTextCommitForSelection,
|
|
588
566
|
handleDomRichTextCommit,
|
|
589
567
|
commitDomTextFields,
|
|
590
568
|
handleDomTextFieldStyleCommit,
|
|
@@ -223,7 +223,7 @@ export function useDomEditWiring({
|
|
|
223
223
|
// ── Telemetry & fallback ──
|
|
224
224
|
|
|
225
225
|
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
|
|
226
|
-
const makeFetchFallback = useGsapAnimationFetchFallback(projectId
|
|
226
|
+
const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
|
|
227
227
|
|
|
228
228
|
// ── GSAP selection handlers ──
|
|
229
229
|
|
|
@@ -210,6 +210,7 @@ export function useDomSelection({
|
|
|
210
210
|
options?: {
|
|
211
211
|
preferClipAncestor?: boolean;
|
|
212
212
|
skipSourceProbe?: boolean;
|
|
213
|
+
exactTarget?: boolean;
|
|
213
214
|
// Override the drill-in scope (used by canvas double-click to resolve the
|
|
214
215
|
// child inside a group before the activeGroupElement state has re-rendered).
|
|
215
216
|
activeGroupElement?: HTMLElement | null;
|
|
@@ -220,6 +221,7 @@ export function useDomSelection({
|
|
|
220
221
|
isMasterView,
|
|
221
222
|
preferClipAncestor: options?.preferClipAncestor,
|
|
222
223
|
skipSourceProbe: options?.skipSourceProbe,
|
|
224
|
+
exactTarget: options?.exactTarget,
|
|
223
225
|
activeGroupElement:
|
|
224
226
|
options && "activeGroupElement" in options
|
|
225
227
|
? options.activeGroupElement
|
|
@@ -16,6 +16,8 @@ export interface ResolveDomSelectionOptions {
|
|
|
16
16
|
preferClipAncestor?: boolean;
|
|
17
17
|
skipSourceProbe?: boolean;
|
|
18
18
|
activeGroupElement?: HTMLElement | null;
|
|
19
|
+
/** Resolve this node itself instead of applying human group-capture behavior. */
|
|
20
|
+
exactTarget?: boolean;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface UseDomSelectionParams {
|
|
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
6
6
|
import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
|
|
7
|
+
import { markSelfWrite, resetSelfWriteRegistry } from "./sdkSelfWriteRegistry";
|
|
7
8
|
import {
|
|
8
9
|
useExternalFileChangeCoordinator,
|
|
9
10
|
type ExternalFileChangeCoordinatorHandle,
|
|
@@ -29,6 +30,7 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
|
|
|
29
30
|
discardPendingChanges: vi.fn(),
|
|
30
31
|
overwriteConflict: vi.fn(async () => undefined),
|
|
31
32
|
readProjectFile: vi.fn(async () => "external"),
|
|
33
|
+
onAcceptedPersistedFileChange: vi.fn(),
|
|
32
34
|
};
|
|
33
35
|
const options = { ...defaults, ...overrides };
|
|
34
36
|
const root = createRoot(document.createElement("div"));
|
|
@@ -45,6 +47,7 @@ describe("external file change coordinator", () => {
|
|
|
45
47
|
beforeEach(() => {
|
|
46
48
|
handler = null;
|
|
47
49
|
resetStudioWriteTokens();
|
|
50
|
+
resetSelfWriteRegistry();
|
|
48
51
|
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
|
|
49
52
|
on: (_event: string, next: HotHandler) => {
|
|
50
53
|
handler = next;
|
|
@@ -67,23 +70,61 @@ describe("external file change coordinator", () => {
|
|
|
67
70
|
order.push("drain");
|
|
68
71
|
return { status: "clean" };
|
|
69
72
|
},
|
|
73
|
+
onAcceptedPersistedFileChange: () => order.push("thumbnail"),
|
|
70
74
|
reloadPreview: () => order.push("preview"),
|
|
71
75
|
reloadSdkSession: () => order.push("sdk"),
|
|
72
76
|
});
|
|
73
77
|
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
74
|
-
expect(order).toEqual(["drain", "preview", "sdk"]);
|
|
78
|
+
expect(order).toEqual(["drain", "thumbnail", "preview", "sdk"]);
|
|
75
79
|
expect(captured.handle?.blocked).toBeNull();
|
|
76
80
|
});
|
|
77
81
|
|
|
78
|
-
it("suppresses an exact Studio write
|
|
82
|
+
it("refreshes thumbnails once but suppresses Preview reload for an exact Studio write", async () => {
|
|
79
83
|
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
80
84
|
const reloadPreview = vi.fn();
|
|
81
85
|
const reloadSdkSession = vi.fn();
|
|
82
|
-
|
|
86
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
87
|
+
await mountCoordinator({
|
|
88
|
+
drainPendingChanges,
|
|
89
|
+
reloadPreview,
|
|
90
|
+
reloadSdkSession,
|
|
91
|
+
onAcceptedPersistedFileChange,
|
|
92
|
+
});
|
|
83
93
|
markStudioWriteToken("studio-write-1");
|
|
94
|
+
const payload = {
|
|
95
|
+
path: "index.html",
|
|
96
|
+
content: "studio",
|
|
97
|
+
version: "v2",
|
|
98
|
+
writeToken: "studio-write-1",
|
|
99
|
+
};
|
|
100
|
+
await act(async () => handler?.(payload));
|
|
101
|
+
await act(async () => handler?.(payload));
|
|
102
|
+
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
103
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
104
|
+
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
105
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
106
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("accepts a matching content echo without a write token and suppresses every reload", async () => {
|
|
110
|
+
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
111
|
+
const reloadPreview = vi.fn();
|
|
112
|
+
const reloadSdkSession = vi.fn();
|
|
113
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
114
|
+
await mountCoordinator({
|
|
115
|
+
drainPendingChanges,
|
|
116
|
+
reloadPreview,
|
|
117
|
+
reloadSdkSession,
|
|
118
|
+
onAcceptedPersistedFileChange,
|
|
119
|
+
});
|
|
120
|
+
markSelfWrite("index.html", "studio content");
|
|
121
|
+
|
|
84
122
|
await act(async () =>
|
|
85
|
-
handler?.({ path: "index.html", content: "studio",
|
|
123
|
+
handler?.({ path: "index.html", content: "studio content", version: "v2" }),
|
|
86
124
|
);
|
|
125
|
+
|
|
126
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
127
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
87
128
|
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
88
129
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
89
130
|
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
@@ -115,15 +156,23 @@ describe("external file change coordinator", () => {
|
|
|
115
156
|
attemptedContent: "studio",
|
|
116
157
|
});
|
|
117
158
|
const persistConflictSnapshot = vi.fn(async () => undefined);
|
|
159
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
118
160
|
const { captured, options } = await mountCoordinator({
|
|
119
161
|
drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
|
|
120
162
|
persistConflictSnapshot,
|
|
163
|
+
onAcceptedPersistedFileChange,
|
|
121
164
|
});
|
|
122
165
|
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
123
166
|
expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
|
|
124
167
|
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
|
|
125
168
|
expect(options.reloadPreview).not.toHaveBeenCalled();
|
|
126
169
|
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
|
170
|
+
expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
|
|
171
|
+
|
|
172
|
+
await act(async () => captured.handle?.useExternalFile());
|
|
173
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
174
|
+
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
|
175
|
+
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
|
|
127
176
|
});
|
|
128
177
|
|
|
129
178
|
it("ignores stale drain completion after a newer generation", async () => {
|
|
@@ -165,6 +214,7 @@ describe("external file change coordinator", () => {
|
|
|
165
214
|
const failure = new Error("network unavailable");
|
|
166
215
|
const persistFailureSnapshot = vi.fn(async () => undefined);
|
|
167
216
|
const deleteConflictSnapshot = vi.fn(async () => undefined);
|
|
217
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
168
218
|
const { captured } = await mountCoordinator({
|
|
169
219
|
drainPendingChanges: vi
|
|
170
220
|
.fn()
|
|
@@ -173,6 +223,7 @@ describe("external file change coordinator", () => {
|
|
|
173
223
|
getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
|
|
174
224
|
persistFailureSnapshot,
|
|
175
225
|
deleteConflictSnapshot,
|
|
226
|
+
onAcceptedPersistedFileChange,
|
|
176
227
|
});
|
|
177
228
|
await act(async () => handler?.({ path: "index.html" }));
|
|
178
229
|
expect(captured.handle?.blocked).toMatchObject({
|
|
@@ -188,15 +239,19 @@ describe("external file change coordinator", () => {
|
|
|
188
239
|
null,
|
|
189
240
|
failure,
|
|
190
241
|
);
|
|
242
|
+
expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
|
|
191
243
|
await act(async () => captured.handle?.retry());
|
|
192
244
|
expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
|
|
245
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
193
246
|
});
|
|
194
247
|
|
|
195
248
|
it("restores and overwrites from a durable failed draft", async () => {
|
|
196
249
|
const overwriteConflict = vi.fn(async () => undefined);
|
|
250
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
197
251
|
const { captured } = await mountCoordinator({
|
|
198
252
|
recoveryFilePath: "index.html",
|
|
199
253
|
overwriteConflict,
|
|
254
|
+
onAcceptedPersistedFileChange,
|
|
200
255
|
loadConflictSnapshot: vi.fn(async () => ({
|
|
201
256
|
kind: "failed" as const,
|
|
202
257
|
projectId: "project-a",
|
|
@@ -217,5 +272,17 @@ describe("external file change coordinator", () => {
|
|
|
217
272
|
expect(overwriteConflict).toHaveBeenCalledWith(
|
|
218
273
|
expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
|
|
219
274
|
);
|
|
275
|
+
expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
markStudioWriteToken("keep-studio-write");
|
|
278
|
+
await act(async () =>
|
|
279
|
+
handler?.({
|
|
280
|
+
path: "index.html",
|
|
281
|
+
content: "recover me",
|
|
282
|
+
version: "v3",
|
|
283
|
+
writeToken: "keep-studio-write",
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
220
287
|
});
|
|
221
288
|
});
|