@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.
Files changed (90) hide show
  1. package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6206 -5011
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /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 { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback";
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, gsapSourceFile: string) {
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
- gsapSourceFile,
97
+ gsapSourceFileForSelection(selection),
94
98
  target,
95
99
  options?.failOnFetchError === true,
96
100
  options?.fresh === true,
97
101
  );
98
102
  },
99
- [projectId, gsapSourceFile],
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(animations: GsapAnimation[]) {
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(animations),
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
- selectedGsapAnimations,
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 scaleRoute = selectedGsapAnimations.some((anim) => anim.propertyGroup === "scale");
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: selectedGsapAnimations.length,
304
- animGroups: selectedGsapAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
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
- selectedGsapAnimations,
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
- selectedGsapAnimations,
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
- selectedGsapAnimations,
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(() => rendered.handlers().handleGsapAddAnimation("to"));
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(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
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
- if (!domEditSelection) return;
227
- void addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime).catch(
228
- (error) => {
229
- trackGsapHandlerFailure(error, domEditSelection, "add", `Add GSAP ${method} animation`);
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 (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
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 handleDomManualEditsReset(domEditSelection).catch(() => undefined);
242
+ void landed.then((didLand) => {
243
+ if (didLand) void handleDomManualEditsReset(selection).catch(() => undefined);
244
+ });
236
245
  }
246
+ return landed;
237
247
  },
238
- [domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
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
  }