@hyperframes/studio 0.8.24 → 0.8.26
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-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
- package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
- package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
- 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 +6230 -5019
- 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/timelineTrackVisibility.test.ts +7 -1
- package/src/hooks/timelineTrackVisibility.ts +5 -5
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +243 -8
- package/src/hooks/useDomEditCommits.ts +69 -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/hooks/timelineSyncHydration.ts +10 -2
- 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
|
@@ -8,7 +8,9 @@ import type { DomEditSelection, DomEditTextField } from "../components/editor/do
|
|
|
8
8
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
9
9
|
import { usePlayerStore } from "../player";
|
|
10
10
|
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
11
|
+
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
11
12
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
13
|
+
import type { CutoverResult } from "../utils/sdkCutover";
|
|
12
14
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
13
15
|
|
|
14
16
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
@@ -22,6 +24,8 @@ interface PatchResponseBody {
|
|
|
22
24
|
changed?: boolean;
|
|
23
25
|
matched?: boolean;
|
|
24
26
|
content?: string;
|
|
27
|
+
path?: string;
|
|
28
|
+
version?: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
interface RenderedDomEditCommits {
|
|
@@ -35,6 +39,9 @@ interface RenderedDomEditCommits {
|
|
|
35
39
|
interface RenderDomEditCommitsOptions {
|
|
36
40
|
importedFontAssets?: ImportedFontAsset[];
|
|
37
41
|
writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
42
|
+
onTrySdkPersist?: () => Promise<CutoverResult>;
|
|
43
|
+
queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
|
|
44
|
+
projectIdRef?: MutableRefObject<string | null>;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
type FetchHandler = (
|
|
@@ -201,7 +208,7 @@ function renderDomEditCommits(
|
|
|
201
208
|
const showToast = makeShowToast();
|
|
202
209
|
const recordEdit = vi.fn(async () => {});
|
|
203
210
|
const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
|
|
204
|
-
const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
|
|
211
|
+
const projectIdRef: MutableRefObject<string | null> = options.projectIdRef ?? { current: "p1" };
|
|
205
212
|
const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
|
|
206
213
|
const reloadPreview = vi.fn();
|
|
207
214
|
|
|
@@ -210,7 +217,7 @@ function renderDomEditCommits(
|
|
|
210
217
|
activeCompPath: "index.html",
|
|
211
218
|
previewIframeRef,
|
|
212
219
|
showToast,
|
|
213
|
-
queueDomEditSave: async (save) => save(),
|
|
220
|
+
queueDomEditSave: options.queueDomEditSave ?? (async (save) => save()),
|
|
214
221
|
writeProjectFile: options.writeProjectFile ?? (async () => {}),
|
|
215
222
|
domEditSaveTimestampRef,
|
|
216
223
|
editHistory: { recordEdit },
|
|
@@ -224,6 +231,7 @@ function renderDomEditCommits(
|
|
|
224
231
|
clearDomSelection: vi.fn(),
|
|
225
232
|
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
226
233
|
buildDomSelectionFromTarget: vi.fn(async () => null),
|
|
234
|
+
onTrySdkPersist: options.onTrySdkPersist,
|
|
227
235
|
});
|
|
228
236
|
return null;
|
|
229
237
|
}
|
|
@@ -772,11 +780,13 @@ async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]
|
|
|
772
780
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
773
781
|
const { iframe, element } = createPreviewElement();
|
|
774
782
|
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
783
|
+
let outcome: Awaited<ReturnType<typeof rendered.hook.handleDomStyleCommit>> | undefined;
|
|
775
784
|
await act(async () => {
|
|
776
|
-
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
785
|
+
outcome = await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
777
786
|
});
|
|
778
787
|
return {
|
|
779
788
|
element,
|
|
789
|
+
outcome,
|
|
780
790
|
rendered,
|
|
781
791
|
warnSpy,
|
|
782
792
|
cleanup: () => {
|
|
@@ -944,10 +954,12 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
944
954
|
});
|
|
945
955
|
|
|
946
956
|
it("warns without a toast when the server matched the element but reported no change", async () => {
|
|
947
|
-
const { rendered, warnSpy, cleanup } = await commitStyleAgainst({
|
|
957
|
+
const { rendered, outcome, warnSpy, cleanup } = await commitStyleAgainst({
|
|
948
958
|
ok: true,
|
|
949
959
|
changed: false,
|
|
950
960
|
matched: true,
|
|
961
|
+
path: "index.html",
|
|
962
|
+
version: '"sha256:current"',
|
|
951
963
|
});
|
|
952
964
|
|
|
953
965
|
try {
|
|
@@ -956,11 +968,55 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
956
968
|
"[Studio] DOM edit persist no-op",
|
|
957
969
|
expect.objectContaining({ operations: "inline-style:color" }),
|
|
958
970
|
);
|
|
971
|
+
expect(outcome).toEqual({
|
|
972
|
+
ok: true,
|
|
973
|
+
persistence: {
|
|
974
|
+
sourceFile: "index.html",
|
|
975
|
+
version: '"sha256:current"',
|
|
976
|
+
changed: false,
|
|
977
|
+
},
|
|
978
|
+
});
|
|
959
979
|
} finally {
|
|
960
980
|
cleanup();
|
|
961
981
|
}
|
|
962
982
|
});
|
|
963
983
|
|
|
984
|
+
it("rebinds the paused preview after a saved optimistic style commit", async () => {
|
|
985
|
+
stubPatchFetch({
|
|
986
|
+
ok: true,
|
|
987
|
+
changed: true,
|
|
988
|
+
matched: true,
|
|
989
|
+
path: "index.html",
|
|
990
|
+
version: '"sha256:changed"',
|
|
991
|
+
});
|
|
992
|
+
const { iframe, element } = createPreviewElement();
|
|
993
|
+
const seek = vi.fn();
|
|
994
|
+
const forceTimelineRebind = vi.fn();
|
|
995
|
+
Object.defineProperty(iframe.contentWindow, "__player", {
|
|
996
|
+
configurable: true,
|
|
997
|
+
value: { seek },
|
|
998
|
+
});
|
|
999
|
+
Object.defineProperty(iframe.contentWindow, "__hfForceTimelineRebind", {
|
|
1000
|
+
configurable: true,
|
|
1001
|
+
value: forceTimelineRebind,
|
|
1002
|
+
});
|
|
1003
|
+
usePlayerStore.setState({ currentTime: 2.4 });
|
|
1004
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
1005
|
+
|
|
1006
|
+
try {
|
|
1007
|
+
await act(async () => {
|
|
1008
|
+
await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
expect(seek).toHaveBeenCalledWith(2.4);
|
|
1012
|
+
expect(forceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
1013
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
1014
|
+
} finally {
|
|
1015
|
+
rendered.cleanup();
|
|
1016
|
+
usePlayerStore.getState().reset();
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
964
1020
|
it("toasts and reverts a style commit when the patch request rejects", async () => {
|
|
965
1021
|
const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down"));
|
|
966
1022
|
|
|
@@ -976,22 +1032,203 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
976
1032
|
});
|
|
977
1033
|
|
|
978
1034
|
it("keeps the optimistic style and records history when the patch succeeds", async () => {
|
|
979
|
-
const { element, rendered, cleanup } = await commitStyleAgainst({
|
|
1035
|
+
const { element, rendered, outcome, cleanup } = await commitStyleAgainst({
|
|
980
1036
|
ok: true,
|
|
981
1037
|
changed: true,
|
|
982
1038
|
matched: true,
|
|
983
1039
|
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
1040
|
+
path: "index.html",
|
|
1041
|
+
version: '"sha256:after"',
|
|
984
1042
|
});
|
|
985
1043
|
|
|
986
1044
|
try {
|
|
987
1045
|
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
988
1046
|
expect(element.style.getPropertyValue("color")).toBe("blue");
|
|
989
1047
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
1048
|
+
expect(outcome).toEqual({
|
|
1049
|
+
ok: true,
|
|
1050
|
+
persistence: {
|
|
1051
|
+
sourceFile: "index.html",
|
|
1052
|
+
version: '"sha256:after"',
|
|
1053
|
+
changed: true,
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
990
1056
|
} finally {
|
|
991
1057
|
cleanup();
|
|
992
1058
|
}
|
|
993
1059
|
});
|
|
994
1060
|
|
|
1061
|
+
it("serializes the full read, write, and history transaction across overlapping commits", async () => {
|
|
1062
|
+
const firstPatch = createDeferred<Response>();
|
|
1063
|
+
let readCount = 0;
|
|
1064
|
+
let patchCount = 0;
|
|
1065
|
+
vi.stubGlobal(
|
|
1066
|
+
"fetch",
|
|
1067
|
+
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
|
|
1068
|
+
const url = requestUrl(input);
|
|
1069
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
1070
|
+
readCount += 1;
|
|
1071
|
+
return jsonResponse({
|
|
1072
|
+
content:
|
|
1073
|
+
readCount === 1
|
|
1074
|
+
? '<div data-hf-id="hf-card" style="color: red">Card</div>'
|
|
1075
|
+
: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
1079
|
+
patchCount += 1;
|
|
1080
|
+
if (patchCount === 1) return firstPatch.promise;
|
|
1081
|
+
return jsonResponse({
|
|
1082
|
+
ok: true,
|
|
1083
|
+
changed: true,
|
|
1084
|
+
matched: true,
|
|
1085
|
+
content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
|
|
1086
|
+
path: "index.html",
|
|
1087
|
+
version: '"sha256:green"',
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
const queue = createDomEditSaveQueue();
|
|
1094
|
+
const { iframe, element } = createPreviewElement();
|
|
1095
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1096
|
+
queueDomEditSave: queue.enqueue,
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
try {
|
|
1100
|
+
const first = rendered.hook.handleDomStyleCommit("color", "blue");
|
|
1101
|
+
await flushAsyncWork();
|
|
1102
|
+
const second = rendered.hook.handleDomStyleCommit("color", "green");
|
|
1103
|
+
await flushAsyncWork();
|
|
1104
|
+
|
|
1105
|
+
expect(readCount).toBe(1);
|
|
1106
|
+
expect(patchCount).toBe(1);
|
|
1107
|
+
|
|
1108
|
+
firstPatch.resolve(
|
|
1109
|
+
jsonResponse({
|
|
1110
|
+
ok: true,
|
|
1111
|
+
changed: true,
|
|
1112
|
+
matched: true,
|
|
1113
|
+
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
1114
|
+
path: "index.html",
|
|
1115
|
+
version: '"sha256:blue"',
|
|
1116
|
+
}),
|
|
1117
|
+
);
|
|
1118
|
+
await first;
|
|
1119
|
+
await second;
|
|
1120
|
+
|
|
1121
|
+
expect(readCount).toBe(2);
|
|
1122
|
+
expect(patchCount).toBe(2);
|
|
1123
|
+
expect(rendered.recordEdit).toHaveBeenNthCalledWith(
|
|
1124
|
+
1,
|
|
1125
|
+
expect.objectContaining({
|
|
1126
|
+
files: {
|
|
1127
|
+
"index.html": expect.objectContaining({
|
|
1128
|
+
before: expect.stringContaining("color: red"),
|
|
1129
|
+
after: expect.stringContaining("color: blue"),
|
|
1130
|
+
}),
|
|
1131
|
+
},
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
expect(rendered.recordEdit).toHaveBeenNthCalledWith(
|
|
1135
|
+
2,
|
|
1136
|
+
expect.objectContaining({
|
|
1137
|
+
files: {
|
|
1138
|
+
"index.html": expect.objectContaining({
|
|
1139
|
+
before: expect.stringContaining("color: blue"),
|
|
1140
|
+
after: expect.stringContaining("color: green"),
|
|
1141
|
+
}),
|
|
1142
|
+
},
|
|
1143
|
+
}),
|
|
1144
|
+
);
|
|
1145
|
+
} finally {
|
|
1146
|
+
queue.destroy();
|
|
1147
|
+
rendered.cleanup();
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("refuses queued persistence after the active project changes", async () => {
|
|
1152
|
+
const firstPatch = createDeferred<Response>();
|
|
1153
|
+
const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
|
|
1154
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
|
|
1155
|
+
const url = requestUrl(input);
|
|
1156
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
1157
|
+
return jsonResponse({
|
|
1158
|
+
content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
1162
|
+
return firstPatch.promise;
|
|
1163
|
+
}
|
|
1164
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
1165
|
+
});
|
|
1166
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1167
|
+
const queue = createDomEditSaveQueue();
|
|
1168
|
+
const { iframe, element } = createPreviewElement();
|
|
1169
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1170
|
+
queueDomEditSave: queue.enqueue,
|
|
1171
|
+
projectIdRef,
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
const first = rendered.hook.handleDomStyleCommit("color", "blue");
|
|
1176
|
+
await flushAsyncWork();
|
|
1177
|
+
const second = rendered.hook.handleDomStyleCommit("color", "green");
|
|
1178
|
+
projectIdRef.current = "p2";
|
|
1179
|
+
firstPatch.resolve(
|
|
1180
|
+
jsonResponse({
|
|
1181
|
+
ok: true,
|
|
1182
|
+
changed: true,
|
|
1183
|
+
matched: true,
|
|
1184
|
+
content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
|
|
1185
|
+
path: "index.html",
|
|
1186
|
+
version: '"sha256:blue"',
|
|
1187
|
+
}),
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
await first;
|
|
1191
|
+
await expect(second).resolves.toMatchObject({
|
|
1192
|
+
ok: false,
|
|
1193
|
+
reason: "persist-failed",
|
|
1194
|
+
});
|
|
1195
|
+
expect(fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/p2/"))).toBe(
|
|
1196
|
+
false,
|
|
1197
|
+
);
|
|
1198
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1199
|
+
} finally {
|
|
1200
|
+
queue.destroy();
|
|
1201
|
+
rendered.cleanup();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("preserves the SDK cutover version as the style commit's durable evidence", async () => {
|
|
1206
|
+
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
1207
|
+
const { iframe, element } = createPreviewElement();
|
|
1208
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1209
|
+
onTrySdkPersist: async () => ({ status: "committed", version: "sdk-version-2" }),
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
try {
|
|
1213
|
+
let outcome: Awaited<ReturnType<typeof rendered.hook.handleDomStyleCommit>> | undefined;
|
|
1214
|
+
await act(async () => {
|
|
1215
|
+
outcome = await rendered.hook.handleDomStyleCommit("color", "blue");
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
expect(outcome).toEqual({
|
|
1219
|
+
ok: true,
|
|
1220
|
+
persistence: { sourceFile: "index.html", version: "sdk-version-2", changed: true },
|
|
1221
|
+
});
|
|
1222
|
+
expect(
|
|
1223
|
+
fetchMock.mock.calls.some(([input]) =>
|
|
1224
|
+
requestUrl(input).includes("/file-mutations/patch-element/"),
|
|
1225
|
+
),
|
|
1226
|
+
).toBe(false);
|
|
1227
|
+
} finally {
|
|
1228
|
+
rendered.cleanup();
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
|
|
995
1232
|
it("keeps a newer style value when an older overlapping commit later fails", async () => {
|
|
996
1233
|
const firstPatch = createDeferred<Response>();
|
|
997
1234
|
const secondPatch = createDeferred<Response>();
|
|
@@ -1466,9 +1703,7 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1466
1703
|
// optimistic apply) and succeeds before the older one rejects. Without the
|
|
1467
1704
|
// per-key version guard, the stale rejection would revert to the older
|
|
1468
1705
|
// commit's own previousValue (null) and stomp the newer commit's value.
|
|
1469
|
-
const firstCommit =
|
|
1470
|
-
await rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
|
|
1471
|
-
});
|
|
1706
|
+
const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
|
|
1472
1707
|
await act(async () => {
|
|
1473
1708
|
await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
|
|
1474
1709
|
});
|
|
@@ -32,8 +32,9 @@ import {
|
|
|
32
32
|
patchElementBatches,
|
|
33
33
|
readErrorResponseBody,
|
|
34
34
|
} from "./useDomEditCommitsHelpers";
|
|
35
|
-
import {
|
|
35
|
+
import type { CutoverResult } from "../utils/sdkCutover";
|
|
36
36
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
37
|
+
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
37
38
|
interface RecordEditInput {
|
|
38
39
|
label: string;
|
|
39
40
|
kind: EditHistoryKind;
|
|
@@ -139,14 +140,25 @@ export function useDomEditCommits({
|
|
|
139
140
|
const reportedUnresolvableRef = useRef(new Set<string>());
|
|
140
141
|
|
|
141
142
|
// fallow-ignore-next-line complexity
|
|
142
|
-
const
|
|
143
|
+
const performPersistDomEditOperations = useCallback(
|
|
143
144
|
// fallow-ignore-next-line complexity
|
|
144
|
-
async (
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
async (
|
|
146
|
+
selection: DomEditSelection,
|
|
147
|
+
operations: PatchOperation[],
|
|
148
|
+
options: Parameters<PersistDomEditOperations>[2],
|
|
149
|
+
expectedProjectId: string,
|
|
150
|
+
) => {
|
|
151
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
152
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
153
|
+
}
|
|
154
|
+
const pid = expectedProjectId;
|
|
147
155
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
148
156
|
|
|
149
157
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
158
|
+
const completePersistence = <T>(result: T, changed: boolean): T => {
|
|
159
|
+
if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
|
|
160
|
+
return result;
|
|
161
|
+
};
|
|
150
162
|
|
|
151
163
|
const readResponse = await fetch(
|
|
152
164
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
@@ -160,6 +172,10 @@ export function useDomEditCommits({
|
|
|
160
172
|
throw new Error(`Missing file contents for ${targetPath}`);
|
|
161
173
|
}
|
|
162
174
|
|
|
175
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
176
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
164
180
|
|
|
165
181
|
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
@@ -186,10 +202,14 @@ export function useDomEditCommits({
|
|
|
186
202
|
coalesceKey: options?.coalesceKey,
|
|
187
203
|
skipRefresh: options?.skipRefresh,
|
|
188
204
|
});
|
|
189
|
-
if (
|
|
205
|
+
if (cutover.status === "failed") throw cutover.error;
|
|
206
|
+
if (cutover.status === "committed") {
|
|
190
207
|
// SDK handled it — its in-memory doc is already current, so do NOT
|
|
191
208
|
// forceReload (that would echo-reload the session we just wrote).
|
|
192
|
-
return
|
|
209
|
+
return completePersistence(
|
|
210
|
+
{ sourceFile: targetPath, version: cutover.version, changed: true },
|
|
211
|
+
true,
|
|
212
|
+
);
|
|
193
213
|
}
|
|
194
214
|
}
|
|
195
215
|
|
|
@@ -218,6 +238,8 @@ export function useDomEditCommits({
|
|
|
218
238
|
changed?: boolean;
|
|
219
239
|
matched?: boolean;
|
|
220
240
|
content?: string;
|
|
241
|
+
path?: string;
|
|
242
|
+
version?: string;
|
|
221
243
|
};
|
|
222
244
|
|
|
223
245
|
if (!patchData.changed) {
|
|
@@ -235,7 +257,12 @@ export function useDomEditCommits({
|
|
|
235
257
|
throw new DomEditPersistUnresolvableError(targetPath);
|
|
236
258
|
}
|
|
237
259
|
warnDomEditPersistNoOp(selection, operations);
|
|
238
|
-
return
|
|
260
|
+
return completePersistence(
|
|
261
|
+
typeof patchData.path === "string" && typeof patchData.version === "string"
|
|
262
|
+
? { sourceFile: patchData.path, version: patchData.version, changed: false }
|
|
263
|
+
: undefined,
|
|
264
|
+
false,
|
|
265
|
+
);
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
const patchedContent =
|
|
@@ -273,6 +300,14 @@ export function useDomEditCommits({
|
|
|
273
300
|
if (!options?.skipRefresh) {
|
|
274
301
|
reloadPreview();
|
|
275
302
|
}
|
|
303
|
+
return completePersistence(
|
|
304
|
+
finalContent === patchedContent &&
|
|
305
|
+
typeof patchData.path === "string" &&
|
|
306
|
+
typeof patchData.version === "string"
|
|
307
|
+
? { sourceFile: patchData.path, version: patchData.version, changed: true }
|
|
308
|
+
: undefined,
|
|
309
|
+
true,
|
|
310
|
+
);
|
|
276
311
|
},
|
|
277
312
|
[
|
|
278
313
|
activeCompPath,
|
|
@@ -284,18 +319,34 @@ export function useDomEditCommits({
|
|
|
284
319
|
showToast,
|
|
285
320
|
forceReloadSdkSession,
|
|
286
321
|
onTrySdkPersist,
|
|
322
|
+
previewIframeRef,
|
|
287
323
|
],
|
|
288
324
|
);
|
|
289
325
|
|
|
326
|
+
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
327
|
+
(selection, operations, options) => {
|
|
328
|
+
const expectedProjectId = projectIdRef.current;
|
|
329
|
+
if (!expectedProjectId) return Promise.reject(new Error("No active project"));
|
|
330
|
+
return queueDomEditSave(() =>
|
|
331
|
+
performPersistDomEditOperations(selection, operations, options, expectedProjectId),
|
|
332
|
+
);
|
|
333
|
+
},
|
|
334
|
+
[performPersistDomEditOperations, projectIdRef, queueDomEditSave],
|
|
335
|
+
);
|
|
336
|
+
|
|
290
337
|
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
|
|
291
|
-
(batches, options) =>
|
|
292
|
-
|
|
338
|
+
(batches, options) => {
|
|
339
|
+
const expectedProjectId = projectIdRef.current;
|
|
340
|
+
if (!expectedProjectId) return Promise.reject(new Error("No active project"));
|
|
341
|
+
return queueDomEditSave(
|
|
293
342
|
// One queued transaction owns validation, persistence, history, reload,
|
|
294
343
|
// and its durable result; splitting those phases risks partial commits.
|
|
295
344
|
// fallow-ignore-next-line complexity
|
|
296
345
|
async () => {
|
|
297
|
-
|
|
298
|
-
|
|
346
|
+
if (projectIdRef.current !== expectedProjectId) {
|
|
347
|
+
throw new Error("Active project changed before the edit could be saved");
|
|
348
|
+
}
|
|
349
|
+
const pid = expectedProjectId;
|
|
299
350
|
const unsafeFields = batches.flatMap((batch) =>
|
|
300
351
|
batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
|
|
301
352
|
);
|
|
@@ -357,7 +408,8 @@ export function useDomEditCommits({
|
|
|
357
408
|
label: options.label,
|
|
358
409
|
});
|
|
359
410
|
throw error;
|
|
360
|
-
})
|
|
411
|
+
});
|
|
412
|
+
},
|
|
361
413
|
[
|
|
362
414
|
domEditSaveTimestampRef,
|
|
363
415
|
editHistory,
|
|
@@ -373,12 +425,14 @@ export function useDomEditCommits({
|
|
|
373
425
|
|
|
374
426
|
const {
|
|
375
427
|
handleDomStyleCommit,
|
|
428
|
+
handleDomStyleCommitForSelection,
|
|
376
429
|
handleDomAttributeCommit,
|
|
377
430
|
handleDomAttributeLiveCommit,
|
|
378
431
|
handleDomAttributeQuietCommit,
|
|
379
432
|
handleDomHtmlAttributeCommit,
|
|
380
433
|
handleDomAttributesCommit,
|
|
381
434
|
handleDomTextCommit,
|
|
435
|
+
handleDomTextCommitForSelection,
|
|
382
436
|
handleDomRichTextCommit,
|
|
383
437
|
commitDomTextFields,
|
|
384
438
|
handleDomTextFieldStyleCommit,
|
|
@@ -401,7 +455,6 @@ export function useDomEditCommits({
|
|
|
401
455
|
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
|
|
402
456
|
activeCompPath,
|
|
403
457
|
persistDomEditOperations,
|
|
404
|
-
queueDomEditSave,
|
|
405
458
|
showToast,
|
|
406
459
|
});
|
|
407
460
|
|
|
@@ -439,12 +492,14 @@ export function useDomEditCommits({
|
|
|
439
492
|
return {
|
|
440
493
|
resolveImportedFontAsset,
|
|
441
494
|
handleDomStyleCommit,
|
|
495
|
+
handleDomStyleCommitForSelection,
|
|
442
496
|
handleDomAttributeCommit,
|
|
443
497
|
handleDomAttributeLiveCommit,
|
|
444
498
|
handleDomAttributeQuietCommit,
|
|
445
499
|
handleDomHtmlAttributeCommit,
|
|
446
500
|
handleDomAttributesCommit,
|
|
447
501
|
handleDomTextCommit,
|
|
502
|
+
handleDomTextCommitForSelection,
|
|
448
503
|
handleDomRichTextCommit,
|
|
449
504
|
commitDomTextFields,
|
|
450
505
|
handleDomTextFieldStyleCommit,
|
|
@@ -55,14 +55,13 @@ function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0
|
|
|
55
55
|
return captured.commit;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function paramsWith(
|
|
58
|
+
function paramsWith(persistDomEditOperations: () => Promise<undefined>) {
|
|
59
59
|
const showToast = vi.fn();
|
|
60
60
|
return {
|
|
61
61
|
showToast,
|
|
62
62
|
params: {
|
|
63
63
|
activeCompPath: "index.html",
|
|
64
|
-
persistDomEditOperations
|
|
65
|
-
queueDomEditSave,
|
|
64
|
+
persistDomEditOperations,
|
|
66
65
|
showToast,
|
|
67
66
|
},
|
|
68
67
|
};
|
|
@@ -104,7 +103,7 @@ describe("useDomEditPositionPatchCommit", () => {
|
|
|
104
103
|
});
|
|
105
104
|
|
|
106
105
|
it("resolves when the write lands", async () => {
|
|
107
|
-
const { showToast, params } = paramsWith((
|
|
106
|
+
const { showToast, params } = paramsWith(() => Promise.resolve(undefined));
|
|
108
107
|
const commit = renderCommit(params);
|
|
109
108
|
|
|
110
109
|
await act(async () => {
|
|
@@ -8,7 +8,6 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
|
8
8
|
interface UseDomEditPositionPatchCommitParams {
|
|
9
9
|
activeCompPath: string | null;
|
|
10
10
|
persistDomEditOperations: PersistDomEditOperations;
|
|
11
|
-
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
|
12
11
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
13
12
|
}
|
|
14
13
|
|
|
@@ -22,39 +21,38 @@ interface PositionPatchOptions {
|
|
|
22
21
|
export function useDomEditPositionPatchCommit({
|
|
23
22
|
activeCompPath,
|
|
24
23
|
persistDomEditOperations,
|
|
25
|
-
queueDomEditSave,
|
|
26
24
|
showToast,
|
|
27
25
|
}: UseDomEditPositionPatchCommitParams) {
|
|
28
26
|
return useCallback(
|
|
29
27
|
(selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
return persistDomEditOperations(selection, patches, {
|
|
29
|
+
label: options.label,
|
|
30
|
+
coalesceKey: options.coalesceKey,
|
|
31
|
+
coalesceMs: options.coalesceMs,
|
|
32
|
+
skipRefresh: options.skipRefresh ?? true,
|
|
33
|
+
})
|
|
34
|
+
.then(() => undefined)
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
// A paused save queue is not worth a toast: the paused-save banner is
|
|
37
|
+
// already on screen, and one toast per blocked edit is what this branch
|
|
38
|
+
// exists to prevent. It still has to REJECT, though. Swallowing it
|
|
39
|
+
// resolved the commit, which skipped the caller's revert, so the element
|
|
40
|
+
// stayed where the drag put it while nothing reached the file.
|
|
41
|
+
if (error instanceof DomEditSaveQueueOpenError) throw error;
|
|
42
|
+
showToast(error instanceof Error ? error.message : "Failed to save position");
|
|
43
|
+
trackStudioSaveFailure({
|
|
44
|
+
source: "dom_edit",
|
|
45
|
+
error,
|
|
46
|
+
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
|
|
47
|
+
mutationType: "position",
|
|
48
|
+
label: options.label,
|
|
49
|
+
targetId: selection.id,
|
|
50
|
+
targetSelector: selection.selector,
|
|
51
|
+
targetSourceFile: selection.sourceFile,
|
|
52
|
+
});
|
|
53
|
+
throw error;
|
|
36
54
|
});
|
|
37
|
-
}).catch((error) => {
|
|
38
|
-
// A paused save queue is not worth a toast: the paused-save banner is
|
|
39
|
-
// already on screen, and one toast per blocked edit is what this branch
|
|
40
|
-
// exists to prevent. It still has to REJECT, though. Swallowing it
|
|
41
|
-
// resolved the commit, which skipped the caller's revert, so the element
|
|
42
|
-
// stayed where the drag put it while nothing reached the file.
|
|
43
|
-
if (error instanceof DomEditSaveQueueOpenError) throw error;
|
|
44
|
-
showToast(error instanceof Error ? error.message : "Failed to save position");
|
|
45
|
-
trackStudioSaveFailure({
|
|
46
|
-
source: "dom_edit",
|
|
47
|
-
error,
|
|
48
|
-
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
|
|
49
|
-
mutationType: "position",
|
|
50
|
-
label: options.label,
|
|
51
|
-
targetId: selection.id,
|
|
52
|
-
targetSelector: selection.selector,
|
|
53
|
-
targetSourceFile: selection.sourceFile,
|
|
54
|
-
});
|
|
55
|
-
throw error;
|
|
56
|
-
});
|
|
57
55
|
},
|
|
58
|
-
[activeCompPath, persistDomEditOperations,
|
|
56
|
+
[activeCompPath, persistDomEditOperations, showToast],
|
|
59
57
|
);
|
|
60
58
|
}
|