@hyperframes/studio 0.7.78 → 0.7.80

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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -60,12 +60,14 @@ const OPACITY = animation("opacity-tween", "visual", [
60
60
  ]);
61
61
 
62
62
  interface RenderHeaderOptions {
63
+ keyframeClip?: TimelineElement;
63
64
  animations?: GsapAnimation[];
64
65
  clipCount?: number;
65
66
  currentTime?: number;
66
67
  expanded?: boolean;
67
68
  onSeek?: (time: number) => void;
68
69
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
70
+ onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
69
71
  }
70
72
 
71
73
  function renderHeader(options: RenderHeaderOptions = {}): {
@@ -80,10 +82,14 @@ function renderHeader(options: RenderHeaderOptions = {}): {
80
82
  act(() => {
81
83
  root.render(
82
84
  <TimelineTrackHeader
83
- trackNumber={0}
85
+ // A real fractional z-order sort key, so a label built from it would
86
+ // read out "track 0.16666666666666666".
87
+ trackNumber={1 / 6}
88
+ trackDisplayNumber={1}
84
89
  trackLabel="Hero card"
90
+ lanesId="timeline-lanes-track-0"
85
91
  contentOrigin={LABEL_COL_W}
86
- keyframeClip={ELEMENT}
92
+ keyframeClip={next.keyframeClip ?? ELEMENT}
87
93
  clipCount={next.clipCount ?? 1}
88
94
  isExpanded={next.expanded !== false}
89
95
  animations={next.animations ?? [POSITION, OPACITY]}
@@ -92,7 +98,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
92
98
  isAudioTrack={false}
93
99
  theme={defaultTimelineTheme}
94
100
  onToggleClipExpanded={vi.fn()}
95
- onToggleTrackHidden={vi.fn()}
101
+ onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
96
102
  onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
97
103
  onSeek={next.onSeek}
98
104
  />,
@@ -110,6 +116,53 @@ function click(host: HTMLElement, label: string) {
110
116
  }
111
117
 
112
118
  describe("TimelineTrackHeader", () => {
119
+ // An expanded sub-composition child sits on the MASTER timeline at a
120
+ // host-absolute start, but its tweens are parsed from its own file and are
121
+ // local to it. Feeding the raw start straight into the clip-% math put every
122
+ // lane keyframe far outside the clip.
123
+ it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
124
+ const child: TimelineElement = {
125
+ id: "pill",
126
+ tag: "div",
127
+ start: 16.5,
128
+ duration: 2,
129
+ track: 0,
130
+ expandedParentStart: 16,
131
+ sourceFile: "scene.html",
132
+ };
133
+ const local: GsapAnimation = {
134
+ id: "pill-tween",
135
+ targetSelector: "#pill",
136
+ method: "to",
137
+ position: 0.5,
138
+ resolvedStart: 0.5,
139
+ duration: 2,
140
+ properties: {},
141
+ propertyGroup: "position",
142
+ keyframes: {
143
+ format: "percentage",
144
+ keyframes: [
145
+ { percentage: 0, properties: { x: 0 } },
146
+ { percentage: 100, properties: { x: 100 } },
147
+ ],
148
+ },
149
+ };
150
+ // Playhead at the clip's midpoint (master time), so the 100% keyframe is
151
+ // ahead of it. On the raw host-absolute basis every keyframe rebased to a
152
+ // large negative percentage and nothing was ever ahead of the playhead.
153
+ const view = renderHeader({
154
+ keyframeClip: child,
155
+ animations: [local],
156
+ currentTime: 17.5,
157
+ });
158
+
159
+ expect(
160
+ view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
161
+ ?.disabled,
162
+ ).toBe(false);
163
+ act(() => view.root.unmount());
164
+ });
165
+
113
166
  // The header shows one clip's lanes, so how many clips the track holds is
114
167
  // otherwise invisible from the label column. A single-clip track stays silent.
115
168
  it("shows the track's clip count only once the track holds more than one clip", () => {
@@ -125,10 +178,27 @@ describe("TimelineTrackHeader", () => {
125
178
  // in every disclosure state — a hover-gated eye is unusable by keyboard.
126
179
  it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
127
180
  const view = renderHeader({ expanded: true });
128
- expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
181
+ expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
129
182
 
130
183
  view.rerender({ expanded: false });
131
- expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
184
+ expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
185
+ act(() => view.root.unmount());
186
+ });
187
+
188
+ // trackNumber is a fractional z-order sort key, so building the label from it
189
+ // made screen readers announce "Hide track 0.16666666666666666". The display
190
+ // number is label-only; the toggle still routes by the real key.
191
+ it("announces the display track number but toggles with the real fractional key", () => {
192
+ const onToggleTrackHidden = vi.fn();
193
+ const view = renderHeader({ onToggleTrackHidden });
194
+ const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');
195
+
196
+ expect(eye).not.toBeNull();
197
+ expect(eye?.title).toBe("Hide track 1");
198
+ expect(view.host.innerHTML).not.toContain("0.16666666666666666");
199
+
200
+ act(() => eye?.click());
201
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
132
202
  act(() => view.root.unmount());
133
203
  });
134
204
 
@@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons";
4
4
  import type { TimelineElement } from "../store/playerStore";
5
5
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
6
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
7
+ import { clipTimingStart } from "../../hooks/gsapShared";
7
8
  import { LayerDisclosureRow } from "./LayerDisclosureRow";
8
9
  import { TrackClipCount } from "./TrackClipCount";
9
10
  import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
@@ -14,10 +15,21 @@ import {
14
15
  type TimelinePropertyLane,
15
16
  } from "./trackHeaderLaneState";
16
17
  import { valueReadout } from "./trackHeaderLaneValues";
18
+ import { trackDisplaySuffix } from "./timelineTrackDisplay";
17
19
 
18
20
  interface TimelineTrackHeaderProps {
21
+ /** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
22
+ * never shown or announced. */
19
23
  trackNumber: number;
24
+ /** The track's 1-based position in the rendered order: the only number safe
25
+ * to put in a label. Announcing `trackNumber` read out "track
26
+ * 0.16666666666666666". Null when the key has no row, which drops the number
27
+ * from the label rather than inventing one (see trackDisplayNumber). */
28
+ trackDisplayNumber: number | null;
20
29
  trackLabel: string;
30
+ /** Id of the canvas-side lanes element the disclosure caret expands. Minted by
31
+ * TimelineLanes, which is the one place that sees both subtrees. */
32
+ lanesId: string;
21
33
  contentOrigin: number;
22
34
  /** The track's active keyframe clip (selected, else primary) — the one whose
23
35
  * disclosure + property rows this header shows, whether expanded or not. */
@@ -39,16 +51,21 @@ interface TimelineTrackHeaderProps {
39
51
  function VisibilityButton({
40
52
  hidden,
41
53
  trackNumber,
54
+ trackDisplayNumber,
42
55
  visible,
43
56
  onToggle,
44
57
  }: {
45
58
  hidden: boolean;
46
59
  trackNumber: number;
60
+ trackDisplayNumber: number | null;
47
61
  visible: boolean;
48
62
  onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
49
63
  }) {
50
64
  if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
51
- const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
65
+ // Display number in the text, real key in the callback. The two must not be
66
+ // conflated in either direction.
67
+ const suffix = trackDisplaySuffix(trackDisplayNumber);
68
+ const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
52
69
  return (
53
70
  <button
54
71
  type="button"
@@ -76,6 +93,7 @@ function VisibilityButton({
76
93
  // count, eye. Not deprecated — it is the live path for every track without lanes.
77
94
  function PlainTrackHeader({
78
95
  trackNumber,
96
+ trackDisplayNumber,
79
97
  trackLabel,
80
98
  clipCount,
81
99
  showTrackLabel,
@@ -85,6 +103,7 @@ function PlainTrackHeader({
85
103
  }: Pick<
86
104
  TimelineTrackHeaderProps,
87
105
  | "trackNumber"
106
+ | "trackDisplayNumber"
88
107
  | "trackLabel"
89
108
  | "clipCount"
90
109
  | "isTrackHidden"
@@ -105,6 +124,7 @@ function PlainTrackHeader({
105
124
  <VisibilityButton
106
125
  hidden={isTrackHidden}
107
126
  trackNumber={trackNumber}
127
+ trackDisplayNumber={trackDisplayNumber}
108
128
  visible
109
129
  onToggle={onToggleTrackHidden}
110
130
  />
@@ -262,7 +282,9 @@ function PropertyGroupHeaderRow({
262
282
 
263
283
  export function TimelineTrackHeader({
264
284
  trackNumber,
285
+ trackDisplayNumber,
265
286
  trackLabel,
287
+ lanesId,
266
288
  contentOrigin,
267
289
  keyframeClip,
268
290
  clipCount,
@@ -281,13 +303,14 @@ export function TimelineTrackHeader({
281
303
  ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
282
304
  : 0;
283
305
  const lanes = keyframeClip
284
- ? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
306
+ ? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
307
+ // host-absolute while its tweens are local to its own file.
308
+ getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
285
309
  : [];
286
310
  // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
287
311
  // owns the gutter past it, so a 0% diamond isn't clipped by this panel).
288
312
  const showTrackLabel = contentOrigin >= LABEL_COL_W;
289
313
  const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
290
- const lanesId = `timeline-lanes-track-${trackNumber}`;
291
314
 
292
315
  return (
293
316
  <div
@@ -307,6 +330,7 @@ export function TimelineTrackHeader({
307
330
  {!keyframeClip || lanes.length === 0 ? (
308
331
  <PlainTrackHeader
309
332
  trackNumber={trackNumber}
333
+ trackDisplayNumber={trackDisplayNumber}
310
334
  trackLabel={trackLabel}
311
335
  clipCount={clipCount}
312
336
  showTrackLabel={showTrackLabel}
@@ -333,29 +357,35 @@ export function TimelineTrackHeader({
333
357
  <VisibilityButton
334
358
  hidden={isTrackHidden}
335
359
  trackNumber={trackNumber}
360
+ trackDisplayNumber={trackDisplayNumber}
336
361
  visible
337
362
  onToggle={onToggleTrackHidden}
338
363
  />
339
364
  </LayerDisclosureRow>
340
- {/* Always mounted so the caret's aria-controls resolves in both states. */}
341
- <div id={lanesId}>
342
- {isExpanded &&
343
- lanes.map((lane, laneIndex) => (
344
- <PropertyGroupHeaderRow
345
- key={lane.group}
346
- lane={lane}
347
- laneIndex={laneIndex}
348
- isLastLane={laneIndex === lanes.length - 1}
349
- expandedElement={keyframeClip}
350
- currentTime={currentTime}
351
- clipPercentage={clipPercentage}
352
- gutterBackground={theme.gutterBackground}
353
- columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
354
- onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
355
- onSeek={onSeek}
356
- />
357
- ))}
358
- </div>
365
+ {/* The caret expands TWO disjoint subtrees: these label-column rows,
366
+ which carry the per-lane keyframe controls, and the diamond lanes
367
+ on the canvas. `lanesId` names the canvas lanes (rendered by
368
+ TimelineLanes), because that is what a sighted user watches appear
369
+ and what following the reference has to land on. These rows are not
370
+ empty and are not the target; they are absolutely positioned inside
371
+ the sticky column, which is what made a wrapper HERE compute to
372
+ 0x0 and hold no diamonds. */}
373
+ {isExpanded &&
374
+ lanes.map((lane, laneIndex) => (
375
+ <PropertyGroupHeaderRow
376
+ key={lane.group}
377
+ lane={lane}
378
+ laneIndex={laneIndex}
379
+ isLastLane={laneIndex === lanes.length - 1}
380
+ expandedElement={keyframeClip}
381
+ currentTime={currentTime}
382
+ clipPercentage={clipPercentage}
383
+ gutterBackground={theme.gutterBackground}
384
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
385
+ onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
386
+ onSeek={onSeek}
387
+ />
388
+ ))}
359
389
  </>
360
390
  )}
361
391
  </div>
@@ -4,6 +4,7 @@
4
4
  * keyframe-identity helper live here; the rendering lives there.
5
5
  */
6
6
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
7
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
7
8
 
8
9
  export interface TimelineDiamondKeyframe {
9
10
  percentage: number;
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
13
14
  animationId?: string;
14
15
  properties: Record<string, number | string>;
15
16
  ease?: string;
16
- /** Set when 2+ source animations collide at this percentage (a single inline
17
- * ease button can't target one): the collapsed row hides the button here. */
18
- easeAmbiguous?: boolean;
17
+ /** Source animation/keyframe targets that collide at this clip percentage. */
18
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
19
19
  }
20
20
 
21
21
  interface KeyframeCacheEntry {
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
116
116
  tweenPercentage: keyframe.tweenPercentage,
117
117
  propertyGroup: keyframe.propertyGroup,
118
118
  animationId: keyframe.animationId,
119
+ collidingAnimationTargets: keyframe.collidingAnimationTargets,
119
120
  };
120
121
  }
@@ -1,8 +1,11 @@
1
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
2
+
1
3
  export interface TimelineKeyframeTarget {
2
4
  percentage: number;
3
5
  tweenPercentage?: number;
4
6
  propertyGroup?: string;
5
7
  animationId?: string;
8
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
6
9
  }
7
10
 
8
11
  /**
@@ -0,0 +1,86 @@
1
+ import { type ReactNode } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
4
+ import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
5
+ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
6
+ import type { TimelineTheme } from "./timelineTheme";
7
+ import type { TrackVisualStyle } from "./timelineIcons";
8
+ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
9
+
10
+ /**
11
+ * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
12
+ * the lane renderer ({@link import("./TimelineLanes")}). TimelineCanvas passes
13
+ * these straight through via spread, so they are declared once here and both
14
+ * prop types compose from this base — no duplicated prop list. Kept in its own
15
+ * module so neither side has to import the other just to name the contract.
16
+ */
17
+ export interface TimelineLaneBaseProps {
18
+ pps: number;
19
+ contentOrigin: number;
20
+ contentGutter: number;
21
+ trackContentWidth: number;
22
+ theme: TimelineTheme;
23
+ displayTrackOrder: number[];
24
+ rowHeights: readonly number[];
25
+ trackOrder: number[];
26
+ tracks: [number, TimelineElement[]][];
27
+ trackStyles: Map<number, TrackVisualStyle>;
28
+ laneCounts: ReadonlyMap<string, number>;
29
+ selectedElementId: string | null;
30
+ selectedElementIds: Set<string>;
31
+ hoveredClip: string | null;
32
+ draggedClip: DraggedClipState | null;
33
+ blockedClipRef: React.RefObject<BlockedClipState | null>;
34
+ suppressClickRef: React.RefObject<boolean>;
35
+ scrollRef: React.RefObject<HTMLDivElement | null>;
36
+ renderClipContent?: (
37
+ element: TimelineElement,
38
+ style: { clip: string; label: string },
39
+ ) => ReactNode;
40
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
41
+ onDrillDown?: (element: TimelineElement) => void;
42
+ onSelectElement?: (element: TimelineElement | null) => void;
43
+ setHoveredClip: (key: string | null) => void;
44
+ setShowPopover: (v: boolean) => void;
45
+ setRangeSelection: (v: null) => void;
46
+ setResizingClip: (v: ResizingClipState | null) => void;
47
+ setDraggedClip: (v: DraggedClipState | null) => void;
48
+ setSelectedElementId: (id: string | null) => void;
49
+ syncClipDragAutoScroll: (x: number, y: number) => void;
50
+ shiftClickClipRef: React.RefObject<{
51
+ element: TimelineElement;
52
+ anchorX: number;
53
+ anchorY: number;
54
+ } | null>;
55
+ getPreviewElement: (element: TimelineElement) => TimelineElement;
56
+ getTrackStyle: (tag: string) => TrackVisualStyle;
57
+ keyframeCache?: Map<string, KeyframeCacheEntry>;
58
+ gsapAnimations: Map<string, GsapAnimation[]>;
59
+ selectedKeyframes: Set<string>;
60
+ currentTime: number;
61
+ onSeek?: (time: number) => void;
62
+ onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
63
+ onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
64
+ onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
65
+ onContextMenuKeyframe?: (
66
+ e: React.MouseEvent,
67
+ elementId: string,
68
+ target: TimelineKeyframeTarget,
69
+ ) => void;
70
+ onMoveKeyframe?: (
71
+ elementId: string,
72
+ keyframe: TimelineKeyframeTarget,
73
+ toClipPercentage: number,
74
+ propertyGroup?: string,
75
+ tweenPercentage?: number,
76
+ animationId?: string,
77
+ ) => Promise<boolean>;
78
+ onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
79
+ /**
80
+ * Right-click on EMPTY lane space (not on a clip — those preventDefault
81
+ * before this fires — not the gutter/ruler, not below the lanes). `time` is
82
+ * the timeline time (seconds) under the pointer on that lane.
83
+ */
84
+ onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
85
+ beatAnalysis?: MusicBeatAnalysis | null;
86
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { timelineTrackOrder, trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
3
+
4
+ describe("timelineTrackOrder", () => {
5
+ it("is the ascending distinct key order, fractional sub-comp keys included", () => {
6
+ const elements = [{ track: 1 }, { track: 0 }, { track: 1 / 6 }, { track: 1 }];
7
+
8
+ expect(timelineTrackOrder(elements)).toEqual([0, 1 / 6, 1]);
9
+ });
10
+ });
11
+
12
+ describe("trackDisplayNumber", () => {
13
+ it("is the key's 1-based row, not the fractional key itself", () => {
14
+ const order = timelineTrackOrder([{ track: 0 }, { track: 1 / 6 }, { track: 1 }]);
15
+
16
+ expect(trackDisplayNumber(order, 1 / 6)).toBe(2);
17
+ expect(trackDisplayNumber(order, 1)).toBe(3);
18
+ });
19
+
20
+ it("is null for a key with no row rather than a plausible-looking one", () => {
21
+ // The old end-row fallback announced "track 4" for a track the user cannot
22
+ // see at row 4, and nothing upstream could tell that apart from a real row.
23
+ expect(trackDisplayNumber([0, 1, 2], 7)).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe("trackDisplaySuffix", () => {
28
+ it("names the row when there is one", () => {
29
+ expect(`Hide track${trackDisplaySuffix(3)}`).toBe("Hide track 3");
30
+ });
31
+
32
+ it("drops the number entirely when there is no row", () => {
33
+ expect(`Hide track${trackDisplaySuffix(null)}`).toBe("Hide track");
34
+ });
35
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The one owner of "what track number does the user see".
3
+ *
4
+ * `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
5
+ * sub-composition child gets a synthesized fractional key (`host.track + n /
6
+ * (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
7
+ * announces "Hide track 0.16666666666666666". Every user-visible track number,
8
+ * whether it is rendered by a component or baked into an undo-history label,
9
+ * routes through here; the raw key stays in callbacks and lookups only.
10
+ */
11
+
12
+ /** Ascending distinct track keys, the row order the timeline renders in. */
13
+ export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
14
+ return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
15
+ }
16
+
17
+ /**
18
+ * A track key's 1-based display row, or null when the key is not in
19
+ * `trackOrder` at all.
20
+ *
21
+ * Both callers build `trackOrder` from the same elements the key came from, so
22
+ * null is unreachable by construction today. It is null rather than a number
23
+ * because the only numbers available to return (the end row, the last row) are
24
+ * indistinguishable from a real answer: a label would announce a row the user
25
+ * can see is wrong, and nothing upstream would ever learn it had guessed.
26
+ */
27
+ export function trackDisplayNumber(trackOrder: readonly number[], track: number): number | null {
28
+ const row = trackOrder.indexOf(track);
29
+ return row < 0 ? null : row + 1;
30
+ }
31
+
32
+ /**
33
+ * The `" 3"` in `Hide track 3`, empty when there is no display row to name.
34
+ * Announcing "Hide track" is thin; announcing an invented row is wrong.
35
+ */
36
+ export function trackDisplaySuffix(displayNumber: number | null): string {
37
+ return displayNumber === null ? "" : ` ${displayNumber}`;
38
+ }
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
36
36
  animationId: "position-tween",
37
37
  };
38
38
 
39
+ const COLLIDING_TARGET: TimelineKeyframeTarget = {
40
+ ...FLAT_TWEEN_TARGET,
41
+ collidingAnimationTargets: [
42
+ { animationId: "position-tween", tweenPercentage: 100 },
43
+ { animationId: "scale-tween", tweenPercentage: 75 },
44
+ ],
45
+ };
46
+
39
47
  afterEach(() => {
40
48
  document.body.innerHTML = "";
41
49
  trackStudioSegmentEaseEdit.mockClear();
42
50
  usePlayerStore.setState({ focusedEaseSegment: null });
43
51
  });
44
52
 
45
- describe("useTimelineKeyframeHandlers", () => {
46
- it("tracks opening the segment ease editor when a timeline segment is selected", () => {
47
- let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
48
-
49
- function Harness() {
50
- ({ onSelectSegment } = useTimelineKeyframeHandlers({
53
+ /**
54
+ * Mount the hook on its own and hand back the handlers it returned, with the
55
+ * options every test shares already filled in. Each test overrides only the
56
+ * inputs its assertion is about.
57
+ */
58
+ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHandlers>[0]> = {}) {
59
+ const handlers: Partial<ReturnType<typeof useTimelineKeyframeHandlers>> = {};
60
+
61
+ function Harness() {
62
+ Object.assign(
63
+ handlers,
64
+ useTimelineKeyframeHandlers({
51
65
  expandedElements: [ELEMENT],
52
66
  keyframeCache: new Map(),
53
67
  setSelectedElementId: vi.fn(),
54
68
  setKfContextMenu: vi.fn(),
55
69
  toggleSelectedKeyframe: vi.fn(),
56
- }));
57
- return null;
58
- }
70
+ ...options,
71
+ }),
72
+ );
73
+ return null;
74
+ }
75
+
76
+ return { root: mountReactHarness(<Harness />), handlers };
77
+ }
59
78
 
60
- const root = mountReactHarness(<Harness />);
61
- act(() => onSelectSegment?.(ELEMENT.id, TARGET));
79
+ describe("useTimelineKeyframeHandlers", () => {
80
+ it("tracks opening the segment ease editor when a timeline segment is selected", () => {
81
+ const { root, handlers } = mountHandlers();
82
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
62
83
 
63
84
  expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
64
85
  expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
65
86
  act(() => root.unmount());
66
87
  });
67
88
 
89
+ it("focuses a merged segment with its colliding animation targets", () => {
90
+ const { root, handlers } = mountHandlers();
91
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
92
+
93
+ expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
94
+ animationId: "position-tween",
95
+ collidingAnimationTargets: [
96
+ { animationId: "position-tween", tweenPercentage: 100 },
97
+ { animationId: "scale-tween", tweenPercentage: 75 },
98
+ ],
99
+ tweenPercentage: 100,
100
+ elementId: ELEMENT.id,
101
+ });
102
+ act(() => root.unmount());
103
+ });
104
+
68
105
  it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
69
106
  const onSeek = vi.fn();
70
107
  const onSelectElement = vi.fn();
71
108
  const setSelectedElementId = vi.fn();
72
- let onClickKeyframe:
73
- | ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
74
- | undefined;
75
- let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
76
-
77
- function Harness() {
78
- ({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
79
- expandedElements: [ELEMENT],
80
- keyframeCache: new Map(),
81
- onSelectElement,
82
- onSeek,
83
- setSelectedElementId,
84
- setKfContextMenu: vi.fn(),
85
- toggleSelectedKeyframe: vi.fn(),
86
- }));
87
- return null;
88
- }
89
-
90
- const root = mountReactHarness(<Harness />);
109
+ const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
91
110
 
92
111
  // Selecting a segment must NOT move the playhead.
93
- act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
112
+ act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
94
113
  expect(onSeek).not.toHaveBeenCalled();
95
114
  expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
96
115
  animationId: "position-tween",
97
116
  tweenPercentage: 100,
98
117
  elementId: ELEMENT.id,
99
118
  });
119
+ expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
100
120
  expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
101
121
  expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
102
122
 
103
123
  // Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
104
- act(() => onClickKeyframe?.(ELEMENT, TARGET));
124
+ act(() => handlers.onClickKeyframe?.(ELEMENT, TARGET));
105
125
  expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
106
126
  act(() => root.unmount());
107
127
  });
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
65
65
  if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
66
66
  usePlayerStore.getState().setFocusedEaseSegment({
67
67
  animationId: target.animationId,
68
+ collidingAnimationTargets: target.collidingAnimationTargets,
68
69
  tweenPercentage: target.tweenPercentage,
69
70
  elementId: elId,
70
71
  });
@@ -268,6 +268,11 @@ export function useTimelineRangeSelection({
268
268
  if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
269
269
  isDragging.current = true;
270
270
  setIsScrubbing(true);
271
+ // Seed the pending coordinate so a press with no pointermove still
272
+ // replays THIS x on pointerup. `updateScrubDrag` is the only other
273
+ // writer, so without this a plain click settles on the ref's initial
274
+ // 0 and clamps the playhead back to t=0.
275
+ pendingClientXRef.current = e.clientX;
271
276
  seekFromX(e.clientX);
272
277
  return;
273
278
  }