@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
|
@@ -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();
|
|
@@ -200,8 +200,13 @@ export function useGsapSelectionHandlers({
|
|
|
200
200
|
const handleGsapDeleteAnimation = useCallback(
|
|
201
201
|
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
|
202
202
|
const sel = resolveWriteSelection(selectionOverride);
|
|
203
|
-
if (!sel) return;
|
|
204
|
-
observeGsapMutation(
|
|
203
|
+
if (!sel) return Promise.resolve(false);
|
|
204
|
+
return observeGsapMutation(
|
|
205
|
+
deleteGsapAnimation(sel, animId),
|
|
206
|
+
sel,
|
|
207
|
+
"delete",
|
|
208
|
+
"Delete GSAP animation",
|
|
209
|
+
);
|
|
205
210
|
},
|
|
206
211
|
[resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
|
|
207
212
|
);
|
|
@@ -222,20 +227,25 @@ export function useGsapSelectionHandlers({
|
|
|
222
227
|
);
|
|
223
228
|
|
|
224
229
|
const handleGsapAddAnimation = useCallback(
|
|
225
|
-
(method: "to" | "from" | "set" | "fromTo") => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
230
|
+
(method: "to" | "from" | "set" | "fromTo", selectionOverride?: DomEditSelection | null) => {
|
|
231
|
+
const selection = resolveWriteSelection(selectionOverride);
|
|
232
|
+
if (!selection) return Promise.resolve(false);
|
|
233
|
+
const landed = observeGsapMutation(
|
|
234
|
+
addGsapAnimation(selection, method, usePlayerStore.getState().currentTime),
|
|
235
|
+
selection,
|
|
236
|
+
"add",
|
|
237
|
+
`Add GSAP ${method} animation`,
|
|
231
238
|
);
|
|
232
|
-
if (
|
|
239
|
+
if (selection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
233
240
|
// The reset owns rollback and the position commit already owns user and
|
|
234
241
|
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
|
235
|
-
void
|
|
242
|
+
void landed.then((didLand) => {
|
|
243
|
+
if (didLand) void handleDomManualEditsReset(selection).catch(() => undefined);
|
|
244
|
+
});
|
|
236
245
|
}
|
|
246
|
+
return landed;
|
|
237
247
|
},
|
|
238
|
-
[
|
|
248
|
+
[resolveWriteSelection, addGsapAnimation, handleDomManualEditsReset, observeGsapMutation],
|
|
239
249
|
);
|
|
240
250
|
|
|
241
251
|
const handleGsapAddProperty = useCallback(
|
|
@@ -241,4 +241,31 @@ describe("useRenderClipContent", () => {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
it("forwards persisted content revision to mounted composition thumbnails", () => {
|
|
246
|
+
usePlayerStore.setState({
|
|
247
|
+
thumbnailMode: "adaptive",
|
|
248
|
+
timelineSessionEpoch: 7,
|
|
249
|
+
thumbnailContentRevision: 11,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const content = renderClipContent({
|
|
253
|
+
id: "nested",
|
|
254
|
+
tag: "div",
|
|
255
|
+
start: 0,
|
|
256
|
+
duration: 4,
|
|
257
|
+
track: 0,
|
|
258
|
+
compositionSrc: "compositions/nested.html",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(isValidElement(content)).toBe(true);
|
|
262
|
+
if (isValidElement(content)) {
|
|
263
|
+
expect(content.type).toBe(CompositionThumbnail);
|
|
264
|
+
expect(content.props).toMatchObject({
|
|
265
|
+
projectId: "my-project",
|
|
266
|
+
sessionEpoch: 7,
|
|
267
|
+
contentRevision: 11,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
244
271
|
});
|
|
@@ -110,6 +110,7 @@ export function useRenderClipContent({
|
|
|
110
110
|
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
111
111
|
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
|
112
112
|
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
113
|
+
const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
|
|
113
114
|
return useCallback(
|
|
114
115
|
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
|
115
116
|
// fallow-ignore-next-line complexity
|
|
@@ -153,6 +154,7 @@ export function useRenderClipContent({
|
|
|
153
154
|
duration: el.duration,
|
|
154
155
|
projectId: pid,
|
|
155
156
|
sessionEpoch,
|
|
157
|
+
contentRevision,
|
|
156
158
|
priority: context.priority,
|
|
157
159
|
rich: context.rich,
|
|
158
160
|
});
|
|
@@ -179,6 +181,7 @@ export function useRenderClipContent({
|
|
|
179
181
|
duration: el.duration,
|
|
180
182
|
projectId: pid,
|
|
181
183
|
sessionEpoch,
|
|
184
|
+
contentRevision,
|
|
182
185
|
priority: context.priority,
|
|
183
186
|
rich: context.rich,
|
|
184
187
|
});
|
|
@@ -232,6 +235,7 @@ export function useRenderClipContent({
|
|
|
232
235
|
duration: el.duration,
|
|
233
236
|
projectId: pid,
|
|
234
237
|
sessionEpoch,
|
|
238
|
+
contentRevision,
|
|
235
239
|
priority: context.priority,
|
|
236
240
|
rich: context.rich,
|
|
237
241
|
});
|
|
@@ -246,6 +250,7 @@ export function useRenderClipContent({
|
|
|
246
250
|
effectiveTimelineDuration,
|
|
247
251
|
effectiveMode,
|
|
248
252
|
sessionEpoch,
|
|
253
|
+
contentRevision,
|
|
249
254
|
],
|
|
250
255
|
);
|
|
251
256
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
2
3
|
import {
|
|
3
4
|
deleteExternalConflictSnapshot,
|
|
4
5
|
loadExternalConflictSnapshot,
|
|
@@ -48,6 +49,9 @@ export function useStudioExternalFileChanges({
|
|
|
48
49
|
}: UseStudioExternalFileChangesOptions) {
|
|
49
50
|
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
|
50
51
|
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
|
52
|
+
const bumpThumbnailContentRevision = usePlayerStore(
|
|
53
|
+
(state) => state.bumpThumbnailContentRevision,
|
|
54
|
+
);
|
|
51
55
|
const drainPendingChanges = useCallback(async () => {
|
|
52
56
|
const source = await flushPendingSourceSave();
|
|
53
57
|
if (source.status !== "clean") return source;
|
|
@@ -77,5 +81,6 @@ export function useStudioExternalFileChanges({
|
|
|
77
81
|
readProjectFile: fileManager.readProjectFile,
|
|
78
82
|
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
79
83
|
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
84
|
+
onAcceptedPersistedFileChange: bumpThumbnailContentRevision,
|
|
80
85
|
});
|
|
81
86
|
}
|