@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { defaultTimelineTheme } from "./timelineTheme";
7
+ import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
8
+ import type { DraggedClipState } from "./timelineClipDragTypes";
9
+ import { getTrackStyle } from "./useTimelineTrackLayout";
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
12
+
13
+ const drag: DraggedClipState = {
14
+ pointerId: 0,
15
+ element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
16
+ originClientX: 100,
17
+ originClientY: 100,
18
+ originScrollLeft: 0,
19
+ originScrollTop: 0,
20
+ pointerClientX: 350,
21
+ pointerClientY: 240,
22
+ pointerOffsetX: 20,
23
+ pointerOffsetY: 10,
24
+ previewStart: 4,
25
+ previewTrack: 2,
26
+ insertRow: null,
27
+ snapTime: null,
28
+ snapType: null,
29
+ started: true,
30
+ };
31
+
32
+ afterEach(() => document.body.replaceChildren());
33
+
34
+ describe("TimelineGestureOverlay", () => {
35
+ it("keeps the drag actor mounted without a source-row node", () => {
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+ const scroll = {
40
+ scrollLeft: 500,
41
+ scrollTop: 300,
42
+ getBoundingClientRect: () => ({ left: 50, top: 40 }),
43
+ } as HTMLDivElement;
44
+ act(() => {
45
+ root.render(
46
+ <TimelineGestureOverlay
47
+ drag={drag}
48
+ scrollRef={{ current: scroll }}
49
+ pixelsPerSecond={100}
50
+ rowHeight={42}
51
+ selectedElementId="hero"
52
+ currentTime={4}
53
+ theme={defaultTimelineTheme}
54
+ getTrackStyle={getTrackStyle}
55
+ />,
56
+ );
57
+ });
58
+ const actor = host.querySelector<HTMLElement>('[data-timeline-gesture-actor="hero"]');
59
+ expect(actor?.style.left).toBe("780px");
60
+ expect(actor?.style.top).toBe("490px");
61
+ expect(actor?.querySelector(".timeline-clip")).not.toBeNull();
62
+ expect(actor?.querySelector("[data-el-id]")).toBeNull();
63
+ expect(actor?.querySelector("[data-clip]")).toBeNull();
64
+ expect(host.querySelector("[data-source-row]")).toBeNull();
65
+ act(() => root.unmount());
66
+ });
67
+
68
+ it("keeps the stable overlay host after terminal cleanup", () => {
69
+ const host = document.createElement("div");
70
+ document.body.append(host);
71
+ const root = createRoot(host);
72
+ act(() => {
73
+ root.render(
74
+ <TimelineGestureOverlay
75
+ drag={null}
76
+ scrollRef={{ current: null }}
77
+ pixelsPerSecond={100}
78
+ rowHeight={42}
79
+ selectedElementId={null}
80
+ currentTime={0}
81
+ theme={defaultTimelineTheme}
82
+ getTrackStyle={getTrackStyle}
83
+ />,
84
+ );
85
+ });
86
+ expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
87
+ expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
88
+ act(() => root.unmount());
89
+ });
90
+ });
@@ -0,0 +1,96 @@
1
+ import { memo, type ReactNode } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { TimelineTheme } from "./timelineTheme";
4
+ import { getRenderedTimelineElement } from "./timelineTheme";
5
+ import { TimelineClip } from "./TimelineClip";
6
+ import { getTimelineEditCapabilities } from "./timelineEditing";
7
+ import { renderClipChildren } from "./timelineClipChildren";
8
+ import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
9
+ import type { DraggedClipState } from "./timelineClipDragTypes";
10
+ import type { TrackVisualStyle } from "./timelineIcons";
11
+ import { isTimelineClipActive } from "./useTimelineActiveClips";
12
+
13
+ interface TimelineGestureOverlayProps {
14
+ drag: DraggedClipState | null;
15
+ scrollRef: React.RefObject<HTMLDivElement | null>;
16
+ pixelsPerSecond: number;
17
+ rowHeight: number;
18
+ selectedElementId: string | null;
19
+ currentTime: number;
20
+ theme: TimelineTheme;
21
+ getTrackStyle: (tag: string) => TrackVisualStyle;
22
+ renderClipContent?: (
23
+ element: TimelineElement,
24
+ style: { clip: string; label: string },
25
+ ) => ReactNode;
26
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
27
+ }
28
+
29
+ /** Stable canvas child that owns the live drag actor independently of source rows. */
30
+ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
31
+ drag,
32
+ scrollRef,
33
+ pixelsPerSecond,
34
+ rowHeight,
35
+ selectedElementId,
36
+ currentTime,
37
+ theme,
38
+ getTrackStyle,
39
+ renderClipContent,
40
+ renderClipOverlay,
41
+ }: TimelineGestureOverlayProps) {
42
+ const element =
43
+ drag?.started === true
44
+ ? getRenderedTimelineElement({
45
+ element: drag.element,
46
+ draggedElementId: drag.element.key ?? drag.element.id,
47
+ previewStart: drag.previewStart,
48
+ previewTrack: drag.previewTrack,
49
+ })
50
+ : null;
51
+ const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
52
+ return (
53
+ <div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
54
+ {element && position && (
55
+ <div
56
+ data-timeline-gesture-actor={element.key ?? element.id}
57
+ className="absolute"
58
+ style={{
59
+ top: position.top,
60
+ left: position.left,
61
+ width: Math.max(element.duration * pixelsPerSecond, 4),
62
+ height: rowHeight,
63
+ zIndex: 40,
64
+ }}
65
+ >
66
+ <TimelineClip
67
+ el={{ ...element, start: 0 }}
68
+ pps={pixelsPerSecond}
69
+ clipY={0}
70
+ isSelected={selectedElementId === (element.key ?? element.id)}
71
+ isHovered={false}
72
+ isDragging
73
+ isGestureActor
74
+ isActive={isTimelineClipActive(element, currentTime)}
75
+ hasCustomContent={!!renderClipContent}
76
+ capabilities={getTimelineEditCapabilities(element)}
77
+ theme={theme}
78
+ isComposition={!!element.compositionSrc}
79
+ onHoverStart={() => {}}
80
+ onHoverEnd={() => {}}
81
+ onResizeStart={() => {}}
82
+ onClick={() => {}}
83
+ onDoubleClick={() => {}}
84
+ >
85
+ {renderClipChildren(
86
+ element,
87
+ getTrackStyle(element.tag),
88
+ renderClipContent,
89
+ renderClipOverlay,
90
+ )}
91
+ </TimelineClip>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ });
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
11
11
  import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12
+ import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
12
13
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13
14
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
14
15
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -26,6 +27,17 @@ afterEach(() => {
26
27
  const TRACK_A = 1 / 6;
27
28
  const TRACK_B = 0.5;
28
29
 
30
+ /** Every string a screen reader or a sighted user actually reads. */
31
+ function visibleText(host: HTMLElement): string {
32
+ return host.textContent ?? "";
33
+ }
34
+
35
+ function ariaLabels(host: HTMLElement): string {
36
+ return Array.from(host.querySelectorAll("[aria-label]"))
37
+ .map((el) => el.getAttribute("aria-label") ?? "")
38
+ .join(" ");
39
+ }
40
+
29
41
  function element(id: string, track: number): TimelineElement {
30
42
  return { id, label: id, tag: "div", start: 0, duration: 2, track };
31
43
  }
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
94
106
  rowGeometry={getTimelineRowGeometry(rowHeights)}
95
107
  virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
96
108
  rowsVirtualized={false}
109
+ focusedTargetId={null}
110
+ logicalRows={buildTimelineLogicalRows({
111
+ tracks,
112
+ displayTrackOrder,
113
+ laneCounts,
114
+ selectedElementId: null,
115
+ selectedElementIds: next.selectedElementIds ?? new Set(),
116
+ expandedClipIds: new Set(next.expandedClipIds ?? []),
117
+ gsapAnimations,
118
+ })}
97
119
  clipIndex={createTimelineClipIndex(tracks)}
98
120
  renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
99
121
  pinnedClipIdentities={new Set()}
@@ -116,7 +138,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
116
138
  setResizingClip={vi.fn()}
117
139
  setDraggedClip={vi.fn()}
118
140
  setSelectedElementId={vi.fn()}
119
- syncClipDragAutoScroll={vi.fn()}
120
141
  shiftClickClipRef={createRef()}
121
142
  getPreviewElement={(el) => el}
122
143
  getTrackStyle={getTrackStyle}
@@ -153,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
153
174
 
154
175
  expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
155
176
  expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
156
- expect(view.host.innerHTML).not.toContain("0.16666666666666666");
177
+ // Only what a user reads. The fractional key still identifies the row in
178
+ // `id` / `data-` attributes, which is exactly where an opaque sort key
179
+ // belongs.
180
+ expect(visibleText(view.host)).not.toContain("0.16666666666666666");
181
+ expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
157
182
  act(() => view.root.unmount());
158
183
  });
159
184
 
@@ -180,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
180
205
  onContextMenuLane,
181
206
  });
182
207
 
183
- // Row children: [sticky header column, time-mapped track content]. The rows
184
- // sit inside the lanes list, which is what carries the virtualization
185
- // positioning context.
186
- const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
187
- const secondTrackContent = rows[1]?.children.item(1);
208
+ // The lane's own content cell: the track row's second child, after the
209
+ // sticky header column.
210
+ const secondTrackContent = view.host
211
+ .querySelectorAll("[data-timeline-row]")[1]
212
+ ?.querySelector('[role="row"]')
213
+ ?.children.item(1);
188
214
  act(() => {
189
215
  secondTrackContent?.dispatchEvent(
190
216
  new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
@@ -239,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
239
265
  );
240
266
  const firstIds = idsFor(first.host);
241
267
  const secondIds = idsFor(second.host);
268
+ const cellIdsFor = (host: HTMLElement) =>
269
+ new Set(
270
+ Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
271
+ cell.getAttribute("id"),
272
+ ).filter((id): id is string => id !== null),
273
+ );
274
+ const ownedIdsFor = (host: HTMLElement) =>
275
+ Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
276
+ owner.getAttribute("aria-owns"),
277
+ ).filter((id): id is string => id !== null);
278
+ const firstCellIds = cellIdsFor(first.host);
279
+ const secondCellIds = cellIdsFor(second.host);
242
280
 
281
+ for (const { host } of [first, second]) {
282
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
283
+ expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
284
+ expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
285
+ expect(
286
+ [...host.querySelectorAll('[role="rowheader"]')].every(
287
+ (cell) => cell.getAttribute("aria-colindex") === "1",
288
+ ),
289
+ ).toBe(true);
290
+ expect(
291
+ [...host.querySelectorAll('[role="gridcell"]')].every(
292
+ (cell) => cell.getAttribute("aria-colindex") === "2",
293
+ ),
294
+ ).toBe(true);
295
+ }
243
296
  expect(firstIds.length).toBeGreaterThan(0);
244
297
  expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
298
+ expect(firstCellIds.size).toBeGreaterThan(0);
299
+ expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
300
+ expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
301
+ expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
245
302
  // Still a legal CSS id selector: the aria-controls lookups above use `#id`.
246
303
  for (const id of [...firstIds, ...secondIds]) {
247
304
  expect(id).toMatch(/^[A-Za-z][\w-]*$/);
@@ -1,7 +1,7 @@
1
- import { Fragment, useId } from "react";
1
+ import { Fragment, useId, useMemo } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
- import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
4
+ import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
5
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
6
6
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
7
7
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
@@ -27,6 +27,9 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
27
27
  import { isTimelineClipActive } from "./useTimelineActiveClips";
28
28
  import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
29
29
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
30
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
31
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
32
+ import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
30
33
 
31
34
  interface TimelineLanesProps extends TimelineLaneBaseProps {
32
35
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -49,6 +52,8 @@ export function TimelineLanes({
49
52
  displayTrackOrder,
50
53
  rowGeometry,
51
54
  virtualRows,
55
+ logicalRows,
56
+ focusedTargetId,
52
57
  rowsVirtualized,
53
58
  clipIndex,
54
59
  renderTimeRange,
@@ -76,7 +81,6 @@ export function TimelineLanes({
76
81
  setResizingClip,
77
82
  setDraggedClip,
78
83
  setSelectedElementId,
79
- syncClipDragAutoScroll,
80
84
  shiftClickClipRef,
81
85
  getPreviewElement,
82
86
  getTrackStyle,
@@ -100,14 +104,20 @@ export function TimelineLanes({
100
104
  onRazorSplit,
101
105
  onRazorSplitAll,
102
106
  }: TimelineLanesProps) {
103
- // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
104
- // beside the main one) cannot both mint `...-track-0` and have every caret's
105
- // aria-controls resolve to whichever mounted first. React's useId embeds
106
- // colons, which are legal in an id and in aria-controls but need escaping in
107
- // a CSS `#id` selector, so they come out here and the prefix stays plain.
107
+ // ponytail: One per-instance namespace prevents aria-controls and aria-owns
108
+ // from resolving into a second timeline that renders the same logical rows.
108
109
  const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
109
110
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
110
111
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
112
+ const logicalRowsByTrack = useMemo(() => {
113
+ const byTrack = new Map<number, TimelineLogicalRow[]>();
114
+ for (const logicalRow of logicalRows) {
115
+ const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
116
+ trackRows.push(logicalRow);
117
+ byTrack.set(logicalRow.physicalTrackKey, trackRows);
118
+ }
119
+ return byTrack;
120
+ }, [logicalRows]);
111
121
  const toggleClipExpandedTracked = (key: string) => {
112
122
  const willExpand = !expandedClipIds.has(key);
113
123
  trackStudioKeyframeLaneExpand({ expanded: willExpand });
@@ -129,10 +139,23 @@ export function TimelineLanes({
129
139
  },
130
140
  ]
131
141
  : [];
142
+ const keyboard = useTimelineKeyboardActor({
143
+ logicalRows,
144
+ focusedTargetId,
145
+ rowGeometry,
146
+ scrollRef,
147
+ onToggleRow: (row) => {
148
+ if (row.elementId) toggleClipExpandedTracked(row.elementId);
149
+ },
150
+ });
132
151
  return (
133
152
  <div
134
- role="list"
153
+ role="treegrid"
135
154
  aria-label="Timeline tracks"
155
+ aria-rowcount={logicalRows.length}
156
+ aria-colcount={2}
157
+ onFocus={keyboard.onFocus}
158
+ onKeyDown={keyboard.onKeyDown}
136
159
  className={rowsVirtualized ? "absolute inset-0" : undefined}
137
160
  >
138
161
  {
@@ -141,6 +164,9 @@ export function TimelineLanes({
141
164
  const trackNum = displayTrackOrder[row];
142
165
  if (trackNum === undefined) return null;
143
166
  const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
167
+ const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
168
+ const logicalRow = trackLogicalRows[0];
169
+ if (!logicalRow) return null;
144
170
  const rowHeight = rowGeometry.getRowHeight(row);
145
171
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
146
172
  const renderElements = rowsVirtualized
@@ -157,9 +183,7 @@ export function TimelineLanes({
157
183
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
158
184
  // All lanes use the same uniform color — no alternating stripes.
159
185
  const rowBackground = theme.rowBackground;
160
- // The beat-dot strip occupies the top of this track's lane (active track,
161
- // or the music track when nothing is selected). When shown, keyframe
162
- // diamonds shrink + drop to the bottom half so they don't collide with it.
186
+ // Keep diamonds below the beat strip on the active/music track.
163
187
  const beatStripOnTrack =
164
188
  (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
165
189
  (selectedElementId
@@ -167,9 +191,7 @@ export function TimelineLanes({
167
191
  : els.some(isMusicTrack));
168
192
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
169
193
  const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
170
- // The one keyframed element this track shows lanes for (selected, else
171
- // most lanes). A track can hold several elements; scoping to one keeps
172
- // their keyframes from cramming into a single row.
194
+ // Only the selected/most-keyframed clip owns expanded lanes on a shared track.
173
195
  const keyframeClip = resolveTrackKeyframeClip(
174
196
  els,
175
197
  laneCounts,
@@ -179,22 +201,22 @@ export function TimelineLanes({
179
201
  const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
180
202
  const keyframeClipExpanded =
181
203
  keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
182
- // Minted here because this is the only place that sees BOTH ends of
183
- // the disclosure: the caret in the sticky header and the diamond lanes
184
- // on the canvas. Keyed by display row, not by `trackNum`, which is a
185
- // fractional sort key and would mint ids like `...-0.16666666666666666`.
204
+ // Link the sticky caret to the canvas lanes with a stable display-row id.
186
205
  const lanesId = `${lanesIdPrefix}-track-${row}`;
187
206
  return (
188
207
  <TimelineTrackRow
189
208
  key={rowKey}
190
209
  index={row}
191
210
  rowKey={rowKey}
192
- rowCount={displayTrackOrder.length}
211
+ logicalRow={logicalRow}
212
+ propertyRows={trackLogicalRows.slice(1)}
213
+ lanesId={lanesId}
193
214
  top={rowGeometry.getRowTop(row)}
194
215
  height={rowHeight}
195
216
  virtualized={rowsVirtualized}
196
217
  background={rowBackground}
197
218
  borderColor={theme.rowBorder}
219
+ rovingTargetId={keyboard.rovingTargetId}
198
220
  >
199
221
  <TimelineTrackHeader
200
222
  trackNumber={trackNum}
@@ -225,8 +247,11 @@ export function TimelineLanes({
225
247
  onToggleTrackHidden={onToggleTrackHidden}
226
248
  onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
227
249
  onSeek={onSeek}
250
+ rovingTargetId={keyboard.rovingTargetId}
228
251
  />
229
252
  <div
253
+ role="gridcell"
254
+ aria-colindex={2}
230
255
  style={{
231
256
  width: trackContentWidth,
232
257
  marginLeft: contentGutter, // room for a 0% diamond left of t=0
@@ -298,9 +323,8 @@ export function TimelineLanes({
298
323
  const isSelected =
299
324
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
300
325
  const isComposition = !!el.compositionSrc;
301
- // elementKey (el.key ?? el.id) is already unique per clip; do NOT
302
- // fold in the map index, or a splice/reorder remounts every clip
303
- // at/after the change (DOM flash, drag interruption).
326
+ // The element identity is already unique per clip. Never fold in the map
327
+ // index, or a splice/reorder remounts every clip at/after the change.
304
328
  const clipKey = elementKey;
305
329
  const isDraggingClip =
306
330
  draggedClip?.started === true &&
@@ -308,11 +332,8 @@ export function TimelineLanes({
308
332
  getTimelineElementIdentity(draggedElement) === elementKey;
309
333
  if (isDraggingClip) return null;
310
334
  const previewElement = getPreviewElement(el);
311
- // Passenger of a live multi-drag: slide by the SAME formation
312
- // delta (the grabbed clip's group-clamped delta) via a
313
- // compositor transform on a same-geometry wrapper (absolute
314
- // inset-0 → identical offset parent, so the clip's own
315
- // left/top are preserved), plus the ghost's elevated z/opacity.
335
+ // Passenger of a live multi-drag: preserve the formation without changing
336
+ // the passenger's timeline data until the owning drag commits.
316
337
  const isPassenger =
317
338
  multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
318
339
  const passengerOffsetPx = isPassenger
@@ -337,6 +358,9 @@ export function TimelineLanes({
337
358
  capabilities={capabilities}
338
359
  theme={theme}
339
360
  isComposition={isComposition}
361
+ tabIndex={
362
+ keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
363
+ }
340
364
  onHoverStart={() => setHoveredClip(clipKey)}
341
365
  onHoverEnd={() => setHoveredClip(null)}
342
366
  onResizeStart={
@@ -350,6 +374,7 @@ export function TimelineLanes({
350
374
  setShowPopover(false);
351
375
  setRangeSelection(null);
352
376
  setResizingClip({
377
+ pointerId: e.pointerId,
353
378
  element: el,
354
379
  edge,
355
380
  originClientX: e.clientX,
@@ -388,6 +413,7 @@ export function TimelineLanes({
388
413
  (blockedIntent !== "move" && onResizeElement))
389
414
  ) {
390
415
  blockedClipRef.current = {
416
+ pointerId: e.pointerId,
391
417
  element: el,
392
418
  intent: blockedIntent,
393
419
  originClientX: e.clientX,
@@ -401,6 +427,7 @@ export function TimelineLanes({
401
427
  setShowPopover(false);
402
428
  setRangeSelection(null);
403
429
  setDraggedClip({
430
+ pointerId: e.pointerId,
404
431
  element: el,
405
432
  originClientX: e.clientX,
406
433
  originClientY: e.clientY,
@@ -418,7 +445,6 @@ export function TimelineLanes({
418
445
  snapType: null,
419
446
  started: false,
420
447
  });
421
- syncClipDragAutoScroll(e.clientX, e.clientY);
422
448
  }
423
449
  }
424
450
  onClick={
@@ -472,41 +498,33 @@ export function TimelineLanes({
472
498
  renderClipContent,
473
499
  renderClipOverlay,
474
500
  )}
475
- {!showsLanes && keyframeCache?.get(elementKey) && (
476
- <TimelineClipDiamonds
477
- keyframesData={keyframeCache.get(elementKey)!}
478
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
479
- clipHeightPx={rowHeight - 2 * CLIP_Y}
480
- clipDuration={previewElement.duration}
481
- beatsActive={beatStripOnTrack}
482
- accentColor={clipStyle.accent}
483
- isSelected={isSelected}
484
- currentPercentage={
485
- previewElement.duration > 0
486
- ? ((currentTime - previewElement.start) / previewElement.duration) *
487
- 100
488
- : 0
489
- }
490
- elementId={elementKey}
491
- selectedKeyframes={selectedKeyframes}
492
- onClickKeyframe={(_elId, target) =>
493
- onClickKeyframe?.(previewElement, target)
494
- }
495
- onShiftClickKeyframe={onShiftClickKeyframe}
496
- onContextMenuKeyframe={onContextMenuKeyframe}
497
- onMoveKeyframe={onMoveKeyframe}
498
- onSelectSegment={onSelectSegment}
499
- suppressClickRef={suppressClickRef}
500
- />
501
- )}
502
501
  </TimelineClip>
503
502
  );
504
- // Mounted for the track's keyframe clip in BOTH disclosure
505
- // states, so the header caret's aria-controls resolves while
506
- // collapsed too; collapsed just feeds it no animations, so
507
- // the wrapper renders empty. The key is stable across a
508
- // multi-drag: without it the passenger branch below remounts
509
- // this subtree and interrupts the gesture.
503
+ const compactKeyframes = keyframeCache?.get(elementKey);
504
+ const compactDiamonds = !showsLanes && compactKeyframes && (
505
+ <TimelineCompactDiamonds
506
+ key={`${clipKey}-diamonds`}
507
+ element={previewElement}
508
+ elementId={elementKey}
509
+ keyframesData={compactKeyframes}
510
+ pixelsPerSecond={pps}
511
+ rowHeight={rowHeight}
512
+ beatsActive={beatStripOnTrack}
513
+ accentColor={clipStyle.accent}
514
+ isSelected={isSelected}
515
+ currentTime={currentTime}
516
+ selectedKeyframes={selectedKeyframes}
517
+ rovingTargetId={keyboard.rovingTargetId}
518
+ onClickKeyframe={onClickKeyframe}
519
+ onShiftClickKeyframe={onShiftClickKeyframe}
520
+ onContextMenuKeyframe={onContextMenuKeyframe}
521
+ onMoveKeyframe={onMoveKeyframe}
522
+ onSelectSegment={onSelectSegment}
523
+ suppressClickRef={suppressClickRef}
524
+ />
525
+ );
526
+ // Keep this shell mounted while collapsed so aria-controls stays valid
527
+ // and multi-drag cannot remount the subtree mid-gesture.
510
528
  const propertyLanes = isTrackKeyframeClip && (
511
529
  <TimelinePropertyLanes
512
530
  key={`${clipKey}-property-lanes`}
@@ -528,6 +546,7 @@ export function TimelineLanes({
528
546
  }
529
547
  elementId={elementKey}
530
548
  selectedKeyframes={selectedKeyframes}
549
+ rovingTargetId={keyboard.rovingTargetId}
531
550
  onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
532
551
  onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
533
552
  onShiftClickKeyframe={(target) =>
@@ -543,14 +562,12 @@ export function TimelineLanes({
543
562
  suppressClickRef={suppressClickRef}
544
563
  />
545
564
  );
546
- // Keep one keyed top-level child per element. Returning an
547
- // array here makes React reconcile the outer array by
548
- // position, so a window shift remounts otherwise stable
549
- // clip keys and can tear down focus mid-reveal.
565
+ // One keyed child prevents window shifts from remounting focused clips.
550
566
  if (!isPassenger) {
551
567
  return (
552
568
  <Fragment key={clipKey}>
553
569
  {clip}
570
+ {compactDiamonds}
554
571
  {propertyLanes}
555
572
  </Fragment>
556
573
  );
@@ -567,6 +584,7 @@ export function TimelineLanes({
567
584
  }}
568
585
  >
569
586
  {clip}
587
+ {compactDiamonds}
570
588
  {propertyLanes}
571
589
  </div>
572
590
  );