@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
|
@@ -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
|
});
|
|
@@ -56,6 +56,7 @@ interface ExternalFileChangeCoordinatorOptions {
|
|
|
56
56
|
readProjectFile: (path: string) => Promise<string>;
|
|
57
57
|
onUseExternalFile?: (path: string, content: string) => void;
|
|
58
58
|
resetSaveQueues?: () => void;
|
|
59
|
+
onAcceptedPersistedFileChange: (path: string) => void;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export interface ExternalFileChangeCoordinatorHandle {
|
|
@@ -126,6 +127,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
126
127
|
readProjectFile,
|
|
127
128
|
onUseExternalFile,
|
|
128
129
|
resetSaveQueues,
|
|
130
|
+
onAcceptedPersistedFileChange,
|
|
129
131
|
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
|
|
130
132
|
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
|
|
131
133
|
const generationRef = useRef(0);
|
|
@@ -225,21 +227,24 @@ export function useExternalFileChangeCoordinator({
|
|
|
225
227
|
const content = readFileChangeContent(payload);
|
|
226
228
|
const token = readFileChangeWriteToken(payload);
|
|
227
229
|
logReload("file-change", { path, token: token ?? null, hasContent: content != null });
|
|
228
|
-
if (consumeStudioWriteToken(token)) {
|
|
229
|
-
logReload("suppressed", { path, why: "own write token" });
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (content != null && isSelfWriteEcho(path, content)) {
|
|
233
|
-
logReload("suppressed", { path, why: "own content echo" });
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
230
|
const identity = eventIdentity(path, payload);
|
|
238
231
|
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
|
|
239
232
|
logReload("suppressed", { path, why: "duplicate event" });
|
|
240
233
|
return;
|
|
241
234
|
}
|
|
242
235
|
lastEventIdentityRef.current = identity;
|
|
236
|
+
|
|
237
|
+
const ownWriteToken = consumeStudioWriteToken(token);
|
|
238
|
+
const ownContentEcho = content != null && isSelfWriteEcho(path, content);
|
|
239
|
+
if (ownWriteToken || ownContentEcho) {
|
|
240
|
+
onAcceptedPersistedFileChange(path);
|
|
241
|
+
logReload("suppressed", {
|
|
242
|
+
path,
|
|
243
|
+
why: ownWriteToken ? "own write token" : "own content echo",
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
243
248
|
const generation = ++generationRef.current;
|
|
244
249
|
const result = await drainPendingChanges();
|
|
245
250
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
@@ -258,6 +263,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
258
263
|
}
|
|
259
264
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
260
265
|
setBlocked(null);
|
|
266
|
+
onAcceptedPersistedFileChange(path);
|
|
261
267
|
reloadAcceptedGeneration(path);
|
|
262
268
|
return;
|
|
263
269
|
}
|
|
@@ -326,6 +332,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
326
332
|
persistFailureSnapshot,
|
|
327
333
|
persistSnapshotInOrder,
|
|
328
334
|
reloadAcceptedGeneration,
|
|
335
|
+
onAcceptedPersistedFileChange,
|
|
329
336
|
],
|
|
330
337
|
);
|
|
331
338
|
|
|
@@ -369,12 +376,14 @@ export function useExternalFileChangeCoordinator({
|
|
|
369
376
|
onUseExternalFile?.(path, external);
|
|
370
377
|
await deleteConflictSnapshot?.(projectId, path);
|
|
371
378
|
setBlocked(null);
|
|
379
|
+
onAcceptedPersistedFileChange(path);
|
|
372
380
|
reloadAcceptedGeneration(path);
|
|
373
381
|
},
|
|
374
382
|
[
|
|
375
383
|
deleteConflictSnapshot,
|
|
376
384
|
discardPendingChanges,
|
|
377
385
|
onUseExternalFile,
|
|
386
|
+
onAcceptedPersistedFileChange,
|
|
378
387
|
projectId,
|
|
379
388
|
readProjectFile,
|
|
380
389
|
reloadAcceptedGeneration,
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
gsapSourceFileForSelection,
|
|
5
|
+
selectElementAnimationsOrRetry,
|
|
6
|
+
} from "./useGsapAnimationFetchFallback";
|
|
7
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
8
|
+
|
|
9
|
+
describe("gsapSourceFileForSelection", () => {
|
|
10
|
+
it("uses the explicit target source instead of ambient selection state", () => {
|
|
11
|
+
expect(
|
|
12
|
+
gsapSourceFileForSelection({ sourceFile: "compositions/card.html" } as DomEditSelection),
|
|
13
|
+
).toBe("compositions/card.html");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
4
16
|
|
|
5
17
|
const anim = (targetSelector: string): GsapAnimation =>
|
|
6
18
|
({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation;
|
|
@@ -37,6 +37,10 @@ export interface GsapAnimationFetchOptions {
|
|
|
37
37
|
fresh?: boolean;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function gsapSourceFileForSelection(selection: DomEditSelection): string {
|
|
41
|
+
return selection.sourceFile || "index.html";
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
/**
|
|
41
45
|
* Classify a parse result for one element. Differentiates a hard fetch failure
|
|
42
46
|
* (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
|
|
@@ -82,7 +86,7 @@ async function fetchElementAnimationsWithRetry(
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
export function useGsapAnimationFetchFallback(projectId: string | null
|
|
89
|
+
export function useGsapAnimationFetchFallback(projectId: string | null) {
|
|
86
90
|
return useCallback(
|
|
87
91
|
(selection: DomEditSelection, options?: GsapAnimationFetchOptions) =>
|
|
88
92
|
async (): Promise<GsapAnimation[]> => {
|
|
@@ -90,12 +94,12 @@ export function useGsapAnimationFetchFallback(projectId: string | null, gsapSour
|
|
|
90
94
|
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
91
95
|
return fetchElementAnimationsWithRetry(
|
|
92
96
|
projectId,
|
|
93
|
-
|
|
97
|
+
gsapSourceFileForSelection(selection),
|
|
94
98
|
target,
|
|
95
99
|
options?.failOnFetchError === true,
|
|
96
100
|
options?.fresh === true,
|
|
97
101
|
);
|
|
98
102
|
},
|
|
99
|
-
[projectId
|
|
103
|
+
[projectId],
|
|
100
104
|
);
|
|
101
105
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { useGsapAnimationOps } from "./useGsapAnimationOps";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
type HookApi = ReturnType<typeof useGsapAnimationOps>;
|
|
11
|
+
|
|
12
|
+
let cleanup: (() => void) | null = null;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup?.();
|
|
15
|
+
cleanup = null;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const selection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
19
|
+
|
|
20
|
+
function renderOps(
|
|
21
|
+
commitMutationSafely: (...args: unknown[]) => Promise<void>,
|
|
22
|
+
commitMutation: (...args: unknown[]) => Promise<void> = vi.fn(async () => undefined),
|
|
23
|
+
): HookApi {
|
|
24
|
+
const captured: { api: HookApi | null } = { api: null };
|
|
25
|
+
function Probe() {
|
|
26
|
+
captured.api = useGsapAnimationOps({
|
|
27
|
+
projectIdRef: { current: "project" },
|
|
28
|
+
activeCompPath: "index.html",
|
|
29
|
+
commitMutation,
|
|
30
|
+
commitMutationSafely,
|
|
31
|
+
showToast: vi.fn(),
|
|
32
|
+
sdkSession: null,
|
|
33
|
+
sdkDeps: null,
|
|
34
|
+
});
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const root = createRoot(document.createElement("div"));
|
|
39
|
+
act(() => root.render(<Probe />));
|
|
40
|
+
cleanup = () => act(() => root.unmount());
|
|
41
|
+
if (!captured.api) throw new Error("hook did not initialize");
|
|
42
|
+
return captured.api;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deferredCommit() {
|
|
46
|
+
let release!: () => void;
|
|
47
|
+
const promise = new Promise<void>((resolve) => {
|
|
48
|
+
release = resolve;
|
|
49
|
+
});
|
|
50
|
+
return { commitMutationSafely: vi.fn(() => promise), release };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("useGsapAnimationOps settlement", () => {
|
|
54
|
+
it.each([
|
|
55
|
+
["update", (api: HookApi) => api.updateGsapMeta(selection, "anim-1", { duration: 2 })],
|
|
56
|
+
["delete", (api: HookApi) => api.deleteGsapAnimation(selection, "anim-1")],
|
|
57
|
+
])("keeps %s pending until the shared preview synchronizer settles", async (_name, run) => {
|
|
58
|
+
const deferred = deferredCommit();
|
|
59
|
+
const api = renderOps(deferred.commitMutationSafely);
|
|
60
|
+
let settled = false;
|
|
61
|
+
|
|
62
|
+
const resultPromise = run(api).then(() => {
|
|
63
|
+
settled = true;
|
|
64
|
+
});
|
|
65
|
+
await Promise.resolve();
|
|
66
|
+
expect(settled).toBe(false);
|
|
67
|
+
|
|
68
|
+
deferred.release();
|
|
69
|
+
await resultPromise;
|
|
70
|
+
expect(settled).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("soft-reloads the preview when adding an animation", async () => {
|
|
74
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
75
|
+
const api = renderOps(
|
|
76
|
+
vi.fn(async () => undefined),
|
|
77
|
+
commitMutation,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await api.addGsapAnimation(selection, "from");
|
|
81
|
+
|
|
82
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
83
|
+
selection,
|
|
84
|
+
expect.objectContaining({ type: "add" }),
|
|
85
|
+
expect.objectContaining({ softReload: true }),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("soft-reloads the preview when deleting an animation", async () => {
|
|
90
|
+
const commitMutationSafely = vi.fn(async () => undefined);
|
|
91
|
+
const api = renderOps(commitMutationSafely);
|
|
92
|
+
|
|
93
|
+
await api.deleteGsapAnimation(selection, "anim-1");
|
|
94
|
+
|
|
95
|
+
expect(commitMutationSafely).toHaveBeenCalledWith(
|
|
96
|
+
selection,
|
|
97
|
+
expect.objectContaining({ type: "delete" }),
|
|
98
|
+
expect.objectContaining({ softReload: true }),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -55,7 +55,7 @@ export function useGsapAnimationOps({
|
|
|
55
55
|
);
|
|
56
56
|
if (cutoverCommittedOrThrow(handled)) return;
|
|
57
57
|
}
|
|
58
|
-
commitMutationSafely(
|
|
58
|
+
return commitMutationSafely(
|
|
59
59
|
selection,
|
|
60
60
|
{ type: "update-meta", animationId, updates },
|
|
61
61
|
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
|
|
@@ -77,10 +77,10 @@ export function useGsapAnimationOps({
|
|
|
77
77
|
);
|
|
78
78
|
if (cutoverCommittedOrThrow(handled)) return;
|
|
79
79
|
}
|
|
80
|
-
commitMutationSafely(
|
|
80
|
+
return commitMutationSafely(
|
|
81
81
|
selection,
|
|
82
82
|
{ type: "delete", animationId, stripStudioEdits: true },
|
|
83
|
-
{ label: "Delete GSAP animation" },
|
|
83
|
+
{ label: "Delete GSAP animation", softReload: true },
|
|
84
84
|
);
|
|
85
85
|
},
|
|
86
86
|
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
@@ -178,7 +178,7 @@ export function useGsapAnimationOps({
|
|
|
178
178
|
properties: toDefaults[method] ?? { opacity: 1 },
|
|
179
179
|
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
|
180
180
|
},
|
|
181
|
-
{ label: `Add GSAP ${method} animation
|
|
181
|
+
{ label: `Add GSAP ${method} animation`, softReload: true },
|
|
182
182
|
);
|
|
183
183
|
},
|
|
184
184
|
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
|
|
@@ -45,7 +45,10 @@ afterEach(() => {
|
|
|
45
45
|
vi.clearAllMocks();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
function mountResizeHandler(
|
|
48
|
+
function mountResizeHandler(
|
|
49
|
+
animations: GsapAnimation[],
|
|
50
|
+
targetAnimations: GsapAnimation[] = animations,
|
|
51
|
+
) {
|
|
49
52
|
const element = document.createElement("div");
|
|
50
53
|
const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
|
|
51
54
|
const fallback = vi.fn().mockResolvedValue(undefined);
|
|
@@ -66,7 +69,7 @@ function mountResizeHandler(animations: GsapAnimation[]) {
|
|
|
66
69
|
previewIframeRef: { current: null },
|
|
67
70
|
showToast: vi.fn(),
|
|
68
71
|
bumpGsapCache: vi.fn(),
|
|
69
|
-
makeFetchFallback: () => vi.fn().mockResolvedValue(
|
|
72
|
+
makeFetchFallback: () => vi.fn().mockResolvedValue(targetAnimations),
|
|
70
73
|
trackGsapInteractionFailure: vi.fn(),
|
|
71
74
|
handleDomBoxSizeCommit: fallback,
|
|
72
75
|
addGsapAnimation: vi.fn(),
|
|
@@ -112,6 +115,31 @@ function mountGroupHandler({
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
describe("useGsapAwareEditing anchored resize", () => {
|
|
118
|
+
it("uses the explicit target's animations instead of the human selection cache", async () => {
|
|
119
|
+
const humanAnimation = { id: "human", propertyGroup: "scale" } as GsapAnimation;
|
|
120
|
+
const targetAnimation = { id: "target", propertyGroup: "size" } as GsapAnimation;
|
|
121
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
122
|
+
const h = mountResizeHandler([humanAnimation], [targetAnimation]);
|
|
123
|
+
const target = {
|
|
124
|
+
...h.selection,
|
|
125
|
+
element: document.createElement("div"),
|
|
126
|
+
id: "agent-target",
|
|
127
|
+
selector: "#agent-target",
|
|
128
|
+
} as DomEditSelection;
|
|
129
|
+
|
|
130
|
+
await act(() => h.resize(target, { width: 300, height: 200 }));
|
|
131
|
+
|
|
132
|
+
expect(mocks.resize).toHaveBeenCalledWith(
|
|
133
|
+
target,
|
|
134
|
+
{ width: 300, height: 200 },
|
|
135
|
+
[targetAnimation],
|
|
136
|
+
null,
|
|
137
|
+
expect.any(Function),
|
|
138
|
+
expect.any(Function),
|
|
139
|
+
);
|
|
140
|
+
act(() => h.root.unmount());
|
|
141
|
+
});
|
|
142
|
+
|
|
115
143
|
it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
|
|
116
144
|
mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
|
|
117
145
|
const h = mountResizeHandler([]);
|
|
@@ -125,6 +125,14 @@ export function useGsapAwareEditing({
|
|
|
125
125
|
}: UseGsapAwareEditingParams) {
|
|
126
126
|
// ── GSAP-aware geometry commits ──
|
|
127
127
|
|
|
128
|
+
const getGsapAnimationsForSelection = useCallback(
|
|
129
|
+
(selection: DomEditSelection): GsapAnimation[] | Promise<GsapAnimation[]> => {
|
|
130
|
+
if (domEditSelection?.element === selection.element) return selectedGsapAnimations;
|
|
131
|
+
return makeFetchFallback(selection, { failOnFetchError: true })();
|
|
132
|
+
},
|
|
133
|
+
[domEditSelection, selectedGsapAnimations, makeFetchFallback],
|
|
134
|
+
);
|
|
135
|
+
|
|
128
136
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
129
137
|
async (
|
|
130
138
|
selection: DomEditSelection,
|
|
@@ -133,10 +141,14 @@ export function useGsapAwareEditing({
|
|
|
133
141
|
) => {
|
|
134
142
|
if (gsapCommitMutation) {
|
|
135
143
|
try {
|
|
144
|
+
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
145
|
+
const targetAnimations = Array.isArray(ownedAnimations)
|
|
146
|
+
? ownedAnimations
|
|
147
|
+
: await ownedAnimations;
|
|
136
148
|
const outcome = await tryGsapDragIntercept(
|
|
137
149
|
selection,
|
|
138
150
|
next,
|
|
139
|
-
|
|
151
|
+
targetAnimations,
|
|
140
152
|
previewIframeRef.current,
|
|
141
153
|
gsapCommitMutation,
|
|
142
154
|
makeFetchFallback(selection),
|
|
@@ -150,11 +162,11 @@ export function useGsapAwareEditing({
|
|
|
150
162
|
}
|
|
151
163
|
},
|
|
152
164
|
[
|
|
153
|
-
selectedGsapAnimations,
|
|
154
165
|
gsapCommitMutation,
|
|
155
166
|
previewIframeRef,
|
|
156
167
|
makeFetchFallback,
|
|
157
168
|
trackGsapInteractionFailure,
|
|
169
|
+
getGsapAnimationsForSelection,
|
|
158
170
|
],
|
|
159
171
|
);
|
|
160
172
|
|
|
@@ -286,7 +298,11 @@ export function useGsapAwareEditing({
|
|
|
286
298
|
offset?: { x: number; y: number },
|
|
287
299
|
restore: () => void = () => undefined,
|
|
288
300
|
) => {
|
|
289
|
-
const
|
|
301
|
+
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
302
|
+
const targetAnimations = Array.isArray(ownedAnimations)
|
|
303
|
+
? ownedAnimations
|
|
304
|
+
: await ownedAnimations;
|
|
305
|
+
const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
|
|
290
306
|
const selector = selectorFromSelection(selection);
|
|
291
307
|
const hasLivePositionTween = selector
|
|
292
308
|
? hasNonHoldTweenForElement(
|
|
@@ -300,8 +316,8 @@ export function useGsapAwareEditing({
|
|
|
300
316
|
next,
|
|
301
317
|
offset: offset ?? null,
|
|
302
318
|
scaleRoute,
|
|
303
|
-
animCount:
|
|
304
|
-
animGroups:
|
|
319
|
+
animCount: targetAnimations.length,
|
|
320
|
+
animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
|
|
305
321
|
});
|
|
306
322
|
return runGestureTransaction({
|
|
307
323
|
element: selection.element,
|
|
@@ -325,7 +341,7 @@ export function useGsapAwareEditing({
|
|
|
325
341
|
const outcome = await tryGsapResizeIntercept(
|
|
326
342
|
selection,
|
|
327
343
|
next,
|
|
328
|
-
|
|
344
|
+
targetAnimations,
|
|
329
345
|
previewIframeRef.current,
|
|
330
346
|
commitMutation,
|
|
331
347
|
makeFetchFallback(selection),
|
|
@@ -349,7 +365,7 @@ export function useGsapAwareEditing({
|
|
|
349
365
|
const dragOutcome = await tryGsapDragIntercept(
|
|
350
366
|
selection,
|
|
351
367
|
offset,
|
|
352
|
-
|
|
368
|
+
targetAnimations,
|
|
353
369
|
previewIframeRef.current,
|
|
354
370
|
commitMutation,
|
|
355
371
|
makeFetchFallback(selection),
|
|
@@ -377,11 +393,11 @@ export function useGsapAwareEditing({
|
|
|
377
393
|
},
|
|
378
394
|
[
|
|
379
395
|
handleDomBoxSizeCommit,
|
|
380
|
-
selectedGsapAnimations,
|
|
381
396
|
gsapCommitMutation,
|
|
382
397
|
previewIframeRef,
|
|
383
398
|
makeFetchFallback,
|
|
384
399
|
trackGsapInteractionFailure,
|
|
400
|
+
getGsapAnimationsForSelection,
|
|
385
401
|
],
|
|
386
402
|
);
|
|
387
403
|
|
|
@@ -389,6 +405,10 @@ export function useGsapAwareEditing({
|
|
|
389
405
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
390
406
|
if (gsapCommitMutation) {
|
|
391
407
|
try {
|
|
408
|
+
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
409
|
+
const targetAnimations = Array.isArray(ownedAnimations)
|
|
410
|
+
? ownedAnimations
|
|
411
|
+
: await ownedAnimations;
|
|
392
412
|
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
393
413
|
// tweened elements (keyframes) and static ones (a tl.set), so there's no
|
|
394
414
|
// CSS-var fallback. Selectorless/computed source rejects so the gesture
|
|
@@ -396,7 +416,7 @@ export function useGsapAwareEditing({
|
|
|
396
416
|
const outcome = await tryGsapRotationIntercept(
|
|
397
417
|
selection,
|
|
398
418
|
next.angle,
|
|
399
|
-
|
|
419
|
+
targetAnimations,
|
|
400
420
|
previewIframeRef.current,
|
|
401
421
|
gsapCommitMutation,
|
|
402
422
|
makeFetchFallback(selection),
|
|
@@ -409,11 +429,11 @@ export function useGsapAwareEditing({
|
|
|
409
429
|
}
|
|
410
430
|
},
|
|
411
431
|
[
|
|
412
|
-
selectedGsapAnimations,
|
|
413
432
|
gsapCommitMutation,
|
|
414
433
|
previewIframeRef,
|
|
415
434
|
makeFetchFallback,
|
|
416
435
|
trackGsapInteractionFailure,
|
|
436
|
+
getGsapAnimationsForSelection,
|
|
417
437
|
],
|
|
418
438
|
);
|
|
419
439
|
|
|
@@ -507,6 +527,7 @@ export function useGsapAwareEditing({
|
|
|
507
527
|
}, [commitMutation]);
|
|
508
528
|
|
|
509
529
|
return {
|
|
530
|
+
getGsapAnimationsForSelection,
|
|
510
531
|
handleGsapAwarePathOffsetCommit,
|
|
511
532
|
handleGsapAwareGroupPathOffsetCommit,
|
|
512
533
|
handleGsapAwareBoxSizeCommit,
|
|
@@ -113,7 +113,9 @@ describe("useGsapSelectionHandlers save failures", () => {
|
|
|
113
113
|
makeParams({ addGsapAnimation: vi.fn().mockRejectedValue(error), showToast }),
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
act(() =>
|
|
116
|
+
act(() => {
|
|
117
|
+
void rendered.handlers().handleGsapAddAnimation("to");
|
|
118
|
+
});
|
|
117
119
|
await flushRejection();
|
|
118
120
|
|
|
119
121
|
expect(showToast).not.toHaveBeenCalled();
|