@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,450 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
8
+ import {
9
+ getTimelinePropertyLanes,
10
+ TimelinePropertyLanes,
11
+ type TimelinePropertyLanesProps,
12
+ } from "./TimelinePropertyLanes";
13
+ import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
14
+
15
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ function animation(
22
+ id: string,
23
+ propertyGroup: PropertyGroupName,
24
+ keyframes: Array<{
25
+ percentage: number;
26
+ properties: Record<string, number | string>;
27
+ ease?: string;
28
+ }>,
29
+ ): GsapAnimation {
30
+ return {
31
+ id,
32
+ targetSelector: "#clip-1",
33
+ method: "to",
34
+ position: 0,
35
+ duration: 1,
36
+ properties: {},
37
+ propertyGroup,
38
+ keyframes: { format: "percentage", keyframes },
39
+ };
40
+ }
41
+
42
+ function flatAnimation(
43
+ id: string,
44
+ propertyGroup: PropertyGroupName,
45
+ properties: Record<string, number | string>,
46
+ ): GsapAnimation {
47
+ return {
48
+ id,
49
+ targetSelector: "#clip-1",
50
+ method: "to",
51
+ position: 0,
52
+ duration: 1,
53
+ properties,
54
+ propertyGroup,
55
+ };
56
+ }
57
+
58
+ function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}): {
59
+ host: HTMLDivElement;
60
+ root: Root;
61
+ } {
62
+ const host = document.createElement("div");
63
+ document.body.append(host);
64
+ const root = createRoot(host);
65
+ act(() => {
66
+ root.render(
67
+ <TimelinePropertyLanes
68
+ animations={[]}
69
+ clipStart={0}
70
+ clipDuration={1}
71
+ clipLeftPx={0}
72
+ clipWidthPx={200}
73
+ accentColor="#4ba3d2"
74
+ isSelected
75
+ currentPercentage={-10}
76
+ elementId="clip-1"
77
+ selectedKeyframes={new Set()}
78
+ {...overrides}
79
+ />,
80
+ );
81
+ });
82
+ return { host, root };
83
+ }
84
+
85
+ function laneDiamonds(host: HTMLElement, group: string): HTMLButtonElement[] {
86
+ return Array.from(
87
+ host.querySelectorAll<HTMLButtonElement>(
88
+ `[data-property-group="${group}"] button[data-keyframe-percentage]`,
89
+ ),
90
+ );
91
+ }
92
+
93
+ function expectLanePercentages(host: HTMLElement, group: string, percentages: string[]) {
94
+ expect(laneDiamonds(host, group).map((diamond) => diamond.dataset.keyframePercentage)).toEqual(
95
+ percentages,
96
+ );
97
+ }
98
+
99
+ function laneEaseButtons(host: HTMLElement, group: string): HTMLButtonElement[] {
100
+ return Array.from(
101
+ host.querySelectorAll<HTMLButtonElement>(
102
+ `[data-property-group="${group}"] button[data-keyframe-ease-button]`,
103
+ ),
104
+ );
105
+ }
106
+
107
+ function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] {
108
+ return Array.from(
109
+ host.querySelectorAll<HTMLElement>(
110
+ `[data-property-group="${group}"] [data-keyframe-ease-segment]`,
111
+ ),
112
+ );
113
+ }
114
+
115
+ function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null {
116
+ return segment.querySelector<HTMLButtonElement>("button[data-keyframe-ease-button]");
117
+ }
118
+
119
+ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
120
+ { percentage: 0, properties: { x: 0 } },
121
+ { percentage: 50, properties: { x: 50 } },
122
+ ]);
123
+
124
+ describe("TimelinePropertyLanes", () => {
125
+ it("returns a position lane with synthesized endpoints for a flat tween", () => {
126
+ const lanes = getTimelinePropertyLanes(
127
+ [flatAnimation("position-tween", "position", { x: 420 })],
128
+ 0,
129
+ 1,
130
+ );
131
+
132
+ expect(lanes).toHaveLength(1);
133
+ expect(lanes[0]?.group).toBe("position");
134
+ expect(lanes[0]?.keyframes).toEqual([
135
+ {
136
+ percentage: 0,
137
+ tweenPercentage: 0,
138
+ properties: { x: 0 },
139
+ propertyGroup: "position",
140
+ animationId: "position-tween",
141
+ },
142
+ {
143
+ percentage: 100,
144
+ tweenPercentage: 100,
145
+ properties: { x: 420 },
146
+ propertyGroup: "position",
147
+ animationId: "position-tween",
148
+ },
149
+ ]);
150
+ });
151
+
152
+ it("returns both flat and authored keyframe property groups", () => {
153
+ const lanes = getTimelinePropertyLanes(
154
+ [
155
+ flatAnimation("position-tween", "position", { x: 420 }),
156
+ animation("visual-tween", "visual", [
157
+ { percentage: 0, properties: { opacity: 0 } },
158
+ { percentage: 100, properties: { opacity: 1 } },
159
+ ]),
160
+ ],
161
+ 0,
162
+ 1,
163
+ );
164
+
165
+ expect(lanes.map((lane) => lane.group)).toEqual(["position", "visual"]);
166
+ expect(lanes.map((lane) => lane.keyframes.map((keyframe) => keyframe.percentage))).toEqual([
167
+ [0, 100],
168
+ [0, 100],
169
+ ]);
170
+ });
171
+
172
+ it("renders each source property group at its independent keyframe positions", () => {
173
+ const animations = [
174
+ animation("position-tween", "position", [
175
+ { percentage: 0, properties: { x: 0, y: 0 } },
176
+ { percentage: 50, properties: { x: 100, y: 20 } },
177
+ { percentage: 100, properties: { x: 200, y: 40 } },
178
+ ]),
179
+ animation("visual-tween", "visual", [{ percentage: 25, properties: { opacity: 0.5 } }]),
180
+ ];
181
+
182
+ const { host, root } = renderPropertyLanes({ animations });
183
+ const position = laneDiamonds(host, "position");
184
+ const visual = laneDiamonds(host, "visual");
185
+
186
+ expect(position).toHaveLength(3);
187
+ expect(visual).toHaveLength(1);
188
+ // Diamonds are centered on their true keyframe time (0% at -half); the
189
+ // reserved left gutter (content origin inset, tested at the Timeline level)
190
+ // keeps the overflowing left half visible rather than clamping it inward.
191
+ expect(position.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px", "189px"]);
192
+ expect(visual[0]?.style.left).toBe("39px");
193
+ expect(
194
+ host.querySelectorAll('[data-property-group="position"] [data-keyframe-connector]'),
195
+ ).toHaveLength(2);
196
+ act(() => root.unmount());
197
+ });
198
+
199
+ it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
200
+ const animations = [
201
+ animation("position-tween", "position", [
202
+ { percentage: 0, properties: { x: 0 } },
203
+ { percentage: 100, properties: { x: 100 } },
204
+ ]),
205
+ animation("visual-tween", "visual", [
206
+ { percentage: 0, properties: { opacity: 0 } },
207
+ { percentage: 100, properties: { opacity: 1 } },
208
+ ]),
209
+ ];
210
+
211
+ const { host, root } = renderPropertyLanes({ animations });
212
+
213
+ expectLanePercentages(host, "position", ["0", "100"]);
214
+ expectLanePercentages(host, "visual", ["0", "100"]);
215
+ act(() => root.unmount());
216
+ });
217
+
218
+ it("renders an authored hold keyframe whose value equals its predecessor", () => {
219
+ const animations = [
220
+ animation("position-tween", "position", [
221
+ { percentage: 0, properties: { x: 10 } },
222
+ { percentage: 50, properties: { x: 10 } },
223
+ { percentage: 100, properties: { x: 20 } },
224
+ ]),
225
+ ];
226
+
227
+ const { host, root } = renderPropertyLanes({ animations });
228
+
229
+ expectLanePercentages(host, "position", ["0", "50", "100"]);
230
+ act(() => root.unmount());
231
+ });
232
+
233
+ it("keeps Position@50% selection distinct from Opacity@50%", () => {
234
+ const onClickKeyframe = vi.fn();
235
+ const animations = [
236
+ animation("position-tween", "position", [{ percentage: 50, properties: { x: 50 } }]),
237
+ animation("visual-tween", "visual", [{ percentage: 50, properties: { opacity: 0.5 } }]),
238
+ ];
239
+ const { host, root } = renderPropertyLanes({ animations, onClickKeyframe });
240
+ const position = laneDiamonds(host, "position")[0]!;
241
+
242
+ act(() => {
243
+ position.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
244
+ });
245
+
246
+ const target = onClickKeyframe.mock.calls[0]?.[0];
247
+ expect(target).toEqual({
248
+ animationId: "position-tween",
249
+ percentage: 50,
250
+ propertyGroup: "position",
251
+ tweenPercentage: 50,
252
+ });
253
+
254
+ act(() => {
255
+ root.render(
256
+ <TimelinePropertyLanes
257
+ animations={animations}
258
+ clipStart={0}
259
+ clipDuration={1}
260
+ clipLeftPx={0}
261
+ clipWidthPx={200}
262
+ accentColor="#4ba3d2"
263
+ isSelected
264
+ currentPercentage={-10}
265
+ elementId="clip-1"
266
+ selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", target)])}
267
+ />,
268
+ );
269
+ });
270
+
271
+ const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
272
+ const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
273
+ expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
274
+ expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3");
275
+ act(() => root.unmount());
276
+ });
277
+
278
+ it("keeps one accessible midpoint ease button per segment, regardless of selection", () => {
279
+ const animations = [
280
+ animation("position-tween", "position", [
281
+ { percentage: 0, properties: { x: 0 } },
282
+ { percentage: 50, properties: { x: 50 } },
283
+ { percentage: 100, properties: { x: 100 } },
284
+ ]),
285
+ ];
286
+ const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
287
+
288
+ const segments = laneEaseSegments(host, "position");
289
+ expect(segments).toHaveLength(2);
290
+ expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]);
291
+ expect(laneDiamonds(host, "position")).toHaveLength(3);
292
+ const buttons = laneEaseButtons(host, "position");
293
+ expect(buttons).toHaveLength(2);
294
+ expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true);
295
+ expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
296
+ true,
297
+ );
298
+ expect(buttons.every((button) => button.classList.contains("focus-visible:opacity-100"))).toBe(
299
+ true,
300
+ );
301
+
302
+ // The ease button is available on hover even when the element is NOT selected
303
+ // (a lane shows for the track's active/primary clip, not only the selected one).
304
+ act(() => {
305
+ root.render(
306
+ <TimelinePropertyLanes
307
+ animations={animations}
308
+ clipStart={0}
309
+ clipDuration={1}
310
+ clipLeftPx={0}
311
+ clipWidthPx={200}
312
+ accentColor="#4ba3d2"
313
+ isSelected={false}
314
+ currentPercentage={-10}
315
+ elementId="clip-1"
316
+ selectedKeyframes={new Set()}
317
+ onSelectSegment={vi.fn()}
318
+ />,
319
+ );
320
+ });
321
+ const unselectedSegments = laneEaseSegments(host, "position");
322
+ expect(unselectedSegments).toHaveLength(2);
323
+ expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
324
+ act(() => root.unmount());
325
+ });
326
+
327
+ it("reveals each segment's button with its destination keyframe ease curve", () => {
328
+ const animations = [
329
+ animation("position-tween", "position", [
330
+ { percentage: 0, properties: { x: 0 } },
331
+ { percentage: 33, properties: { x: 33 }, ease: "none" },
332
+ { percentage: 66, properties: { x: 66 }, ease: "power2.out" },
333
+ {
334
+ percentage: 100,
335
+ properties: { x: 100 },
336
+ ease: "custom(M0,0 C0.1,0.2 0.3,0.9 1,1)",
337
+ },
338
+ ]),
339
+ ];
340
+ const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
341
+
342
+ const segments = laneEaseSegments(host, "position");
343
+ expect(segments).toHaveLength(3);
344
+ const paths = segments.map((segment) =>
345
+ revealEaseButton(segment)?.querySelector("path")?.getAttribute("d"),
346
+ );
347
+ expect(paths).toHaveLength(3);
348
+ expect(new Set(paths).size).toBe(3);
349
+ // Uniqueness alone passes even when the curves are swapped between segments.
350
+ // Each segment is labelled with the ease it draws, so pin the ORDER: a
351
+ // segment carries the ease of the keyframe it arrives at.
352
+ expect(
353
+ segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
354
+ ).toEqual([
355
+ "Edit none easing",
356
+ "Edit power2.out easing",
357
+ "Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
358
+ ]);
359
+ act(() => root.unmount());
360
+ });
361
+
362
+ it("selects the destination keyframe when a hovered segment's ease button is clicked", () => {
363
+ const onSelectSegment = vi.fn();
364
+ const { host, root } = renderPropertyLanes({
365
+ animations: [POSITION_SEGMENT_ANIMATION],
366
+ onSelectSegment,
367
+ });
368
+
369
+ const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
370
+ act(() => button?.click());
371
+
372
+ expect(onSelectSegment).toHaveBeenCalledWith({
373
+ animationId: "position-tween",
374
+ percentage: 50,
375
+ propertyGroup: "position",
376
+ tweenPercentage: 50,
377
+ });
378
+ act(() => root.unmount());
379
+ });
380
+
381
+ it("routes a colliding Position segment to the Position animation", () => {
382
+ const onSelectSegment = vi.fn();
383
+ const animations = [
384
+ POSITION_SEGMENT_ANIMATION,
385
+ animation("visual-tween", "visual", [
386
+ { percentage: 0, properties: { opacity: 0 } },
387
+ { percentage: 50, properties: { opacity: 0.5 } },
388
+ ]),
389
+ ];
390
+ const { host, root } = renderPropertyLanes({ animations, onSelectSegment });
391
+
392
+ const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
393
+ act(() => button?.click());
394
+
395
+ expect(onSelectSegment.mock.calls[0]?.[0]).toMatchObject({
396
+ animationId: "position-tween",
397
+ propertyGroup: "position",
398
+ });
399
+ act(() => root.unmount());
400
+ });
401
+
402
+ it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
403
+ const onClickKeyframe = vi.fn();
404
+ const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
405
+ const COLLAPSED_TARGET = { ...COLLAPSED_IDENTITY, percentage: 50, tweenPercentage: 50 };
406
+ const host = document.createElement("div");
407
+ document.body.append(host);
408
+ const root = createRoot(host);
409
+ act(() => {
410
+ root.render(
411
+ <TimelineClipDiamonds
412
+ keyframesData={{
413
+ format: "percentage",
414
+ keyframes: [
415
+ { percentage: 0, ...COLLAPSED_IDENTITY, tweenPercentage: 0, properties: { x: 0 } },
416
+ {
417
+ percentage: 50,
418
+ ...COLLAPSED_IDENTITY,
419
+ tweenPercentage: 50,
420
+ properties: { x: 100 },
421
+ },
422
+ ],
423
+ }}
424
+ clipWidthPx={200}
425
+ clipHeightPx={48}
426
+ accentColor="#4ba3d2"
427
+ isSelected
428
+ currentPercentage={-10}
429
+ elementId="clip-1"
430
+ selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", COLLAPSED_TARGET)])}
431
+ onClickKeyframe={onClickKeyframe}
432
+ />,
433
+ );
434
+ });
435
+ const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button"));
436
+
437
+ // Unified keyframe-diamond size (LANE_H·ratio ≈ 22px, half 11) on collapsed
438
+ // clips too, so 0% sits at -11px regardless of clip-bar height.
439
+ expect(diamonds.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px"]);
440
+ expect(diamonds[1]?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
441
+ act(() => {
442
+ diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
443
+ });
444
+ // The whole identity, not just the percentage: objectContaining on the one
445
+ // field passes even when the animation id / property group / tween-% the
446
+ // diamond-identity refactor added are dropped on the way out.
447
+ expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", COLLAPSED_TARGET);
448
+ act(() => root.unmount());
449
+ });
450
+ });
@@ -0,0 +1,191 @@
1
+ import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
2
+ import {
3
+ classifyPropertyGroup,
4
+ type GsapAnimation,
5
+ type PropertyGroupName,
6
+ } from "@hyperframes/core/gsap-parser";
7
+ import { toClipKeyframes } from "../../hooks/gsapShared";
8
+ import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
9
+ import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
10
+ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
11
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
12
+
13
+ export interface TimelinePropertyLanesProps {
14
+ animations: readonly GsapAnimation[];
15
+ clipStart: number;
16
+ clipDuration: number;
17
+ clipLeftPx: number;
18
+ clipWidthPx: number;
19
+ accentColor: string;
20
+ isSelected: boolean;
21
+ currentPercentage: number;
22
+ elementId: string;
23
+ selectedKeyframes: ReadonlySet<string>;
24
+ onSelectSegment?: (target: TimelineKeyframeTarget) => void;
25
+ onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
26
+ onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
27
+ onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
28
+ onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
29
+ suppressClickRef?: RefObject<boolean>;
30
+ }
31
+
32
+ function hasGroupProperty(
33
+ properties: Record<string, number | string>,
34
+ group: PropertyGroupName,
35
+ ): boolean {
36
+ return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group);
37
+ }
38
+
39
+ /** The tween's editable keyframes: its real keyframes, or the start→end pair
40
+ * synthesized for a flat tween. Empty for a tween that animates nothing. */
41
+ function animationKeyframes(animation: GsapAnimation) {
42
+ return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
43
+ }
44
+
45
+ /** A tween contributes a property lane when it has a group and at least one
46
+ * editable keyframe (real or synthesized). */
47
+ export function animationContributesLane(animation: GsapAnimation): boolean {
48
+ return !!animation.propertyGroup && animationKeyframes(animation).length > 0;
49
+ }
50
+
51
+ function sourceGroups(animations: readonly GsapAnimation[]) {
52
+ const groups = new Map<PropertyGroupName, GsapAnimation[]>();
53
+ for (const animation of animations) {
54
+ if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
55
+ const groupAnimations = groups.get(animation.propertyGroup) ?? [];
56
+ groupAnimations.push(animation);
57
+ groups.set(animation.propertyGroup, groupAnimations);
58
+ }
59
+ return groups;
60
+ }
61
+
62
+ /** Resolve the ease from THIS keyframe's own source tween. A lane can merge
63
+ * several tweens, so a shared lane-level fallback would label a segment with a
64
+ * different animation's ease than the one the ease editor targets (it routes
65
+ * by animationId). */
66
+ function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
67
+ return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
68
+ }
69
+
70
+ /**
71
+ * One lane row per keyframe of `group`. The clip-% re-basing goes through the
72
+ * shared toClipKeyframes so lane rows land on the exact same percentage the
73
+ * keyframe cache writes: this file used to derive it inline and skipped that
74
+ * helper's rounding, which is the one precision every keyframe-cache writer has
75
+ * to agree on (selection keys embed the number).
76
+ */
77
+ function groupKeyframes(
78
+ animations: readonly GsapAnimation[],
79
+ group: PropertyGroupName,
80
+ clipStart: number,
81
+ clipDuration: number,
82
+ ): TimelineDiamondKeyframe[] {
83
+ const keyframes: TimelineDiamondKeyframe[] = [];
84
+ for (const animation of animations) {
85
+ const inGroup = animationKeyframes(animation).filter((keyframe) =>
86
+ hasGroupProperty(keyframe.properties, group),
87
+ );
88
+ for (const keyframe of toClipKeyframes(inGroup, animation, clipStart, clipDuration)) {
89
+ keyframes.push({
90
+ ...keyframe,
91
+ // The LANE's group, not the tween's own classification: a mixed-property
92
+ // tween classifies to undefined yet still feeds every group it touches.
93
+ propertyGroup: group,
94
+ ease: keyframeEase(keyframe, animation),
95
+ });
96
+ }
97
+ }
98
+ return keyframes;
99
+ }
100
+
101
+ export function getTimelinePropertyLanes(
102
+ animations: readonly GsapAnimation[],
103
+ clipStart: number,
104
+ clipDuration: number,
105
+ ) {
106
+ if (clipDuration <= 0) return [];
107
+ return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
108
+ group,
109
+ animations: groupAnimations,
110
+ keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
111
+ })).filter((lane) => lane.keyframes.length > 0);
112
+ }
113
+
114
+ export function TimelinePropertyLanes({
115
+ animations,
116
+ clipStart,
117
+ clipDuration,
118
+ clipLeftPx,
119
+ clipWidthPx,
120
+ accentColor,
121
+ isSelected,
122
+ currentPercentage,
123
+ elementId,
124
+ selectedKeyframes,
125
+ onSelectSegment,
126
+ onClickKeyframe,
127
+ onShiftClickKeyframe,
128
+ onContextMenuKeyframe,
129
+ onMoveKeyframe,
130
+ suppressClickRef,
131
+ }: TimelinePropertyLanesProps) {
132
+ // Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
133
+ // a fresh keyframesData literal per lane) on every render would re-render every
134
+ // diamond in every expanded clip on each playhead tick.
135
+ const lanes = useMemo(
136
+ () =>
137
+ clipWidthPx < 20 || clipDuration <= 0
138
+ ? []
139
+ : getTimelinePropertyLanes(animations, clipStart, clipDuration),
140
+ [animations, clipStart, clipDuration, clipWidthPx],
141
+ );
142
+ const laneData = useMemo(
143
+ () =>
144
+ lanes.map((lane) => ({
145
+ ...lane,
146
+ keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
147
+ })),
148
+ [lanes],
149
+ );
150
+
151
+ if (laneData.length === 0) return null;
152
+ return (
153
+ <>
154
+ {laneData.map(({ group, keyframesData }, laneIndex) => (
155
+ <div
156
+ key={group}
157
+ role="group"
158
+ aria-label={`${group} keyframes`}
159
+ data-property-group={group}
160
+ data-timeline-property-lane=""
161
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
162
+ className="absolute"
163
+ style={{
164
+ left: clipLeftPx,
165
+ top: getTimelineLaneTop(laneIndex),
166
+ width: clipWidthPx,
167
+ height: LANE_H,
168
+ }}
169
+ >
170
+ <TimelineDiamondLane
171
+ keyframesData={keyframesData}
172
+ clipWidthPx={clipWidthPx}
173
+ clipHeightPx={LANE_H}
174
+ accentColor={accentColor}
175
+ isSelected={isSelected}
176
+ currentPercentage={currentPercentage}
177
+ elementId={elementId}
178
+ selectedKeyframes={selectedKeyframes}
179
+ onSelectSegment={onSelectSegment}
180
+ onClickKeyframe={onClickKeyframe}
181
+ onShiftClickKeyframe={onShiftClickKeyframe}
182
+ onContextMenuKeyframe={onContextMenuKeyframe}
183
+ onMoveKeyframe={onMoveKeyframe}
184
+ suppressClickRef={suppressClickRef}
185
+ groupAware
186
+ />
187
+ </div>
188
+ ))}
189
+ </>
190
+ );
191
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
2
+ import { getTimelineContentXFromClient } from "./timelineLayout";
3
+
4
+ interface TimelineRazorInteractionOptions {
5
+ active: boolean;
6
+ scrollRef: RefObject<HTMLDivElement | null>;
7
+ contentOrigin: number;
8
+ pixelsPerSecond: number;
9
+ onSplitAll?: (time: number) => void;
10
+ }
11
+
12
+ export function useTimelineRazorInteraction({
13
+ active,
14
+ scrollRef,
15
+ contentOrigin,
16
+ pixelsPerSecond,
17
+ onSplitAll,
18
+ }: TimelineRazorInteractionOptions) {
19
+ const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
20
+
21
+ const updateRazorGuide = useCallback(
22
+ (event: MouseEvent<HTMLDivElement>) => {
23
+ const scroll = scrollRef.current;
24
+ if (!active || !scroll) return;
25
+ const rect = scroll.getBoundingClientRect();
26
+ setRazorGuideX(event.clientX - rect.left + scroll.scrollLeft);
27
+ },
28
+ [active, scrollRef],
29
+ );
30
+
31
+ const clearRazorGuide = useCallback(() => setRazorGuideX(null), []);
32
+
33
+ const splitAllAtPointer = useCallback(
34
+ (event: PointerEvent<HTMLDivElement>) => {
35
+ const scroll = scrollRef.current;
36
+ if (!active || !event.shiftKey || event.button !== 0 || !scroll) return false;
37
+ const rect = scroll.getBoundingClientRect();
38
+ const x = getTimelineContentXFromClient({
39
+ clientX: event.clientX,
40
+ rectLeft: rect.left,
41
+ scrollLeft: scroll.scrollLeft,
42
+ contentOrigin,
43
+ });
44
+ onSplitAll?.(Math.max(0, x / pixelsPerSecond));
45
+ return true;
46
+ },
47
+ [active, contentOrigin, onSplitAll, pixelsPerSecond, scrollRef],
48
+ );
49
+
50
+ return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
51
+ }
52
+
53
+ export function TimelineRazorGuide({ x }: { x: number }) {
54
+ return (
55
+ <div
56
+ className="absolute top-0 bottom-0 pointer-events-none z-10"
57
+ style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
58
+ />
59
+ );
60
+ }