@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
@@ -0,0 +1,693 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { TimelineElement } from "../../player";
7
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
8
+ import { usePlayerStore } from "../../player/store/playerStore";
9
+ import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
+
11
+ installReactActEnvironment();
12
+
13
+ const mocks = vi.hoisted(() => ({
14
+ actions: {
15
+ handleGsapRemoveKeyframe: vi.fn(),
16
+ handleGsapMoveKeyframeToPlayhead: vi.fn(),
17
+ handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
18
+ handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
19
+ handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
20
+ handleGsapAddKeyframe: vi.fn(),
21
+ handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
22
+ handleGsapConvertToKeyframes: vi.fn(),
23
+ handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
24
+ handleGsapDeleteAnimation: vi.fn(),
25
+ buildDomSelectionForTimelineElement: vi.fn(),
26
+ },
27
+ selection: { id: "box", selector: "#box", sourceFile: "index.html" },
28
+ animations: Array<GsapAnimation>(),
29
+ }));
30
+
31
+ vi.mock("../../contexts/StudioContext", () => ({
32
+ useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
33
+ }));
34
+
35
+ vi.mock("../../contexts/DomEditContext", () => ({
36
+ useDomEditActionsContext: () => mocks.actions,
37
+ useDomEditSelectionContext: () => ({
38
+ domEditSelection: mocks.selection,
39
+ selectedGsapAnimations: mocks.animations,
40
+ }),
41
+ }));
42
+
43
+ import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
44
+
45
+ const element: TimelineElement = {
46
+ id: "box",
47
+ key: "index.html#box",
48
+ domId: "box",
49
+ tag: "div",
50
+ start: 0,
51
+ duration: 1,
52
+ track: 0,
53
+ sourceFile: "index.html",
54
+ };
55
+
56
+ const flatAnimation: GsapAnimation = {
57
+ id: "box-to-0-position",
58
+ targetSelector: "#box",
59
+ method: "to",
60
+ position: 0,
61
+ resolvedStart: 0,
62
+ duration: 1,
63
+ properties: { x: 420 },
64
+ propertyGroup: "position",
65
+ };
66
+
67
+ const otherFlatAnimation: GsapAnimation = {
68
+ ...flatAnimation,
69
+ id: "circle-to-0-position",
70
+ targetSelector: "#circle",
71
+ };
72
+
73
+ const otherKeyframedAnimation: GsapAnimation = {
74
+ ...otherFlatAnimation,
75
+ keyframes: {
76
+ format: "percentage",
77
+ keyframes: [
78
+ { percentage: 0, properties: { x: 0 } },
79
+ { percentage: 100, properties: { x: 420 } },
80
+ ],
81
+ },
82
+ };
83
+
84
+ function authoredInteriorAnimation(): GsapAnimation {
85
+ return {
86
+ ...flatAnimation,
87
+ keyframes: {
88
+ format: "percentage",
89
+ keyframes: [
90
+ { percentage: 0, properties: { x: 0 } },
91
+ { percentage: 50, properties: { x: 210 } },
92
+ { percentage: 100, properties: { x: 420 } },
93
+ ],
94
+ },
95
+ };
96
+ }
97
+
98
+ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
99
+ let callbacks: TimelineEditCallbacks | null = null;
100
+ function Harness() {
101
+ callbacks = useTimelineEditCallbacks({
102
+ handleTimelineElementMove: vi.fn(),
103
+ handleTimelineElementsMove: vi.fn(),
104
+ handleTimelineElementResize: vi.fn(),
105
+ handleTimelineGroupResize: vi.fn(),
106
+ handleToggleTrackHidden: vi.fn(),
107
+ handleBlockedTimelineEdit: vi.fn(),
108
+ handleTimelineElementSplit: vi.fn(),
109
+ handleRazorSplit: vi.fn(),
110
+ handleRazorSplitAll: vi.fn(),
111
+ });
112
+ return null;
113
+ }
114
+ const root = mountReactHarness(<Harness />);
115
+ if (!callbacks) throw new Error("timeline callbacks did not initialize");
116
+ return { callbacks, unmount: () => act(() => root.unmount()) };
117
+ }
118
+
119
+ // One selection PER element, so a callback that resolves the selection for the
120
+ // wrong element gets a visibly different object. A single mockResolvedValue
121
+ // hands every element the same selection, which passes just as happily when the
122
+ // write is committed through whatever happens to be selected.
123
+ function selectionForElement(el: TimelineElement): {
124
+ id: string;
125
+ selector: string;
126
+ sourceFile: string;
127
+ } {
128
+ if (el.id === "box") return mocks.selection;
129
+ return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
130
+ }
131
+
132
+ function arrangeClickedCircle(): {
133
+ circle: TimelineElement;
134
+ selection: { id: string; selector: string; sourceFile: string };
135
+ } {
136
+ const elementKey = "scenes/main.html#circle";
137
+ const circle: TimelineElement = {
138
+ ...element,
139
+ id: "circle",
140
+ key: elementKey,
141
+ domId: "circle",
142
+ sourceFile: "scenes/main.html",
143
+ };
144
+ usePlayerStore.setState({
145
+ elements: [element, circle],
146
+ gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
147
+ });
148
+ return { circle, selection: selectionForElement(circle) };
149
+ }
150
+
151
+ beforeEach(() => {
152
+ vi.clearAllMocks();
153
+ mocks.animations = [flatAnimation];
154
+ mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
155
+ Promise.resolve(selectionForElement(el)),
156
+ );
157
+ usePlayerStore.setState({
158
+ currentTime: 0.5,
159
+ elements: [element],
160
+ domClipChildren: [],
161
+ keyframeCache: new Map(),
162
+ gsapAnimations: new Map([["box", [flatAnimation]]]),
163
+ });
164
+ });
165
+
166
+ afterEach(() => {
167
+ usePlayerStore.setState({
168
+ elements: [],
169
+ domClipChildren: [],
170
+ keyframeCache: new Map(),
171
+ gsapAnimations: new Map(),
172
+ });
173
+ });
174
+
175
+ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
176
+ it("adds an interior point through the add-keyframe persist boundary", async () => {
177
+ const view = renderCallbacks();
178
+
179
+ await act(async () => {
180
+ await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
181
+ animationId: flatAnimation.id,
182
+ propertyGroup: "position",
183
+ tweenPercentage: 50,
184
+ properties: { x: 210 },
185
+ remove: false,
186
+ });
187
+ });
188
+
189
+ expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
190
+ flatAnimation.id,
191
+ 50,
192
+ { x: 210 },
193
+ undefined,
194
+ mocks.selection,
195
+ );
196
+ expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
197
+ view.unmount();
198
+ });
199
+
200
+ it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
201
+ const view = renderCallbacks();
202
+
203
+ await expect(
204
+ view.callbacks.onMoveKeyframe?.(
205
+ "box",
206
+ {
207
+ percentage: 0,
208
+ propertyGroup: "position",
209
+ tweenPercentage: 0,
210
+ animationId: flatAnimation.id,
211
+ },
212
+ 25,
213
+ ),
214
+ ).resolves.toBe(true);
215
+
216
+ // The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
217
+ expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
218
+ flatAnimation.id,
219
+ { position: 0.25, duration: 0.75 },
220
+ mocks.selection,
221
+ );
222
+ expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
223
+ // resize-keyframed-tween would convert the flat tween to keyframes form.
224
+ expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
225
+ view.unmount();
226
+ });
227
+
228
+ it("reports an unsettled flat-boundary retime as uncommitted", async () => {
229
+ mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
230
+ const view = renderCallbacks();
231
+
232
+ // The diamond snaps back on `false`. Answering `true` the moment update-meta
233
+ // was dispatched left a rejected boundary drag rendered at its drop position.
234
+ await expect(
235
+ view.callbacks.onMoveKeyframe?.(
236
+ "box",
237
+ {
238
+ percentage: 0,
239
+ propertyGroup: "position",
240
+ tweenPercentage: 0,
241
+ animationId: flatAnimation.id,
242
+ },
243
+ 25,
244
+ ),
245
+ ).resolves.toBe(false);
246
+ view.unmount();
247
+ });
248
+
249
+ it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
250
+ const circle: TimelineElement = {
251
+ ...element,
252
+ id: "circle",
253
+ key: "scenes/main.html#circle",
254
+ domId: "circle",
255
+ };
256
+ usePlayerStore.setState({
257
+ elements: [element, circle],
258
+ gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
259
+ });
260
+ const view = renderCallbacks();
261
+
262
+ await act(async () => {
263
+ view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
264
+ percentage: 0,
265
+ propertyGroup: "position",
266
+ tweenPercentage: 0,
267
+ animationId: otherFlatAnimation.id,
268
+ });
269
+ await Promise.resolve();
270
+ await Promise.resolve();
271
+ });
272
+
273
+ // Persisted through the CLICKED element's own selection, not the current one,
274
+ // and as a remove-keyframe the writer can refuse — never a whole-tween delete.
275
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
276
+ otherFlatAnimation.id,
277
+ 0,
278
+ undefined,
279
+ selectionForElement(circle),
280
+ );
281
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
282
+ view.unmount();
283
+ });
284
+
285
+ it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
286
+ const circle: TimelineElement = {
287
+ ...element,
288
+ id: "circle",
289
+ key: "scenes/main.html#circle",
290
+ domId: "circle",
291
+ };
292
+ usePlayerStore.setState({
293
+ elements: [element, circle],
294
+ gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
295
+ });
296
+ const view = renderCallbacks();
297
+
298
+ await act(async () => {
299
+ view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
300
+ percentage: 100,
301
+ propertyGroup: "position",
302
+ tweenPercentage: 100,
303
+ animationId: otherKeyframedAnimation.id,
304
+ });
305
+ await Promise.resolve();
306
+ await Promise.resolve();
307
+ });
308
+
309
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
310
+ otherKeyframedAnimation.id,
311
+ 100,
312
+ undefined,
313
+ selectionForElement(circle),
314
+ );
315
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
316
+ view.unmount();
317
+ });
318
+
319
+ it("deletes all keyframes through the clicked non-selected element's identity", async () => {
320
+ const { circle, selection } = arrangeClickedCircle();
321
+ const view = renderCallbacks();
322
+
323
+ await act(async () => {
324
+ view.callbacks.onDeleteAllKeyframes?.(circle);
325
+ await Promise.resolve();
326
+ });
327
+
328
+ expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
329
+ otherKeyframedAnimation.id,
330
+ selection,
331
+ );
332
+ view.unmount();
333
+ });
334
+
335
+ it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
336
+ const opacityAnimation: GsapAnimation = {
337
+ ...otherKeyframedAnimation,
338
+ id: "circle-to-0-visual",
339
+ propertyGroup: "visual",
340
+ };
341
+ const { circle } = arrangeClickedCircle();
342
+ usePlayerStore.setState({
343
+ gsapAnimations: new Map([
344
+ ["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
345
+ ]),
346
+ });
347
+ const view = renderCallbacks();
348
+
349
+ await act(async () => {
350
+ view.callbacks.onDeleteAllKeyframes?.(circle);
351
+ await Promise.resolve();
352
+ await Promise.resolve();
353
+ await Promise.resolve();
354
+ });
355
+
356
+ expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
357
+ otherKeyframedAnimation.id,
358
+ opacityAnimation.id,
359
+ ]);
360
+ view.unmount();
361
+ });
362
+
363
+ it("aborts every mutation when the clicked element resolves no selection", async () => {
364
+ const { circle } = arrangeClickedCircle();
365
+ mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
366
+ const view = renderCallbacks();
367
+
368
+ await act(async () => {
369
+ view.callbacks.onDeleteAllKeyframes?.(circle);
370
+ view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
371
+ percentage: 100,
372
+ propertyGroup: "position",
373
+ tweenPercentage: 100,
374
+ animationId: otherKeyframedAnimation.id,
375
+ });
376
+ view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
377
+ percentage: 100,
378
+ propertyGroup: "position",
379
+ tweenPercentage: 100,
380
+ animationId: otherKeyframedAnimation.id,
381
+ });
382
+ await Promise.resolve();
383
+ await Promise.resolve();
384
+ });
385
+
386
+ // No selection for the clicked element means there is nothing safe to write
387
+ // to: falling back to the current selection would edit a different file.
388
+ expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
389
+ expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
390
+ expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
391
+ view.unmount();
392
+ });
393
+
394
+ it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
395
+ const { circle, selection } = arrangeClickedCircle();
396
+ const view = renderCallbacks();
397
+
398
+ await act(async () => {
399
+ view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
400
+ percentage: 100,
401
+ propertyGroup: "position",
402
+ tweenPercentage: 100,
403
+ animationId: otherKeyframedAnimation.id,
404
+ });
405
+ await Promise.resolve();
406
+ });
407
+
408
+ // The retime target, the selection it commits through, and the animation the
409
+ // playhead percentage is computed against all come from the CLICKED element.
410
+ expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
411
+ otherKeyframedAnimation.id,
412
+ 100,
413
+ selection,
414
+ otherKeyframedAnimation,
415
+ );
416
+ view.unmount();
417
+ });
418
+
419
+ it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
420
+ const view = renderCallbacks();
421
+
422
+ act(() => {
423
+ view.callbacks.onDeleteKeyframe?.("box", {
424
+ percentage: 0,
425
+ propertyGroup: "position",
426
+ tweenPercentage: 0,
427
+ animationId: flatAnimation.id,
428
+ });
429
+ });
430
+
431
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
432
+ flatAnimation.id,
433
+ 0,
434
+ undefined,
435
+ undefined,
436
+ );
437
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
438
+ view.unmount();
439
+ });
440
+
441
+ it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
442
+ const view = renderCallbacks();
443
+
444
+ await act(async () => {
445
+ await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
446
+ animationId: flatAnimation.id,
447
+ propertyGroup: "position",
448
+ tweenPercentage: 100,
449
+ properties: { x: 420 },
450
+ remove: true,
451
+ });
452
+ });
453
+
454
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
455
+ flatAnimation.id,
456
+ 100,
457
+ undefined,
458
+ mocks.selection,
459
+ );
460
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
461
+ view.unmount();
462
+ });
463
+
464
+ // The lane-header toggle fires on whichever element owns the lane, which need
465
+ // not be the selected one. It must still commit through that element's own
466
+ // selection, and it must never escalate a flat tween to a whole-tween delete.
467
+ it("removes a non-selected element's flat tween through that element's own selection", async () => {
468
+ const circle: TimelineElement = {
469
+ ...element,
470
+ id: "circle",
471
+ key: "scenes/main.html#circle",
472
+ domId: "circle",
473
+ };
474
+ usePlayerStore.setState({
475
+ elements: [element, circle],
476
+ gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
477
+ });
478
+ const view = renderCallbacks();
479
+
480
+ await act(async () => {
481
+ await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, {
482
+ animationId: otherFlatAnimation.id,
483
+ propertyGroup: "position",
484
+ tweenPercentage: 0,
485
+ properties: { x: 0 },
486
+ remove: true,
487
+ });
488
+ });
489
+
490
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
491
+ otherFlatAnimation.id,
492
+ 0,
493
+ undefined,
494
+ selectionForElement(circle),
495
+ );
496
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
497
+ view.unmount();
498
+ });
499
+
500
+ it("keeps authored interior deletion on the per-keyframe path", () => {
501
+ mocks.animations = [authoredInteriorAnimation()];
502
+ usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
503
+ const view = renderCallbacks();
504
+
505
+ act(() => {
506
+ view.callbacks.onDeleteKeyframe?.("box", {
507
+ percentage: 50,
508
+ propertyGroup: "position",
509
+ tweenPercentage: 50,
510
+ animationId: flatAnimation.id,
511
+ });
512
+ });
513
+
514
+ expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
515
+ flatAnimation.id,
516
+ 50,
517
+ undefined,
518
+ undefined,
519
+ );
520
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
521
+ view.unmount();
522
+ });
523
+
524
+ it("keeps an authored interior drag on the per-keyframe move path", async () => {
525
+ const authored = authoredInteriorAnimation();
526
+ mocks.animations = [authored];
527
+ usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
528
+ const view = renderCallbacks();
529
+
530
+ await expect(
531
+ view.callbacks.onMoveKeyframe?.(
532
+ "box",
533
+ {
534
+ percentage: 50,
535
+ propertyGroup: "position",
536
+ tweenPercentage: 50,
537
+ animationId: authored.id,
538
+ },
539
+ 75,
540
+ ),
541
+ ).resolves.toBe(true);
542
+
543
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
544
+ authored.id,
545
+ 50,
546
+ 75,
547
+ mocks.selection,
548
+ );
549
+ expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
550
+ view.unmount();
551
+ });
552
+
553
+ // A drag starts on whatever diamond the pointer is over, which need not be the
554
+ // selected element. Resolving against the selection would retime the selected
555
+ // element's tween and commit it through the selected element's file.
556
+ it("retimes a non-selected element's keyframe through that element's own selection", async () => {
557
+ const circle: TimelineElement = {
558
+ ...element,
559
+ id: "circle",
560
+ key: "scenes/main.html#circle",
561
+ domId: "circle",
562
+ sourceFile: "scenes/main.html",
563
+ };
564
+ const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
565
+ const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
566
+ usePlayerStore.setState({
567
+ elements: [element, circle],
568
+ gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
569
+ });
570
+ mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
571
+ const view = renderCallbacks();
572
+
573
+ await act(async () => {
574
+ await view.callbacks.onMoveKeyframe?.(
575
+ "scenes/main.html#circle",
576
+ {
577
+ percentage: 50,
578
+ propertyGroup: "position",
579
+ tweenPercentage: 50,
580
+ animationId: circleAnimation.id,
581
+ },
582
+ 75,
583
+ );
584
+ });
585
+
586
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
587
+ circleAnimation.id,
588
+ 50,
589
+ 75,
590
+ circleSelection,
591
+ );
592
+ view.unmount();
593
+ });
594
+
595
+ // The diamond's rapid-second-retime path reports the PENDING clip-% (where the
596
+ // first drag put the keyframe), which the keyframe cache has not caught up to.
597
+ // TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
598
+ // the real callback resolves that stale-cache position off the identity fields
599
+ // instead of failing the lookup.
600
+ it("retimes from a pending position the keyframe cache has not caught up to", async () => {
601
+ const authored = authoredInteriorAnimation();
602
+ mocks.animations = [authored];
603
+ usePlayerStore.setState({
604
+ elements: [element],
605
+ gsapAnimations: new Map([["index.html#box", [authored]]]),
606
+ // Still the pre-drag positions: 75% is not in here.
607
+ keyframeCache: new Map([
608
+ [
609
+ "index.html#box",
610
+ {
611
+ format: "percentage" as const,
612
+ keyframes: [
613
+ { percentage: 0, properties: { x: 0 } },
614
+ { percentage: 50, properties: { x: 210 } },
615
+ { percentage: 100, properties: { x: 420 } },
616
+ ],
617
+ },
618
+ ],
619
+ ]),
620
+ });
621
+ const view = renderCallbacks();
622
+
623
+ await expect(
624
+ view.callbacks.onMoveKeyframe?.(
625
+ "index.html#box",
626
+ {
627
+ percentage: 75,
628
+ propertyGroup: "position",
629
+ tweenPercentage: 50,
630
+ animationId: authored.id,
631
+ },
632
+ 85,
633
+ ),
634
+ ).resolves.toBe(true);
635
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
636
+ authored.id,
637
+ 50,
638
+ 85,
639
+ mocks.selection,
640
+ );
641
+
642
+ // Control: the same drag WITHOUT the identity fields falls back to the cache
643
+ // lookup, finds nothing at 75%, and cannot retime.
644
+ mocks.actions.handleGsapMoveKeyframe.mockClear();
645
+ await expect(
646
+ view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
647
+ ).resolves.toBe(false);
648
+ expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
649
+ view.unmount();
650
+ });
651
+
652
+ it("uses the clip timing basis when retiming a duration-less tween", async () => {
653
+ const durationless = {
654
+ ...authoredInteriorAnimation(),
655
+ position: 3.2,
656
+ resolvedStart: 3.2,
657
+ duration: undefined,
658
+ };
659
+ const wideElement = { ...element, start: 10.94, duration: 16.26 };
660
+ mocks.animations = [durationless];
661
+ usePlayerStore.setState({
662
+ elements: [wideElement],
663
+ gsapAnimations: new Map([["box", [durationless]]]),
664
+ });
665
+ const view = renderCallbacks();
666
+
667
+ await expect(
668
+ view.callbacks.onMoveKeyframe?.(
669
+ "box",
670
+ {
671
+ percentage: 19.1,
672
+ propertyGroup: "position",
673
+ tweenPercentage: 50,
674
+ animationId: durationless.id,
675
+ },
676
+ 40,
677
+ ),
678
+ ).resolves.toBe(true);
679
+
680
+ // The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
681
+ // 17.444s, and the duration-less tween borrows the clip's 16.26s window from
682
+ // its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
683
+ // zero-length tween, or the clip's own 0-100 %) produces a different number.
684
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
685
+ durationless.id,
686
+ 50,
687
+ expect.closeTo(87.601, 3),
688
+ mocks.selection,
689
+ );
690
+ expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
691
+ view.unmount();
692
+ });
693
+ });