@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
@@ -3,7 +3,8 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
+ import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
7
+ import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
10
 
@@ -45,6 +46,45 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
45
46
  }
46
47
 
47
48
  describe("TimelineClipDiamonds", () => {
49
+ // Dense rows narrow the DIAMOND so neighbours stay individually readable, but
50
+ // the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
51
+ // (~7px) at the zoom floor.
52
+ it("narrows dense keyframe visuals while flooring their hit regions", () => {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ const root = createRoot(host);
56
+ act(() => {
57
+ root.render(
58
+ <TimelineDiamondLane
59
+ keyframesData={{
60
+ format: "percentage",
61
+ keyframes: [30, 60, 90].map((percentage) => ({
62
+ percentage,
63
+ propertyGroup: "position",
64
+ properties: { x: percentage },
65
+ })),
66
+ }}
67
+ clipWidthPx={36}
68
+ clipHeightPx={48}
69
+ accentColor="#4ba3d2"
70
+ isSelected
71
+ currentPercentage={0}
72
+ elementId="clip-1"
73
+ selectedKeyframes={new Set()}
74
+ groupAware
75
+ />,
76
+ );
77
+ });
78
+
79
+ const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button[title]"));
80
+ expect(diamonds).toHaveLength(3);
81
+ for (const diamond of diamonds) {
82
+ expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
83
+ expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
84
+ }
85
+ act(() => root.unmount());
86
+ });
87
+
48
88
  it("treats primary pointerup without drag as a keyframe click", () => {
49
89
  const { host, root, onClickKeyframe } = renderDiamonds();
50
90
  const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
@@ -54,7 +94,57 @@ describe("TimelineClipDiamonds", () => {
54
94
  diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
55
95
  });
56
96
 
57
- expect(onClickKeyframe).toHaveBeenCalledWith(50);
97
+ expect(onClickKeyframe).toHaveBeenCalledWith(
98
+ "clip-1",
99
+ expect.objectContaining({ percentage: 50 }),
100
+ );
101
+ act(() => root.unmount());
102
+ });
103
+
104
+ // The collapsed clip row and the expanded property lanes read the same cache,
105
+ // so a keyframe that carries a property group has to hash to the same key in
106
+ // both — otherwise collapsing a track silently drops the selection.
107
+ it("keys a grouped keyframe the same way collapsed as expanded", () => {
108
+ const groupedKeyframe = {
109
+ percentage: 50,
110
+ tweenPercentage: 25,
111
+ propertyGroup: "position",
112
+ animationId: "anim-1",
113
+ properties: { x: 100 },
114
+ };
115
+ const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe);
116
+ const host = document.createElement("div");
117
+ document.body.append(host);
118
+ const root = createRoot(host);
119
+ const onClickKeyframe = vi.fn();
120
+ act(() => {
121
+ root.render(
122
+ <TimelineClipDiamonds
123
+ keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
124
+ clipWidthPx={200}
125
+ clipHeightPx={48}
126
+ accentColor="#4ba3d2"
127
+ isSelected
128
+ currentPercentage={-10}
129
+ elementId="clip-1"
130
+ selectedKeyframes={new Set([sharedKey])}
131
+ onClickKeyframe={onClickKeyframe}
132
+ />,
133
+ );
134
+ });
135
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
136
+ // Highlighted from the shared key alone (the playhead is off-clip here).
137
+ expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
138
+
139
+ act(() => {
140
+ diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
141
+ });
142
+ expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", {
143
+ percentage: 50,
144
+ tweenPercentage: 25,
145
+ propertyGroup: "position",
146
+ animationId: "anim-1",
147
+ });
58
148
  act(() => root.unmount());
59
149
  });
60
150
 
@@ -84,12 +174,24 @@ describe("TimelineClipDiamonds", () => {
84
174
  const root = createRoot(host);
85
175
  act(() => {
86
176
  root.render(
87
- <TimelineClipDiamonds
177
+ <TimelineDiamondLane
88
178
  keyframesData={{
89
179
  format: "percentage",
90
180
  keyframes: [
91
- { percentage: 0, properties: { x: 0 } },
92
- { percentage: 50, properties: { x: 100 } },
181
+ {
182
+ percentage: 0,
183
+ tweenPercentage: 0,
184
+ propertyGroup: "position",
185
+ animationId: "anim-1",
186
+ properties: { x: 0 },
187
+ },
188
+ {
189
+ percentage: 50,
190
+ tweenPercentage: 100,
191
+ propertyGroup: "position",
192
+ animationId: "anim-1",
193
+ properties: { x: 100 },
194
+ },
93
195
  ],
94
196
  }}
95
197
  clipWidthPx={5000}
@@ -101,6 +203,7 @@ describe("TimelineClipDiamonds", () => {
101
203
  selectedKeyframes={new Set()}
102
204
  onClickKeyframe={onClickKeyframe}
103
205
  onMoveKeyframe={onMoveKeyframe}
206
+ groupAware
104
207
  />,
105
208
  );
106
209
  });
@@ -117,7 +220,12 @@ describe("TimelineClipDiamonds", () => {
117
220
  diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
118
221
  });
119
222
 
120
- expect(onClickKeyframe).toHaveBeenCalledWith(50);
223
+ expect(onClickKeyframe).toHaveBeenCalledWith({
224
+ percentage: 50,
225
+ tweenPercentage: 100,
226
+ propertyGroup: "position",
227
+ animationId: "anim-1",
228
+ });
121
229
  expect(onMoveKeyframe).not.toHaveBeenCalled();
122
230
  act(() => root.unmount());
123
231
  });
@@ -126,20 +234,39 @@ describe("TimelineClipDiamonds", () => {
126
234
  // keyframe) committed the move but never selected/parked on the result —
127
235
  // the diamond it was just dragged looked exactly like one nothing happened
128
236
  // to. Select it at its NEW position too.
129
- it("selects the keyframe at its new position after a real drag-retime", () => {
237
+ it("reselects a retimed keyframe with its post-move tween percentage", () => {
130
238
  const onClickKeyframe = vi.fn();
131
- const onMoveKeyframe = vi.fn();
239
+ const onMoveKeyframe = vi.fn().mockResolvedValue(true);
132
240
  const host = document.createElement("div");
133
241
  document.body.append(host);
134
242
  const root = createRoot(host);
135
243
  act(() => {
136
244
  root.render(
137
- <TimelineClipDiamonds
245
+ <TimelineDiamondLane
138
246
  keyframesData={{
139
247
  format: "percentage",
140
248
  keyframes: [
141
- { percentage: 0, properties: { x: 0 } },
142
- { percentage: 50, properties: { x: 100 } },
249
+ {
250
+ percentage: 20,
251
+ tweenPercentage: 0,
252
+ propertyGroup: "position",
253
+ animationId: "anim-1",
254
+ properties: { x: 0 },
255
+ },
256
+ {
257
+ percentage: 40,
258
+ tweenPercentage: 50,
259
+ propertyGroup: "position",
260
+ animationId: "anim-1",
261
+ properties: { x: 100 },
262
+ },
263
+ {
264
+ percentage: 60,
265
+ tweenPercentage: 100,
266
+ propertyGroup: "position",
267
+ animationId: "anim-1",
268
+ properties: { x: 200 },
269
+ },
143
270
  ],
144
271
  }}
145
272
  clipWidthPx={200}
@@ -151,6 +278,164 @@ describe("TimelineClipDiamonds", () => {
151
278
  selectedKeyframes={new Set()}
152
279
  onClickKeyframe={onClickKeyframe}
153
280
  onMoveKeyframe={onMoveKeyframe}
281
+ groupAware
282
+ />,
283
+ );
284
+ });
285
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
286
+ expect(diamond).not.toBeNull();
287
+
288
+ act(() => {
289
+ diamond!.dispatchEvent(
290
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
291
+ );
292
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
293
+ });
294
+
295
+ expect(onMoveKeyframe).toHaveBeenCalledWith(
296
+ {
297
+ percentage: 40,
298
+ tweenPercentage: 50,
299
+ propertyGroup: "position",
300
+ animationId: "anim-1",
301
+ },
302
+ 50,
303
+ );
304
+ expect(onClickKeyframe).toHaveBeenCalledWith({
305
+ percentage: 50,
306
+ tweenPercentage: 75,
307
+ propertyGroup: "position",
308
+ animationId: "anim-1",
309
+ });
310
+ act(() => root.unmount());
311
+ });
312
+
313
+ it("leaves the selection alone when a stale retime fails after a newer drag", async () => {
314
+ const onClickKeyframe = vi.fn();
315
+ let failFirstDrag: (() => void) | undefined;
316
+ const onMoveKeyframe = vi
317
+ .fn()
318
+ .mockImplementationOnce(
319
+ () =>
320
+ new Promise<boolean>((resolve) => {
321
+ failFirstDrag = () => resolve(false);
322
+ }),
323
+ )
324
+ .mockResolvedValue(true);
325
+ const host = document.createElement("div");
326
+ document.body.append(host);
327
+ const root = createRoot(host);
328
+ act(() => {
329
+ root.render(
330
+ <TimelineDiamondLane
331
+ keyframesData={{
332
+ format: "percentage",
333
+ keyframes: [
334
+ {
335
+ percentage: 0,
336
+ tweenPercentage: 0,
337
+ propertyGroup: "position",
338
+ animationId: "anim-1",
339
+ properties: { x: 0 },
340
+ },
341
+ {
342
+ percentage: 50,
343
+ tweenPercentage: 50,
344
+ propertyGroup: "position",
345
+ animationId: "anim-1",
346
+ properties: { x: 100 },
347
+ },
348
+ {
349
+ percentage: 100,
350
+ tweenPercentage: 100,
351
+ propertyGroup: "position",
352
+ animationId: "anim-1",
353
+ properties: { x: 200 },
354
+ },
355
+ ],
356
+ }}
357
+ clipWidthPx={200}
358
+ clipHeightPx={48}
359
+ accentColor="#4ba3d2"
360
+ isSelected
361
+ currentPercentage={0}
362
+ elementId="clip-1"
363
+ selectedKeyframes={new Set()}
364
+ onClickKeyframe={onClickKeyframe}
365
+ onMoveKeyframe={onMoveKeyframe}
366
+ groupAware
367
+ />,
368
+ );
369
+ });
370
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
371
+
372
+ // Two drags back to back; the first one's commit is still in flight.
373
+ act(() => {
374
+ diamond!.dispatchEvent(
375
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
376
+ );
377
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
378
+ diamond!.dispatchEvent(
379
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
380
+ );
381
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
382
+ });
383
+ onClickKeyframe.mockClear();
384
+
385
+ await act(async () => {
386
+ failFirstDrag?.();
387
+ await Promise.resolve();
388
+ });
389
+
390
+ // The stale failure must not drag the selection back to the first drag's
391
+ // source: the second retime, which the user can see, owns it now.
392
+ expect(onClickKeyframe).not.toHaveBeenCalled();
393
+ act(() => root.unmount());
394
+ });
395
+
396
+ it("composes a rapid second retime from the pending position", () => {
397
+ const onMoveKeyframe = vi.fn().mockResolvedValue(true);
398
+ const host = document.createElement("div");
399
+ document.body.append(host);
400
+ const root = createRoot(host);
401
+ act(() => {
402
+ root.render(
403
+ <TimelineDiamondLane
404
+ keyframesData={{
405
+ format: "percentage",
406
+ keyframes: [
407
+ {
408
+ percentage: 0,
409
+ tweenPercentage: 0,
410
+ propertyGroup: "position",
411
+ animationId: "anim-1",
412
+ properties: { x: 0 },
413
+ },
414
+ {
415
+ percentage: 50,
416
+ tweenPercentage: 50,
417
+ propertyGroup: "position",
418
+ animationId: "anim-1",
419
+ properties: { x: 100 },
420
+ },
421
+ {
422
+ percentage: 100,
423
+ tweenPercentage: 100,
424
+ propertyGroup: "position",
425
+ animationId: "anim-1",
426
+ properties: { x: 200 },
427
+ },
428
+ ],
429
+ }}
430
+ clipWidthPx={200}
431
+ clipHeightPx={48}
432
+ accentColor="#4ba3d2"
433
+ isSelected
434
+ currentPercentage={0}
435
+ elementId="clip-1"
436
+ selectedKeyframes={new Set()}
437
+ onMoveKeyframe={onMoveKeyframe}
438
+ groupAware
154
439
  />,
155
440
  );
156
441
  });
@@ -161,13 +446,156 @@ describe("TimelineClipDiamonds", () => {
161
446
  diamond!.dispatchEvent(
162
447
  pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
163
448
  );
164
- // 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
165
- // a real retime.
166
- diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
449
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
450
+
451
+ // The cache still exposes 50%, but this second +10% drag starts at the
452
+ // pending 75% destination and must therefore land at 85%, not 60%.
453
+ diamond!.dispatchEvent(
454
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
455
+ );
456
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
457
+ });
458
+
459
+ // The second move must identify the FROM keyframe by the pending (already-
460
+ // moved) position 75%, not the stale rendered 50%; otherwise the serialized
461
+ // mutation can't locate the keyframe the first move relocated.
462
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
463
+ 2,
464
+ {
465
+ percentage: 75,
466
+ tweenPercentage: 75,
467
+ propertyGroup: "position",
468
+ animationId: "anim-1",
469
+ },
470
+ 85,
471
+ );
472
+ act(() => root.unmount());
473
+ });
474
+
475
+ it.each([
476
+ ["returns false", () => Promise.resolve(false)],
477
+ ["rejects", () => Promise.reject(new Error("retime failed"))],
478
+ ])("clears a failed pending retime when the callback %s", async (_label, settle) => {
479
+ const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
480
+ const host = document.createElement("div");
481
+ document.body.append(host);
482
+ const root = createRoot(host);
483
+ act(() => {
484
+ root.render(
485
+ <TimelineDiamondLane
486
+ keyframesData={{
487
+ format: "percentage",
488
+ keyframes: [
489
+ {
490
+ percentage: 0,
491
+ tweenPercentage: 0,
492
+ propertyGroup: "position",
493
+ animationId: "anim-1",
494
+ properties: { x: 0 },
495
+ },
496
+ {
497
+ percentage: 50,
498
+ tweenPercentage: 50,
499
+ propertyGroup: "position",
500
+ animationId: "anim-1",
501
+ properties: { x: 100 },
502
+ },
503
+ {
504
+ percentage: 100,
505
+ tweenPercentage: 100,
506
+ propertyGroup: "position",
507
+ animationId: "anim-1",
508
+ properties: { x: 200 },
509
+ },
510
+ ],
511
+ }}
512
+ clipWidthPx={200}
513
+ clipHeightPx={48}
514
+ accentColor="#4ba3d2"
515
+ isSelected
516
+ currentPercentage={0}
517
+ elementId="clip-1"
518
+ selectedKeyframes={new Set()}
519
+ onMoveKeyframe={onMoveKeyframe}
520
+ groupAware
521
+ />,
522
+ );
523
+ });
524
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
525
+ expect(diamond).not.toBeNull();
526
+
527
+ await act(async () => {
528
+ diamond!.dispatchEvent(
529
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
530
+ );
531
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
532
+ await Promise.resolve();
167
533
  });
168
534
 
169
- expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
170
- expect(onClickKeyframe).toHaveBeenCalledWith(52);
535
+ act(() => {
536
+ diamond!.dispatchEvent(
537
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
538
+ );
539
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
540
+ });
541
+
542
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
543
+ 2,
544
+ {
545
+ percentage: 50,
546
+ tweenPercentage: 50,
547
+ propertyGroup: "position",
548
+ animationId: "anim-1",
549
+ },
550
+ 60,
551
+ );
552
+ act(() => root.unmount());
553
+ });
554
+
555
+ it("cancels an in-flight retime on Escape without committing or selecting", () => {
556
+ const onClickKeyframe = vi.fn();
557
+ const onMoveKeyframe = vi.fn().mockResolvedValue(true);
558
+ const host = document.createElement("div");
559
+ document.body.append(host);
560
+ const root = createRoot(host);
561
+ act(() => {
562
+ root.render(
563
+ <TimelineDiamondLane
564
+ keyframesData={{
565
+ format: "percentage",
566
+ keyframes: [
567
+ { percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
568
+ { percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
569
+ { percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
570
+ ],
571
+ }}
572
+ clipWidthPx={200}
573
+ clipHeightPx={48}
574
+ accentColor="#4ba3d2"
575
+ isSelected
576
+ currentPercentage={0}
577
+ elementId="clip-1"
578
+ selectedKeyframes={new Set()}
579
+ onClickKeyframe={onClickKeyframe}
580
+ onMoveKeyframe={onMoveKeyframe}
581
+ />,
582
+ );
583
+ });
584
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
585
+ expect(diamond).not.toBeNull();
586
+
587
+ act(() => {
588
+ diamond!.dispatchEvent(
589
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
590
+ );
591
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
592
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
593
+ });
594
+
595
+ // Escape ends the gesture: no retime is written, and the release is not
596
+ // reinterpreted as a click that would park the playhead on the keyframe.
597
+ expect(onMoveKeyframe).not.toHaveBeenCalled();
598
+ expect(onClickKeyframe).not.toHaveBeenCalled();
171
599
  act(() => root.unmount());
172
600
  });
173
601
 
@@ -212,4 +640,100 @@ describe("TimelineClipDiamonds", () => {
212
640
  expect(suppressClickRef.current).toBe(true);
213
641
  act(() => root.unmount());
214
642
  });
643
+
644
+ const renderSegmentLane = (lastAmbiguous: boolean) => {
645
+ const host = document.createElement("div");
646
+ document.body.append(host);
647
+ const root = createRoot(host);
648
+ const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
649
+ percentage,
650
+ tweenPercentage: percentage,
651
+ propertyGroup: "position",
652
+ animationId: "anim-1",
653
+ properties: { x: percentage },
654
+ ...extra,
655
+ });
656
+ act(() => {
657
+ root.render(
658
+ <TimelineDiamondLane
659
+ keyframesData={{
660
+ format: "percentage",
661
+ keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
662
+ }}
663
+ clipWidthPx={200}
664
+ clipHeightPx={48}
665
+ accentColor="#4ba3d2"
666
+ isSelected
667
+ currentPercentage={0}
668
+ elementId="clip-1"
669
+ selectedKeyframes={new Set()}
670
+ onSelectSegment={vi.fn()}
671
+ groupAware
672
+ />,
673
+ );
674
+ });
675
+ return { host, root };
676
+ };
677
+
678
+ it("hides the inline ease button on an ambiguous merged segment", () => {
679
+ // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
680
+ // keyframe, so its hover/ease-button area is not rendered.
681
+ const { host, root } = renderSegmentLane(true);
682
+ expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
683
+ act(() => root.unmount());
684
+ });
685
+
686
+ it("keeps the inline ease button on unambiguous merged segments", () => {
687
+ const { host, root } = renderSegmentLane(false);
688
+ expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
689
+ act(() => root.unmount());
690
+ });
691
+
692
+ it("keeps the ease button above nearby diamonds without blocking the segment", () => {
693
+ const { host, root } = renderSegmentLane(false);
694
+ const segment = host.querySelector<HTMLElement>("[data-keyframe-ease-segment]");
695
+ const ease = segment?.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
696
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
697
+
698
+ expect(segment?.style.pointerEvents).toBe("none");
699
+ expect(ease?.style.pointerEvents).toBe("auto");
700
+ expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
701
+ act(() => root.unmount());
702
+ });
703
+
704
+ it("hides the inline ease button on a segment with no source animation id", () => {
705
+ // A runtime-scanned keyframe has no animationId, so there is no tween to
706
+ // target; the segment ending on it must not render a (dead) ease button.
707
+ const host = document.createElement("div");
708
+ document.body.append(host);
709
+ const root = createRoot(host);
710
+ const kf = (percentage: number, animationId?: string) => ({
711
+ percentage,
712
+ tweenPercentage: percentage,
713
+ propertyGroup: "position",
714
+ ...(animationId ? { animationId } : {}),
715
+ properties: { x: percentage },
716
+ });
717
+ act(() => {
718
+ root.render(
719
+ <TimelineDiamondLane
720
+ keyframesData={{
721
+ format: "percentage",
722
+ keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
723
+ }}
724
+ clipWidthPx={200}
725
+ clipHeightPx={48}
726
+ accentColor="#4ba3d2"
727
+ isSelected
728
+ currentPercentage={0}
729
+ elementId="clip-1"
730
+ selectedKeyframes={new Set()}
731
+ onSelectSegment={vi.fn()}
732
+ groupAware
733
+ />,
734
+ );
735
+ });
736
+ expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
737
+ act(() => root.unmount());
738
+ });
215
739
  });