@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -15,6 +15,7 @@ import {
15
15
  } from "../utils/sdkCutover";
16
16
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
17
17
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
18
+ import { idFromSelector } from "./gsapShared";
18
19
  import {
19
20
  clearKeyframeCacheForElement,
20
21
  readKeyframeSnapshot,
@@ -236,7 +237,7 @@ export function useGsapKeyframeOps({
236
237
  );
237
238
 
238
239
  const moveKeyframe = useCallback(
239
- (
240
+ async (
240
241
  selection: DomEditSelection,
241
242
  animationId: string,
242
243
  fromPercentage: number,
@@ -247,18 +248,26 @@ export function useGsapKeyframeOps({
247
248
  // updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
248
249
  // optimistic cache write is needed (mapping the tween-% to clip-% here would
249
250
  // duplicate that math). softReload mirrors remove-keyframe.
250
- void commitMutation(selection, mutation, {
251
- label: `Move keyframe to ${toPercentage}%`,
252
- softReload: true,
253
- }).catch((error) => {
251
+ try {
252
+ let changed = false;
253
+ await commitMutation(selection, mutation, {
254
+ label: `Move keyframe to ${toPercentage}%`,
255
+ softReload: true,
256
+ onResult: (result) => {
257
+ changed = result.changed !== false;
258
+ },
259
+ });
260
+ return changed;
261
+ } catch (error) {
254
262
  trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
255
- });
263
+ return false;
264
+ }
256
265
  },
257
266
  [commitMutation, trackGsapSaveFailure],
258
267
  );
259
268
 
260
269
  const resizeKeyframedTween = useCallback(
261
- (
270
+ async (
262
271
  selection: DomEditSelection,
263
272
  animationId: string,
264
273
  position: number,
@@ -275,12 +284,20 @@ export function useGsapKeyframeOps({
275
284
  // Boundary drag-to-retime: the server re-keys keyframes in place + grows the
276
285
  // tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
277
286
  // softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
278
- void commitMutation(selection, mutation, {
279
- label: "Retime keyframe (resize tween)",
280
- softReload: true,
281
- }).catch((error) => {
287
+ try {
288
+ let changed = false;
289
+ await commitMutation(selection, mutation, {
290
+ label: "Retime keyframe (resize tween)",
291
+ softReload: true,
292
+ onResult: (result) => {
293
+ changed = result.changed !== false;
294
+ },
295
+ });
296
+ return changed;
297
+ } catch (error) {
282
298
  trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
283
- });
299
+ return false;
300
+ }
284
301
  },
285
302
  [commitMutation, trackGsapSaveFailure],
286
303
  );
@@ -322,7 +339,7 @@ export function useGsapKeyframeOps({
322
339
  // remove-all-keyframes collapses the tween to a static hold and the commit
323
340
  // path doesn't return parsed animations, so the keyframe cache is never
324
341
  // refreshed — clear it here so the timeline diamonds disappear immediately.
325
- const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null;
342
+ const elementId = selection.id ?? idFromSelector(selection.selector);
326
343
  if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
327
344
  if (sdkSession && sdkDeps) {
328
345
  const handled = await sdkGsapRemoveAllKeyframesPersist(
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
298
298
  expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
299
299
  });
300
300
 
301
+ it("publishes the server mutation outcome to callers", async () => {
302
+ mockFetchResult({ changed: false });
303
+ const deps = renderCommitHook();
304
+ let commitResult: MutationResult | undefined;
305
+
306
+ await act(async () => {
307
+ await deps.api.commitMutation(
308
+ selection,
309
+ { type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
310
+ {
311
+ label: "Move keyframe",
312
+ onResult: (result) => {
313
+ commitResult = result;
314
+ },
315
+ },
316
+ );
317
+ });
318
+
319
+ expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
320
+ });
321
+
301
322
  it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
302
323
  patchRuntimeTweenInPlace.mockReturnValue(true);
303
324
  mockFetchResult({ changed: false });
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
338
338
  mutateGsapScript(pid, targetPath, mutation),
339
339
  );
340
340
  if (!result) return;
341
+ options.onResult?.(result);
341
342
  await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
342
343
  }, [showToast, finalizeSuccessfulMutation]);
343
344
 
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
350
351
  mutateGsapScriptBatch(pid, targetPath, mutations),
351
352
  );
352
353
  if (!result) return;
354
+ options.onResult?.(result);
353
355
  await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
354
356
  }, [showToast, finalizeSuccessfulMutation]);
355
357
 
@@ -2,7 +2,9 @@
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { describe, expect, it, vi } from "vitest";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import { usePlayerStore } from "../player/store/playerStore";
6
8
  import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
7
9
 
8
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -37,8 +39,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
37
39
  addKeyframe: vi.fn(),
38
40
  addKeyframeBatch: resolved(),
39
41
  removeKeyframe: vi.fn(),
40
- moveKeyframe: vi.fn(),
41
- resizeKeyframedTween: vi.fn(),
42
+ moveKeyframe: vi.fn().mockResolvedValue(true),
43
+ resizeKeyframedTween: vi.fn().mockResolvedValue(true),
42
44
  convertToKeyframes: resolved(),
43
45
  removeAllKeyframes: resolved(),
44
46
  handleDomManualEditsReset: vi.fn(),
@@ -79,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => {
79
81
  makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
80
82
  );
81
83
 
82
- act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
84
+ // Braces, not a bare arrow: the handler returns its settlement promise now,
85
+ // and returning a thenable from act() turns it into an un-awaited async act.
86
+ act(() => {
87
+ void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
88
+ });
83
89
  await flushRejection();
84
90
 
85
91
  expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
@@ -114,3 +120,63 @@ describe("useGsapSelectionHandlers save failures", () => {
114
120
  rendered.unmount();
115
121
  });
116
122
  });
123
+
124
+ describe("useGsapSelectionHandlers selection override", () => {
125
+ it("aborts on an explicit null override instead of writing to the current selection", () => {
126
+ const removeKeyframe = vi.fn();
127
+ const rendered = renderHandlers(makeParams({ removeKeyframe }));
128
+
129
+ // Explicit null: the caller resolved a selection for its own element and
130
+ // found none, so the write must not land on the selected element.
131
+ rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
132
+ expect(removeKeyframe).not.toHaveBeenCalled();
133
+
134
+ // Omitted override: falls back to the current selection as before.
135
+ rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
136
+ expect(removeKeyframe).toHaveBeenCalledOnce();
137
+ rendered.unmount();
138
+ });
139
+
140
+ it("computes the playhead percentage from the passed animation, not the selection's", () => {
141
+ const moveKeyframe = vi.fn();
142
+ const selection = makeSelection();
143
+ // The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
144
+ // the animation the handler falls back to the selection's own element window
145
+ // (0s→1s here), which reads the same playhead as 100%. Asserting the exact
146
+ // 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
147
+ // missing window would produce.
148
+ const animation = {
149
+ id: "anim-1",
150
+ position: 2,
151
+ resolvedStart: 2,
152
+ duration: 4,
153
+ keyframes: { keyframes: [] },
154
+ } as unknown as GsapAnimation;
155
+ usePlayerStore.setState({ currentTime: 3 });
156
+ const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
157
+
158
+ rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
159
+
160
+ expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
161
+ rendered.unmount();
162
+ });
163
+ });
164
+
165
+ describe("useGsapSelectionHandlers retime settlement", () => {
166
+ it("returns false without a selection and forwards the mutation result with one", async () => {
167
+ const moveKeyframe = vi.fn().mockResolvedValue(true);
168
+ const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
169
+ await expect(
170
+ withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
171
+ ).resolves.toBe(false);
172
+ expect(moveKeyframe).not.toHaveBeenCalled();
173
+ withoutSelection.unmount();
174
+
175
+ const withSelection = renderHandlers(makeParams({ moveKeyframe }));
176
+ await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
177
+ true,
178
+ );
179
+ expect(moveKeyframe).toHaveBeenCalledOnce();
180
+ withSelection.unmount();
181
+ });
182
+ });
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
94
94
  animId: string,
95
95
  fromPercentage: number,
96
96
  toPercentage: number,
97
- ) => void;
97
+ ) => Promise<boolean>;
98
98
  resizeKeyframedTween: (
99
99
  sel: DomEditSelection,
100
100
  animId: string,
101
101
  position: number,
102
102
  duration: number,
103
103
  pctRemap: Array<{ from: number; to: number }>,
104
- ) => void;
104
+ ) => Promise<boolean>;
105
105
  convertToKeyframes: (
106
106
  sel: DomEditSelection,
107
107
  animId: string,
@@ -118,6 +118,19 @@ export function useGsapSelectionHandlers({
118
118
  const lastSelectionRef = useRef<DomEditSelection | null>(null);
119
119
  if (domEditSelection) lastSelectionRef.current = domEditSelection;
120
120
 
121
+ // `undefined` means the caller passed no override and accepts the current
122
+ // selection. An explicit `null` means the caller RESOLVED a selection for the
123
+ // element it is editing and there is none: falling back to domEditSelection
124
+ // there commits the edit onto whichever element happens to be selected, which
125
+ // is a different element's file. Only `undefined` may fall back.
126
+ const resolveWriteSelection = useCallback(
127
+ (selectionOverride?: DomEditSelection | null): DomEditSelection | null =>
128
+ selectionOverride === undefined
129
+ ? (domEditSelection ?? lastSelectionRef.current)
130
+ : selectionOverride,
131
+ [domEditSelection],
132
+ );
133
+
121
134
  const trackGsapHandlerFailure = useCallback(
122
135
  (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
123
136
  trackStudioSaveFailure({
@@ -137,12 +150,24 @@ export function useGsapSelectionHandlers({
137
150
  [showToast],
138
151
  );
139
152
 
153
+ // Resolves to whether the mutation landed. Callers that only fire-and-forget
154
+ // can ignore it (the rejection is always handled here), but a caller that
155
+ // reports a commit result to the UI has to await the real settlement instead
156
+ // of assuming success the moment it dispatched.
140
157
  const observeGsapMutation = useCallback(
141
- (mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
142
- void mutation.catch((error) => {
143
- trackGsapHandlerFailure(error, selection, mutationType, label);
144
- });
145
- },
158
+ (
159
+ mutation: Promise<void>,
160
+ selection: DomEditSelection,
161
+ mutationType: string,
162
+ label: string,
163
+ ): Promise<boolean> =>
164
+ mutation.then(
165
+ () => true,
166
+ (error: unknown) => {
167
+ trackGsapHandlerFailure(error, selection, mutationType, label);
168
+ return false;
169
+ },
170
+ ),
146
171
  [trackGsapHandlerFailure],
147
172
  );
148
173
 
@@ -160,25 +185,25 @@ export function useGsapSelectionHandlers({
160
185
  updates: { duration?: number; ease?: string; position?: number },
161
186
  selectionOverride?: DomEditSelection | null,
162
187
  ) => {
163
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
164
- if (!sel) return;
165
- observeGsapMutation(
188
+ const sel = resolveWriteSelection(selectionOverride);
189
+ if (!sel) return Promise.resolve(false);
190
+ return observeGsapMutation(
166
191
  updateGsapMeta(sel, animId, updates),
167
192
  sel,
168
193
  "update-meta",
169
194
  "Edit GSAP animation",
170
195
  );
171
196
  },
172
- [domEditSelection, observeGsapMutation, updateGsapMeta],
197
+ [resolveWriteSelection, observeGsapMutation, updateGsapMeta],
173
198
  );
174
199
 
175
200
  const handleGsapDeleteAnimation = useCallback(
176
- (animId: string) => {
177
- const sel = domEditSelection ?? lastSelectionRef.current;
201
+ (animId: string, selectionOverride?: DomEditSelection | null) => {
202
+ const sel = resolveWriteSelection(selectionOverride);
178
203
  if (!sel) return;
179
204
  observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
180
205
  },
181
- [domEditSelection, deleteGsapAnimation, observeGsapMutation],
206
+ [resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
182
207
  );
183
208
 
184
209
  const handleGsapDeleteAllForElement = useCallback(
@@ -284,12 +309,12 @@ export function useGsapSelectionHandlers({
284
309
  value: number | string,
285
310
  selectionOverride?: DomEditSelection | null,
286
311
  ) => {
287
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
312
+ const sel = resolveWriteSelection(selectionOverride);
288
313
  if (!sel) return;
289
314
  trackStudioEvent("keyframe", { action: "add", property });
290
315
  addKeyframe(sel, animId, percentage, property, value);
291
316
  },
292
- [domEditSelection, addKeyframe],
317
+ [resolveWriteSelection, addKeyframe],
293
318
  );
294
319
 
295
320
  const handleGsapAddKeyframeBatch = useCallback(
@@ -298,19 +323,17 @@ export function useGsapSelectionHandlers({
298
323
  percentage: number,
299
324
  properties: Record<string, number | string>,
300
325
  commitOverrides?: Partial<CommitMutationOptions>,
326
+ selectionOverride?: DomEditSelection | null,
301
327
  ) => {
302
- if (!domEditSelection) return Promise.resolve();
303
- return addKeyframeBatch(
304
- domEditSelection,
305
- animId,
306
- percentage,
307
- properties,
308
- commitOverrides,
309
- ).catch((error) => {
310
- trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
311
- });
328
+ const sel = resolveWriteSelection(selectionOverride);
329
+ if (!sel) return Promise.resolve();
330
+ return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch(
331
+ (error) => {
332
+ trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe");
333
+ },
334
+ );
312
335
  },
313
- [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
336
+ [resolveWriteSelection, addKeyframeBatch, trackGsapHandlerFailure],
314
337
  );
315
338
  const handleGsapRemoveKeyframe = useCallback(
316
339
  (
@@ -319,26 +342,34 @@ export function useGsapSelectionHandlers({
319
342
  commitOverrides?: Partial<CommitMutationOptions>,
320
343
  selectionOverride?: DomEditSelection | null,
321
344
  ) => {
322
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
345
+ const sel = resolveWriteSelection(selectionOverride);
323
346
  if (!sel) return;
324
347
  trackStudioEvent("keyframe", { action: "remove" });
325
348
  removeKeyframe(sel, animId, percentage, commitOverrides);
326
349
  },
327
- [domEditSelection, removeKeyframe],
350
+ [resolveWriteSelection, removeKeyframe],
328
351
  );
329
352
 
330
353
  const handleGsapMoveKeyframeToPlayhead = useCallback(
331
- (animId: string, fromPercentage: number, selectionOverride?: DomEditSelection | null) => {
332
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
354
+ (
355
+ animId: string,
356
+ fromPercentage: number,
357
+ selectionOverride?: DomEditSelection | null,
358
+ animationOverride?: GsapAnimation,
359
+ ) => {
360
+ const sel = resolveWriteSelection(selectionOverride);
333
361
  if (!sel) return;
334
362
  // Retime the keyframe to the playhead, preserving its value + ease. The
335
- // playhead's tween-relative percentage is the move target.
336
- const anim = selectedGsapAnimations.find((a) => a.id === animId);
363
+ // playhead's tween-relative percentage is the move target, and it has to
364
+ // come from the SAME element the write lands on: reading the animation off
365
+ // the current selection while the percentage came from the clicked element
366
+ // computes the target against one tween and writes it into another.
367
+ const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
337
368
  const toPercentage = computeCurrentPercentage(sel, anim);
338
369
  trackStudioEvent("keyframe", { action: "move_to_playhead" });
339
- moveKeyframe(sel, animId, fromPercentage, toPercentage);
370
+ void moveKeyframe(sel, animId, fromPercentage, toPercentage);
340
371
  },
341
- [domEditSelection, selectedGsapAnimations, moveKeyframe],
372
+ [resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
342
373
  );
343
374
 
344
375
  const handleGsapMoveKeyframe = useCallback(
@@ -348,16 +379,16 @@ export function useGsapSelectionHandlers({
348
379
  toPercentage: number,
349
380
  selectionOverride?: DomEditSelection | null,
350
381
  ) => {
351
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
352
- if (!sel) return;
382
+ const sel = resolveWriteSelection(selectionOverride);
383
+ if (!sel) return Promise.resolve(false);
353
384
  // Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
354
385
  // percentages are tween-relative (the drag handler converts the drop
355
386
  // position before calling). No optimistic runtime hold — the soft-reload
356
387
  // re-keys the diamond from source.
357
388
  trackStudioEvent("keyframe", { action: "retime" });
358
- moveKeyframe(sel, animId, fromPercentage, toPercentage);
389
+ return moveKeyframe(sel, animId, fromPercentage, toPercentage);
359
390
  },
360
- [domEditSelection, moveKeyframe],
391
+ [resolveWriteSelection, moveKeyframe],
361
392
  );
362
393
 
363
394
  const handleGsapResizeKeyframedTween = useCallback(
@@ -368,14 +399,14 @@ export function useGsapSelectionHandlers({
368
399
  pctRemap: Array<{ from: number; to: number }>,
369
400
  selectionOverride?: DomEditSelection | null,
370
401
  ) => {
371
- const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
372
- if (!sel) return;
402
+ const sel = resolveWriteSelection(selectionOverride);
403
+ if (!sel) return Promise.resolve(false);
373
404
  // Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
374
405
  // in place. Distinct telemetry action so resize is separable from in-window move.
375
406
  trackStudioEvent("keyframe", { action: "retime_resize" });
376
- resizeKeyframedTween(sel, animId, position, duration, pctRemap);
407
+ return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
377
408
  },
378
- [domEditSelection, resizeKeyframedTween],
409
+ [resolveWriteSelection, resizeKeyframedTween],
379
410
  );
380
411
 
381
412
  const handleGsapConvertToKeyframes = useCallback(
@@ -384,37 +415,31 @@ export function useGsapSelectionHandlers({
384
415
  resolvedFromValues?: Record<string, number | string>,
385
416
  duration?: number,
386
417
  commitOverrides?: Partial<CommitMutationOptions>,
418
+ selectionOverride?: DomEditSelection | null,
387
419
  ) => {
388
- if (!domEditSelection) return Promise.resolve();
389
- return convertToKeyframes(
390
- domEditSelection,
391
- animId,
392
- resolvedFromValues,
393
- duration,
394
- commitOverrides,
395
- ).catch((error) => {
396
- trackGsapHandlerFailure(
397
- error,
398
- domEditSelection,
399
- "convert-to-keyframes",
400
- "Convert to keyframes",
401
- );
402
- });
420
+ const sel = resolveWriteSelection(selectionOverride);
421
+ if (!sel) return Promise.resolve();
422
+ return convertToKeyframes(sel, animId, resolvedFromValues, duration, commitOverrides).catch(
423
+ (error) => {
424
+ trackGsapHandlerFailure(error, sel, "convert-to-keyframes", "Convert to keyframes");
425
+ },
426
+ );
403
427
  },
404
- [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
428
+ [resolveWriteSelection, convertToKeyframes, trackGsapHandlerFailure],
405
429
  );
406
430
 
407
431
  const handleGsapRemoveAllKeyframes = useCallback(
408
- (animId: string) => {
409
- if (!domEditSelection) return;
410
- observeGsapMutation(
411
- removeAllKeyframes(domEditSelection, animId),
412
- domEditSelection,
432
+ (animId: string, selectionOverride?: DomEditSelection | null) => {
433
+ const selection = resolveWriteSelection(selectionOverride);
434
+ if (!selection) return Promise.resolve(false);
435
+ return observeGsapMutation(
436
+ removeAllKeyframes(selection, animId),
437
+ selection,
413
438
  "remove-all-keyframes",
414
439
  "Remove all keyframes",
415
440
  );
416
441
  },
417
- [domEditSelection, observeGsapMutation, removeAllKeyframes],
442
+ [resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
418
443
  );
419
444
 
420
445
  const handleResetSelectedElementKeyframes = useCallback((): boolean => {
@@ -90,4 +90,20 @@ describe("resolveSelectorElementIds", () => {
90
90
  expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
91
91
  expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
92
92
  });
93
+
94
+ // The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
95
+ // dotted). The old local `#id`-only regex read no id at all for those, so they
96
+ // silently dropped out of both DOM-less paths.
97
+ it("falls back to a bracketed id when there is no DOM", () => {
98
+ expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]);
99
+ });
100
+
101
+ it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
102
+ const doc = {
103
+ querySelectorAll: () => {
104
+ throw new SyntaxError("bad selector");
105
+ },
106
+ } as unknown as Document;
107
+ expect(resolveSelectorElementIds('[id="01-hook"]:has(>*)', doc)).toEqual(["01-hook"]);
108
+ });
93
109
  });