@hyperframes/studio 0.7.78 → 0.7.79

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 (74) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1303 -1121
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  26. package/src/hooks/gsapDragCommit.ts +43 -10
  27. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  28. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  30. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  31. package/src/hooks/gsapResizeIntercept.ts +2 -2
  32. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  33. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  34. package/src/hooks/gsapShared.test.ts +144 -0
  35. package/src/hooks/gsapShared.ts +264 -0
  36. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  37. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  38. package/src/hooks/newTweenTarget.test.ts +364 -0
  39. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  40. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  41. package/src/hooks/timelineTrackVisibility.ts +11 -1
  42. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  43. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  44. package/src/hooks/useEnableKeyframes.ts +18 -5
  45. package/src/hooks/useGestureCommit.ts +21 -4
  46. package/src/hooks/useGsapScriptCommits.ts +3 -0
  47. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  48. package/src/hooks/useGsapTweenCache.ts +4 -11
  49. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  50. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  51. package/src/player/components/ShortcutsPanel.tsx +11 -15
  52. package/src/player/components/Timeline.test.ts +3 -1
  53. package/src/player/components/TimelineCanvas.tsx +2 -1
  54. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  55. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  56. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  57. package/src/player/components/TimelineLanes.test.tsx +281 -0
  58. package/src/player/components/TimelineLanes.tsx +43 -97
  59. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  60. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  61. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  62. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  63. package/src/player/components/timelineLaneProps.ts +86 -0
  64. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  65. package/src/player/components/timelineTrackDisplay.ts +38 -0
  66. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  67. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  68. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  69. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  70. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  71. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  72. package/src/telemetry/client.test.ts +12 -3
  73. package/src/telemetry/client.ts +3 -2
  74. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -27,6 +27,12 @@ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
27
27
  // stops there: at the zoom floor the gap alone left a ~7px target, which is
28
28
  // neither hittable nor selectable with any accuracy. Boxes may overlap slightly
29
29
  // below this width; each diamond still owns the half-gap around its own centre.
30
+ //
31
+ // Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
32
+ // own spacing exception: at normal keyframe density the neighbour gap is under
33
+ // 24px, so a 24px floor would make adjacent diamonds' hit boxes OVERLAP — one
34
+ // diamond swallowing its neighbour's clicks is a worse 2.5.8 failure than a
35
+ // small target. Do not "fix" this to 24.
30
36
  const KF_MIN_HIT_W = 12;
31
37
 
32
38
  /** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
@@ -141,6 +147,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
141
147
  ? Math.round(clipHeightPx * 0.45)
142
148
  : Math.round(LANE_H * DIAMOND_RATIO);
143
149
  const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
150
+ // Hit-box height only — the glyph keeps `marker.visualSize`. 24 is the WCAG 2.2
151
+ // (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
152
+ // shrunken box: 24px there would reach up into the beat strip.
153
+ const hitHeight = beatsActive ? diamondSize : 24;
144
154
  const sorted = keyframesData.keyframes
145
155
  .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
146
156
  .sort((a, b) => a.percentage - b.percentage);
@@ -412,7 +422,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
412
422
  top: centerY,
413
423
  transform: "translateY(-50%)",
414
424
  width: marker.hitWidth,
415
- height: diamondSize,
425
+ height: hitHeight,
416
426
  zIndex: isHighlighted ? 2 : 1,
417
427
  pointerEvents: "auto",
418
428
  background: "none",
@@ -62,6 +62,12 @@ export function TimelineDiamondConnectors({
62
62
  // no source animation id (runtime-scanned) so there is no tween to target.
63
63
  const target = keyframeTarget(kf);
64
64
  const ease = kf.ease ?? globalEase;
65
+ // connectorWidth is the clear span between the two diamonds' edges, so a
66
+ // 24x24 target centred in it overhangs a diamond as soon as the span is
67
+ // narrower than 24. The segment wrapper sits at z-index 3, above the
68
+ // diamonds, so that overhang would win the hit test and steal their
69
+ // clicks at fit zoom. Grow the target only where the room exists.
70
+ const roomForFullTarget = connectorWidth >= 24;
65
71
  return (
66
72
  <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
67
73
  <div
@@ -103,7 +109,14 @@ export function TimelineDiamondConnectors({
103
109
  data-keyframe-ease-button=""
104
110
  aria-label={`Edit ${ease} easing`}
105
111
  title={`Edit ${ease} easing`}
106
- className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
112
+ // A visible 24x24 badge would collide with the diamonds either
113
+ // side, so the WCAG 2.2 (2.5.8) target is met with a centered
114
+ // transparent ::before overlay; the box stays 16x16. Where the
115
+ // segment is too narrow for that overlay the button keeps its
116
+ // 16x16 hit area, which is WCAG's target-spacing exception:
117
+ // the neighbouring diamonds are themselves the reason it
118
+ // cannot grow, and stealing their clicks is the worse failure.
119
+ className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
107
120
  style={{
108
121
  left: "50%",
109
122
  top: "50%",
@@ -0,0 +1,281 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, createRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { TimelineLanes } from "./TimelineLanes";
8
+ import { getTrackStyle } from "./timelineIcons";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
+ import { TRACK_H } from "./timelineLayout";
11
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
12
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
14
+ import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
15
+
16
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ afterEach(() => {
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.getState().reset();
21
+ });
22
+
23
+ /** The z-order sort keys really are fractional: a clip nudged between two lanes
24
+ * lands on the midpoint. These are the values that used to reach aria-label. */
25
+ const TRACK_A = 1 / 6;
26
+ const TRACK_B = 0.5;
27
+
28
+ function element(id: string, track: number): TimelineElement {
29
+ return { id, label: id, tag: "div", start: 0, duration: 2, track };
30
+ }
31
+
32
+ function positionTween(id: string): GsapAnimation {
33
+ return {
34
+ id: `${id}-tween`,
35
+ targetSelector: `#${id}`,
36
+ method: "to",
37
+ position: 0,
38
+ duration: 2,
39
+ properties: {},
40
+ propertyGroup: "position",
41
+ keyframes: {
42
+ format: "percentage",
43
+ keyframes: [
44
+ { percentage: 0, properties: { x: 0 } },
45
+ { percentage: 100, properties: { x: 100 } },
46
+ ],
47
+ },
48
+ };
49
+ }
50
+
51
+ interface RenderLanesOptions {
52
+ elements?: TimelineElement[];
53
+ animations?: Map<string, GsapAnimation[]>;
54
+ expandedClipIds?: string[];
55
+ selectedElementIds?: Set<string>;
56
+ multiDragPreview?: MultiDragPreviewInput | null;
57
+ draggedClip?: DraggedClipState | null;
58
+ onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
59
+ onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
60
+ }
61
+
62
+ function renderLanes(options: RenderLanesOptions = {}): {
63
+ host: HTMLDivElement;
64
+ root: Root;
65
+ rerender: (next: RenderLanesOptions) => void;
66
+ } {
67
+ const host = document.createElement("div");
68
+ document.body.append(host);
69
+ const root = createRoot(host);
70
+ const render = (next: RenderLanesOptions) => {
71
+ const elements = next.elements ?? [element("clip-a", TRACK_A)];
72
+ const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
73
+ const displayTrackOrder = [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
74
+ const tracks: [number, TimelineElement[]][] = displayTrackOrder.map((track) => [
75
+ track,
76
+ elements.filter((el) => el.track === track),
77
+ ]);
78
+ const laneCounts = new Map(
79
+ elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
80
+ );
81
+ act(() => {
82
+ usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
83
+ root.render(
84
+ <TimelineLanes
85
+ pps={100}
86
+ contentOrigin={232}
87
+ contentGutter={32}
88
+ trackContentWidth={800}
89
+ theme={defaultTimelineTheme}
90
+ displayTrackOrder={displayTrackOrder}
91
+ rowHeights={displayTrackOrder.map(() => TRACK_H)}
92
+ trackOrder={displayTrackOrder}
93
+ tracks={tracks}
94
+ trackStyles={new Map()}
95
+ laneCounts={laneCounts}
96
+ selectedElementId={null}
97
+ selectedElementIds={next.selectedElementIds ?? new Set()}
98
+ hoveredClip={null}
99
+ draggedClip={next.draggedClip ?? null}
100
+ draggedElement={null}
101
+ multiDragPreview={next.multiDragPreview ?? null}
102
+ blockedClipRef={createRef<BlockedClipState | null>()}
103
+ suppressClickRef={{ current: false }}
104
+ scrollRef={createRef<HTMLDivElement>()}
105
+ setHoveredClip={vi.fn()}
106
+ setShowPopover={vi.fn()}
107
+ setRangeSelection={vi.fn()}
108
+ setResizingClip={vi.fn()}
109
+ setDraggedClip={vi.fn()}
110
+ setSelectedElementId={vi.fn()}
111
+ syncClipDragAutoScroll={vi.fn()}
112
+ shiftClickClipRef={createRef()}
113
+ getPreviewElement={(el) => el}
114
+ getTrackStyle={getTrackStyle}
115
+ gsapAnimations={gsapAnimations}
116
+ selectedKeyframes={new Set()}
117
+ currentTime={0}
118
+ onContextMenuLane={next.onContextMenuLane}
119
+ onToggleTrackHidden={next.onToggleTrackHidden}
120
+ onTogglePropertyGroupKeyframe={vi.fn()}
121
+ onResizeElement={vi.fn()}
122
+ onMoveElement={vi.fn()}
123
+ onRazorSplit={vi.fn()}
124
+ onRazorSplitAll={vi.fn()}
125
+ />,
126
+ );
127
+ });
128
+ };
129
+ render(options);
130
+ return { host, root, rerender: render };
131
+ }
132
+
133
+ function visibilityLabels(host: HTMLElement): (string | null)[] {
134
+ return Array.from(host.querySelectorAll("button[aria-label^='Hide track ']")).map((button) =>
135
+ button.getAttribute("aria-label"),
136
+ );
137
+ }
138
+
139
+ describe("TimelineLanes track numbering", () => {
140
+ // Screen readers literally announced "Hide track 0.16666666666666666".
141
+ it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
142
+ const view = renderLanes({
143
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
144
+ });
145
+
146
+ expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
147
+ expect(view.host.innerHTML).not.toContain("0.16666666666666666");
148
+ act(() => view.root.unmount());
149
+ });
150
+
151
+ it("hands the visibility toggle the real track key, not the display index", () => {
152
+ const onToggleTrackHidden = vi.fn();
153
+ const view = renderLanes({
154
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
155
+ onToggleTrackHidden,
156
+ });
157
+
158
+ const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
159
+ act(() => second?.click());
160
+
161
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
162
+ act(() => view.root.unmount());
163
+ });
164
+
165
+ // The gap menu inserts at the track it is given, so a display index here would
166
+ // drop the new clip on the wrong lane.
167
+ it("hands the lane context menu the real track key, not the display index", () => {
168
+ const onContextMenuLane = vi.fn();
169
+ const view = renderLanes({
170
+ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
171
+ onContextMenuLane,
172
+ });
173
+
174
+ // Row children: [sticky header column, time-mapped track content].
175
+ const rows = Array.from(view.host.children);
176
+ const secondTrackContent = rows[1]?.children.item(1);
177
+ act(() => {
178
+ secondTrackContent?.dispatchEvent(
179
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
180
+ );
181
+ });
182
+
183
+ expect(onContextMenuLane).toHaveBeenCalledOnce();
184
+ expect(onContextMenuLane.mock.calls[0]?.[1]).toBe(TRACK_B);
185
+ act(() => view.root.unmount());
186
+ });
187
+ });
188
+
189
+ describe("TimelineLanes disclosure target", () => {
190
+ const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
191
+
192
+ function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
193
+ const caret = host.querySelector("button[aria-controls]");
194
+ const id = caret?.getAttribute("aria-controls");
195
+ return id ? host.querySelector<HTMLElement>(`#${id}`) : null;
196
+ }
197
+
198
+ // aria-controls used to name a div in the sticky label column: it computed to
199
+ // 0x0 and held no diamonds at all.
200
+ it("resolves the caret's aria-controls to an element holding the property lanes", () => {
201
+ const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
202
+ const target = ariaControlsTarget(view.host);
203
+
204
+ expect(target).not.toBeNull();
205
+ expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
206
+ act(() => view.root.unmount());
207
+ });
208
+
209
+ it("still resolves the caret's aria-controls while the layer is collapsed", () => {
210
+ const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] });
211
+ const target = ariaControlsTarget(view.host);
212
+
213
+ expect(target).not.toBeNull();
214
+ expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
215
+ act(() => view.root.unmount());
216
+ });
217
+
218
+ // Two timelines on one page (a mini-timeline in a modal beside the main one)
219
+ // both minted `timeline-lanes-track-0`, so every caret's aria-controls
220
+ // resolved to whichever instance mounted first.
221
+ it("mints lane ids that do not collide with a second TimelineLanes on the page", () => {
222
+ const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
223
+ const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
224
+
225
+ const idsFor = (host: HTMLElement) =>
226
+ Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) =>
227
+ caret.getAttribute("aria-controls"),
228
+ );
229
+ const firstIds = idsFor(first.host);
230
+ const secondIds = idsFor(second.host);
231
+
232
+ expect(firstIds.length).toBeGreaterThan(0);
233
+ expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
234
+ // Still a legal CSS id selector: the aria-controls lookups above use `#id`.
235
+ for (const id of [...firstIds, ...secondIds]) {
236
+ expect(id).toMatch(/^[A-Za-z][\w-]*$/);
237
+ }
238
+ act(() => first.root.unmount());
239
+ act(() => second.root.unmount());
240
+ });
241
+
242
+ // The passenger branch wraps [clip, lanes] in a transformed div that re-renders
243
+ // on every pointer move. An unstable key there remounts the lanes and drops the
244
+ // in-flight drag.
245
+ it("does not remount the lanes while a multi-clip drag slides the formation", () => {
246
+ const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
247
+ const selectedElementIds = new Set(["clip-a", "clip-b"]);
248
+ const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({
249
+ dragStarted: true,
250
+ draggedKey: "clip-b",
251
+ draggedOriginStart: 0,
252
+ draggedPreviewStart,
253
+ selectedKeys: selectedElementIds,
254
+ });
255
+ const view = renderLanes({
256
+ elements,
257
+ animations: ANIMATIONS,
258
+ expandedClipIds: ["clip-a"],
259
+ selectedElementIds,
260
+ multiDragPreview: preview(0.25),
261
+ });
262
+
263
+ const before = ariaControlsTarget(view.host);
264
+ const beforeLane = before?.querySelector("[data-timeline-property-lane]");
265
+ expect(before).not.toBeNull();
266
+ expect(beforeLane).not.toBeNull();
267
+
268
+ view.rerender({
269
+ elements,
270
+ animations: ANIMATIONS,
271
+ expandedClipIds: ["clip-a"],
272
+ selectedElementIds,
273
+ multiDragPreview: preview(0.75),
274
+ });
275
+
276
+ // Node identity, not just presence: a remount replaces these nodes.
277
+ expect(ariaControlsTarget(view.host)).toBe(before);
278
+ expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
279
+ act(() => view.root.unmount());
280
+ });
281
+ });
@@ -1,28 +1,21 @@
1
- import { type ReactNode } from "react";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
+ import { useId } from "react";
3
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
4
3
  import { TimelineClip } from "./TimelineClip";
5
4
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
7
6
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
8
7
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
9
- import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
10
- import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
8
+ import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
9
+ import { clipTimingStart } from "../../hooks/gsapShared";
11
10
  import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
12
- import type { TimelineTheme } from "./timelineTheme";
13
11
  import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
14
- import {
15
- usePlayerStore,
16
- type TimelineElement,
17
- type KeyframeCacheEntry,
18
- } from "../store/playerStore";
19
- import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
12
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
20
13
  import {
21
14
  isMultiDragPassenger,
22
15
  multiDragPassengerOffsetPx,
23
16
  type MultiDragPreviewInput,
24
17
  } from "./timelineMultiDragPreview";
25
- import type { TrackVisualStyle } from "./timelineIcons";
18
+ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
26
19
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
27
20
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
28
21
  import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
@@ -30,83 +23,6 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
30
23
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
31
24
  import { renderClipChildren } from "./timelineClipChildren";
32
25
 
33
- /**
34
- * Props shared by the scroll container ({@link TimelineCanvas}) and the lane
35
- * renderer below. TimelineCanvas passes these straight through via spread, so
36
- * they are declared once here and both prop types compose from this base — no
37
- * duplicated prop list.
38
- */
39
- export interface TimelineLaneBaseProps {
40
- pps: number;
41
- contentOrigin: number;
42
- contentGutter: number;
43
- trackContentWidth: number;
44
- theme: TimelineTheme;
45
- displayTrackOrder: number[];
46
- rowHeights: readonly number[];
47
- trackOrder: number[];
48
- tracks: [number, TimelineElement[]][];
49
- trackStyles: Map<number, TrackVisualStyle>;
50
- laneCounts: ReadonlyMap<string, number>;
51
- selectedElementId: string | null;
52
- selectedElementIds: Set<string>;
53
- hoveredClip: string | null;
54
- draggedClip: DraggedClipState | null;
55
- blockedClipRef: React.RefObject<BlockedClipState | null>;
56
- suppressClickRef: React.RefObject<boolean>;
57
- scrollRef: React.RefObject<HTMLDivElement | null>;
58
- renderClipContent?: (
59
- element: TimelineElement,
60
- style: { clip: string; label: string },
61
- ) => ReactNode;
62
- renderClipOverlay?: (element: TimelineElement) => ReactNode;
63
- onDrillDown?: (element: TimelineElement) => void;
64
- onSelectElement?: (element: TimelineElement | null) => void;
65
- setHoveredClip: (key: string | null) => void;
66
- setShowPopover: (v: boolean) => void;
67
- setRangeSelection: (v: null) => void;
68
- setResizingClip: (v: ResizingClipState | null) => void;
69
- setDraggedClip: (v: DraggedClipState | null) => void;
70
- setSelectedElementId: (id: string | null) => void;
71
- syncClipDragAutoScroll: (x: number, y: number) => void;
72
- shiftClickClipRef: React.RefObject<{
73
- element: TimelineElement;
74
- anchorX: number;
75
- anchorY: number;
76
- } | null>;
77
- getPreviewElement: (element: TimelineElement) => TimelineElement;
78
- getTrackStyle: (tag: string) => TrackVisualStyle;
79
- keyframeCache?: Map<string, KeyframeCacheEntry>;
80
- gsapAnimations: Map<string, GsapAnimation[]>;
81
- selectedKeyframes: Set<string>;
82
- currentTime: number;
83
- onSeek?: (time: number) => void;
84
- onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
85
- onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
86
- onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
87
- onContextMenuKeyframe?: (
88
- e: React.MouseEvent,
89
- elementId: string,
90
- target: TimelineKeyframeTarget,
91
- ) => void;
92
- onMoveKeyframe?: (
93
- elementId: string,
94
- keyframe: TimelineKeyframeTarget,
95
- toClipPercentage: number,
96
- propertyGroup?: string,
97
- tweenPercentage?: number,
98
- animationId?: string,
99
- ) => Promise<boolean>;
100
- onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
101
- /**
102
- * Right-click on EMPTY lane space (not on a clip — those preventDefault
103
- * before this fires — not the gutter/ruler, not below the lanes). `time` is
104
- * the timeline time (seconds) under the pointer on that lane.
105
- */
106
- onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
107
- beatAnalysis?: MusicBeatAnalysis | null;
108
- }
109
-
110
26
  interface TimelineLanesProps extends TimelineLaneBaseProps {
111
27
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
112
28
  draggedElement: TimelineElement | null;
@@ -174,6 +90,12 @@ export function TimelineLanes({
174
90
  onRazorSplit,
175
91
  onRazorSplitAll,
176
92
  }: TimelineLanesProps) {
93
+ // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
94
+ // beside the main one) cannot both mint `...-track-0` and have every caret's
95
+ // aria-controls resolve to whichever mounted first. React's useId embeds
96
+ // colons, which are legal in an id and in aria-controls but need escaping in
97
+ // a CSS `#id` selector, so they come out here and the prefix stays plain.
98
+ const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
177
99
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
178
100
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
179
101
  const toggleClipExpandedTracked = (key: string) => {
@@ -191,6 +113,7 @@ export function TimelineLanes({
191
113
  // in a virtualizer if editorial workflows ever push very high clip counts.
192
114
  // fallow-ignore-next-line complexity
193
115
  displayTrackOrder.map((trackNum, row) => {
116
+ const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
194
117
  const rowHeight = getTimelineRowHeight(row, rowHeights);
195
118
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
196
119
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
@@ -217,6 +140,11 @@ export function TimelineLanes({
217
140
  const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
218
141
  const keyframeClipExpanded =
219
142
  keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
143
+ // Minted here because this is the only place that sees BOTH ends of
144
+ // the disclosure: the caret in the sticky header and the diamond lanes
145
+ // on the canvas. Keyed by display row, not by `trackNum`, which is a
146
+ // fractional sort key and would mint ids like `...-0.16666666666666666`.
147
+ const lanesId = `${lanesIdPrefix}-track-${row}`;
220
148
  return (
221
149
  <div
222
150
  key={trackNum}
@@ -229,7 +157,16 @@ export function TimelineLanes({
229
157
  >
230
158
  <TimelineTrackHeader
231
159
  trackNumber={trackNum}
232
- trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
160
+ // What gets announced. `trackNum` is a fractional z-order sort
161
+ // key, so it stays out of every label and in every callback.
162
+ trackDisplayNumber={displayNumber}
163
+ trackLabel={
164
+ els[0]?.label ??
165
+ els[0]?.domId ??
166
+ els[0]?.id ??
167
+ `Track${trackDisplaySuffix(displayNumber)}`
168
+ }
169
+ lanesId={lanesId}
233
170
  contentOrigin={contentOrigin}
234
171
  keyframeClip={keyframeClip}
235
172
  clipCount={els.length}
@@ -312,10 +249,9 @@ export function TimelineLanes({
312
249
  // Only the track's active keyframe clip shows expanded lanes;
313
250
  // other clips (incl. siblings on a shared track) show compact
314
251
  // diamonds on their own bar instead.
315
- const showsLanes =
316
- STUDIO_KEYFRAMES_ENABLED &&
317
- elementKey === keyframeClipKey &&
318
- keyframeClipExpanded;
252
+ const isTrackKeyframeClip =
253
+ STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey;
254
+ const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
319
255
  const capabilities = getTimelineEditCapabilities(el);
320
256
  const isSelected =
321
257
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
@@ -523,11 +459,21 @@ export function TimelineLanes({
523
459
  )}
524
460
  </TimelineClip>
525
461
  );
526
- const propertyLanes = showsLanes && (
462
+ // Mounted for the track's keyframe clip in BOTH disclosure
463
+ // states, so the header caret's aria-controls resolves while
464
+ // collapsed too; collapsed just feeds it no animations, so
465
+ // the wrapper renders empty. The key is stable across a
466
+ // multi-drag: without it the passenger branch below remounts
467
+ // this subtree and interrupts the gesture.
468
+ const propertyLanes = isTrackKeyframeClip && (
527
469
  <TimelinePropertyLanes
528
470
  key={`${clipKey}-property-lanes`}
529
- animations={gsapAnimations.get(elementKey) ?? []}
530
- clipStart={previewElement.start}
471
+ id={lanesId}
472
+ animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
473
+ // clipTimingStart, not the raw start: an expanded sub-comp
474
+ // child's start is host-absolute while its tweens are
475
+ // local to its own file.
476
+ clipStart={clipTimingStart(previewElement)}
531
477
  clipDuration={previewElement.duration}
532
478
  clipLeftPx={previewElement.start * pps}
533
479
  clipWidthPx={Math.max(previewElement.duration * pps, 4)}