@hyperframes/studio 0.8.58 → 0.8.60

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 (92) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
  2. package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
  3. package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
  4. package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
  5. package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
  6. package/dist/index.d.ts +549 -13
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +23133 -21941
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/StudioHeader.tsx +2 -13
  13. package/src/components/TimelineToolbar.test.tsx +42 -0
  14. package/src/components/TimelineToolbar.tsx +22 -8
  15. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  16. package/src/components/editor/audioInspectorInput.ts +55 -0
  17. package/src/components/editor/propertyPanelFill.tsx +3 -2
  18. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  19. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  20. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  22. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  23. package/src/components/editor/useColorGradingController.ts +2 -6
  24. package/src/components/icons/AudioMetersIcon.tsx +16 -0
  25. package/src/components/icons/InspectorIcon.tsx +18 -0
  26. package/src/components/icons/ReverseGradientIcon.tsx +23 -0
  27. package/src/components/icons/RippleEditIcon.tsx +19 -0
  28. package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
  29. package/src/components/nle/AudioMeterStrip.tsx +430 -0
  30. package/src/components/nle/TimelinePane.tsx +29 -23
  31. package/src/components/ui/Menu.test.tsx +3 -4
  32. package/src/components/ui/Menu.tsx +18 -5
  33. package/src/components/ui/Select.tsx +7 -1
  34. package/src/components/ui/Tooltip.tsx +6 -1
  35. package/src/contexts/TimelineEditContext.tsx +4 -0
  36. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  37. package/src/index.ts +16 -0
  38. package/src/player/components/AudioWaveform.tsx +16 -7
  39. package/src/player/components/ImageThumbnail.tsx +3 -5
  40. package/src/player/components/LayerDisclosureRow.tsx +9 -4
  41. package/src/player/components/PlayheadIndicator.tsx +3 -3
  42. package/src/player/components/Timeline.test.ts +22 -31
  43. package/src/player/components/Timeline.tsx +34 -7
  44. package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
  45. package/src/player/components/TimelineAutomationLane.tsx +5 -5
  46. package/src/player/components/TimelineCanvas.tsx +28 -71
  47. package/src/player/components/TimelineClip.test.tsx +41 -4
  48. package/src/player/components/TimelineClip.tsx +25 -5
  49. package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
  50. package/src/player/components/TimelineClipDiamonds.tsx +2 -2
  51. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  52. package/src/player/components/TimelineClipFades.tsx +302 -0
  53. package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
  54. package/src/player/components/TimelineEmptyState.tsx +1 -1
  55. package/src/player/components/TimelineFxButton.tsx +10 -8
  56. package/src/player/components/TimelineGroupHeader.tsx +18 -9
  57. package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
  58. package/src/player/components/TimelineGroupRow.tsx +1 -1
  59. package/src/player/components/TimelineLanes.test.tsx +14 -0
  60. package/src/player/components/TimelineOverlays.tsx +164 -165
  61. package/src/player/components/TimelineParts.test.tsx +101 -0
  62. package/src/player/components/TimelineParts.tsx +74 -0
  63. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  64. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  65. package/src/player/components/TimelineProvider.tsx +25 -0
  66. package/src/player/components/TimelineRazorInteraction.tsx +2 -2
  67. package/src/player/components/TimelineRuler.tsx +3 -2
  68. package/src/player/components/TimelineRulerPart.tsx +22 -0
  69. package/src/player/components/TimelineShortcutHint.tsx +2 -2
  70. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  71. package/src/player/components/TimelineTrackHeader.tsx +4 -6
  72. package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
  73. package/src/player/components/VideoThumbnail.tsx +3 -5
  74. package/src/player/components/timelineMotionStyles.test.ts +96 -30
  75. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  76. package/src/player/components/timelineTheme.test.ts +14 -0
  77. package/src/player/components/timelineTheme.ts +12 -11
  78. package/src/player/components/useTimelineProviderState.tsx +19 -12
  79. package/src/player/lib/timelineElementHelpers.ts +20 -0
  80. package/src/player/store/timelineElement.ts +5 -0
  81. package/src/styles/hex-baseline.json +4 -23
  82. package/src/styles/studio.css +43 -47
  83. package/src/styles/theme.css +109 -1
  84. package/src/styles/theme.test.ts +9 -0
  85. package/src/utils/audioMeterMath.test.ts +44 -0
  86. package/src/utils/audioMeterMath.ts +79 -0
  87. package/src/utils/audioMeterVisibility.ts +14 -0
  88. package/src/utils/studioHelpers.ts +21 -0
  89. package/src/utils/studioUiPreferences.test.ts +10 -0
  90. package/src/utils/studioUiPreferences.ts +5 -0
  91. package/src/utils/timelineAssetDrop.test.ts +32 -0
  92. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -55,21 +55,24 @@ function GroupNameButton({
55
55
  // No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
56
56
  // claiming it here would push the controls off the right edge — and the
57
57
  // count with them, since it rides inside this button.
58
- className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
58
+ className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-[var(--timeline-text-solid)] hover:text-[var(--timeline-accent)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
59
59
  onPointerDown={(event) => event.stopPropagation()}
60
60
  onClick={(event) => {
61
61
  event.stopPropagation();
62
62
  onOpenFxRack();
63
63
  }}
64
64
  >
65
- <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
65
+ <span
66
+ aria-hidden="true"
67
+ className="shrink-0 text-[12px] leading-none text-[var(--timeline-track-label)]"
68
+ >
66
69
  ▤
67
70
  </span>
68
71
  <span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
69
72
  {label}
70
73
  </span>
71
74
  <span
72
- className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
75
+ className="shrink-0 rounded-full bg-[var(--timeline-tick-major)] px-1 text-[9px] leading-[14px] tabular-nums text-[var(--timeline-handle)]"
73
76
  aria-hidden="true"
74
77
  title={`${memberCount} tracks`}
75
78
  >
@@ -103,7 +106,7 @@ export function TimelineGroupHeader({
103
106
  style={{
104
107
  width: columnWidth,
105
108
  height: TRACK_H,
106
- color: "#ffffff",
109
+ color: "var(--timeline-text-solid)",
107
110
  background: theme.gutterBackground,
108
111
  borderRight: `1px solid ${theme.gutterBorder}`,
109
112
  }}
@@ -119,8 +122,10 @@ export function TimelineGroupHeader({
119
122
  // 13px mono, matching the property panel's preset-run caret
120
123
  // (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
121
124
  // at the same size rather than a smaller one unique to this row.
122
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
123
- isExpanded ? "text-white" : "text-white/55 hover:text-white"
125
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
126
+ isExpanded
127
+ ? "text-[var(--timeline-text-solid)]"
128
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
124
129
  }`}
125
130
  onPointerDown={(event) => event.stopPropagation()}
126
131
  onClick={(event) => {
@@ -157,8 +162,10 @@ export function TimelineGroupHeader({
157
162
  aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
158
163
  title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
159
164
  // Anchored right, matching every other header's lane toggle.
160
- className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
161
- isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
165
+ className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
166
+ isLaneOpen
167
+ ? "text-[var(--timeline-accent)]"
168
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
162
169
  }`}
163
170
  onPointerDown={(event) => event.stopPropagation()}
164
171
  onClick={(event) => {
@@ -167,7 +174,9 @@ export function TimelineGroupHeader({
167
174
  }}
168
175
  >
169
176
  <span aria-hidden="true">∿</span>
170
- <span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
177
+ <span className="text-[9px] tabular-nums text-[var(--timeline-handle)]">
178
+ {laneCount}
179
+ </span>
171
180
  </button>
172
181
  )}
173
182
  </div>
@@ -64,7 +64,7 @@ export function TimelineGroupLaneLabels({
64
64
  key={lane.target}
65
65
  data-group-lane-label={lane.target}
66
66
  aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67
- className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
67
+ className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
68
68
  style={{
69
69
  top: top + index * AUTOMATION_LANE_H,
70
70
  width: columnWidth,
@@ -79,16 +79,23 @@ export function TimelineGroupLaneLabels({
79
79
  onReveal?.(lane.target);
80
80
  }}
81
81
  >
82
- <span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
82
+ <span
83
+ aria-hidden="true"
84
+ className="shrink-0 text-[11px] text-[var(--timeline-text-dim)]"
85
+ >
83
86
  ▤
84
87
  </span>
85
88
  <span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
86
- <span className="truncate font-mono text-[9px] text-white/70">{parts.name}</span>
89
+ <span className="truncate font-mono text-[9px] text-[var(--timeline-text-bright)]">
90
+ {parts.name}
91
+ </span>
87
92
  {parts.param ? (
88
- <span className="truncate font-mono text-[9px] text-white/40">{parts.param}</span>
93
+ <span className="truncate font-mono text-[9px] text-[var(--timeline-text-dim)]">
94
+ {parts.param}
95
+ </span>
89
96
  ) : null}
90
97
  </span>
91
- <span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
98
+ <span className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--timeline-handle)]">
92
99
  {value.toFixed(2)}
93
100
  </span>
94
101
  </button>
@@ -21,7 +21,7 @@ import { usePlayerStore } from "../store/playerStore";
21
21
 
22
22
  /** Accent rail on a group-owned lane — the same green the member rail uses, so
23
23
  * "this belongs to the group" reads the same in both places (groups doc §5). */
24
- const GROUP_LANE_ACCENT = "#3CE6AC";
24
+ const GROUP_LANE_ACCENT = "var(--timeline-accent)";
25
25
 
26
26
  interface TimelineGroupRowProps {
27
27
  index: number;
@@ -432,6 +432,20 @@ describe("TimelineLanes selection", () => {
432
432
  });
433
433
 
434
434
  describe("TimelineLanes clip thumbnails", () => {
435
+ it("keeps thumbnail content inside a selected clip", () => {
436
+ const selected = element("clip-a", TRACK_A);
437
+ const view = renderLanes({
438
+ elements: [selected],
439
+ selectedElementIds: new Set([selected.id]),
440
+ renderClipContent: () => <div className="absolute inset-0 bg-neutral-900" data-thumbnail />,
441
+ });
442
+
443
+ const clip = view.host.querySelector('[data-el-id="clip-a"]');
444
+ expect(clip?.classList.contains("is-selected")).toBe(true);
445
+ expect(clip?.querySelector("[data-thumbnail]")).not.toBeNull();
446
+ act(() => view.root.unmount());
447
+ });
448
+
435
449
  it("asks for the same frames at rest, hovered and selected", () => {
436
450
  const rich: unknown[] = [];
437
451
  const renderClipContent = vi.fn(
@@ -4,7 +4,7 @@ import { EditPopover } from "./EditModal";
4
4
  import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
5
5
  import { ClipContextMenu } from "./ClipContextMenu";
6
6
  import { TrackGapContextMenu } from "./TrackGapContextMenu";
7
- import { TimelineShortcutHint } from "./TimelineShortcutHint";
7
+ import { TimelineShortcutHint as TimelineShortcutHintImpl } from "./TimelineShortcutHint";
8
8
  import { copyTextToClipboard } from "../../utils/clipboard";
9
9
  import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
10
10
  import { useTimelineContext } from "./TimelineProvider";
@@ -17,7 +17,6 @@ interface TimelineContextTargetInput {
17
17
  elements: readonly TimelineElement[];
18
18
  }
19
19
 
20
- /** The captured project session and current selection jointly own a context target. */
21
20
  export function resolveTimelineContextElement({
22
21
  capturedElement,
23
22
  targetSessionEpoch,
@@ -31,192 +30,192 @@ export function resolveTimelineContextElement({
31
30
  return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
32
31
  }
33
32
 
34
- // The timeline's floating overlays, rendered as siblings above the scroll area:
35
- // the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
36
- // and the clip context menu.
37
- export function TimelineOverlays() {
33
+ export function TimelineShortcutHintOverlay() {
34
+ const { state } = useTimelineContext();
35
+ const { showShortcutHint, showPopover, rangeSelection, theme } = state.overlays;
36
+ if (!showShortcutHint || showPopover || rangeSelection) return null;
37
+ return <TimelineShortcutHintImpl theme={theme} />;
38
+ }
39
+
40
+ export function TimelineEditPopoverOverlay() {
41
+ const { state } = useTimelineContext();
42
+ const { showPopover, rangeSelection, setShowPopover, setRangeSelection } = state.overlays;
43
+ if (!showPopover || !rangeSelection) return null;
44
+ return (
45
+ <EditPopover
46
+ rangeStart={rangeSelection.start}
47
+ rangeEnd={rangeSelection.end}
48
+ anchorX={rangeSelection.anchorX}
49
+ anchorY={rangeSelection.anchorY}
50
+ onClose={() => {
51
+ setShowPopover(false);
52
+ setRangeSelection(null);
53
+ }}
54
+ />
55
+ );
56
+ }
57
+
58
+ export function TimelineKeyframeMenuOverlay() {
38
59
  const { state, actions } = useTimelineContext();
39
- const overlayProps = state.overlays;
40
- const {
41
- elements,
42
- elementsRef,
43
- theme,
44
- showShortcutHint,
45
- showPopover,
46
- rangeSelection,
47
- setShowPopover,
48
- setRangeSelection,
49
- kfContextMenu,
50
- setKfContextMenu,
51
- onDeleteKeyframe,
52
- onDeleteAllKeyframes,
53
- onMoveKeyframeToPlayhead,
54
- clipContextMenu,
55
- setClipContextMenu,
56
- currentTime,
57
- onSplitElement,
58
- pinZoomBeforeEdit,
59
- onDeleteElement,
60
- onCopyClip,
61
- onPasteClip,
62
- onDuplicateClip,
63
- canPasteClip,
64
- gapContextMenu,
65
- onDismissGapContextMenu,
66
- onCloseTrackGap,
67
- onCloseAllTrackGaps,
68
- onHoverGapAction,
69
- } = overlayProps;
60
+ const overlay = state.overlays;
61
+ const { kfContextMenu, setKfContextMenu } = overlay;
70
62
  const { selectedElementId, sessionEpoch, keyframeCache } = state;
71
- const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
72
- const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
73
- const keyframeElement = kfContextMenu
63
+ const targetEpoch = kfContextMenu?.sessionEpoch;
64
+ const element = kfContextMenu
74
65
  ? resolveTimelineContextElement({
75
66
  capturedElement: kfContextMenu.element,
76
- targetSessionEpoch: kfTargetSessionEpoch,
67
+ targetSessionEpoch: targetEpoch,
77
68
  sessionEpoch,
78
69
  selectedElementId,
79
- elements,
70
+ elements: overlay.elements,
80
71
  })
81
72
  : null;
82
- const clipElement = clipContextMenu
73
+ useEffect(() => {
74
+ if (kfContextMenu && !element) setKfContextMenu(null);
75
+ }, [element, kfContextMenu, setKfContextMenu]);
76
+ if (!kfContextMenu || !element) return null;
77
+ const readCurrentElement = () =>
78
+ resolveTimelineContextElement({
79
+ capturedElement: element,
80
+ targetSessionEpoch: targetEpoch,
81
+ sessionEpoch,
82
+ selectedElementId,
83
+ elements: overlay.elementsRef.current,
84
+ });
85
+ const menu = kfContextMenu;
86
+ return (
87
+ <KeyframeDiamondContextMenu
88
+ state={{ ...menu, element }}
89
+ onClose={() => setKfContextMenu(null)}
90
+ onDelete={(...args) => {
91
+ if (readCurrentElement()) overlay.onDeleteKeyframe?.(...args);
92
+ }}
93
+ onDeleteAll={(_element, animationId) => {
94
+ const current = readCurrentElement();
95
+ if (current) overlay.onDeleteAllKeyframes?.(current, animationId);
96
+ }}
97
+ onMoveToPlayhead={
98
+ overlay.onMoveKeyframeToPlayhead
99
+ ? (_element, ...args) => {
100
+ const current = readCurrentElement();
101
+ if (current) overlay.onMoveKeyframeToPlayhead?.(current, ...args);
102
+ }
103
+ : undefined
104
+ }
105
+ onEditEase={
106
+ // Routed to the same focused-ease-segment path a segment click takes,
107
+ // so the menu advertises the editor that exists rather than growing a
108
+ // second one. Offered only for a keyframe that names a tween to focus.
109
+ menu.animationId !== undefined && menu.tweenPercentage !== undefined
110
+ ? (elementId, keyframe) => {
111
+ if (keyframe.animationId === undefined || keyframe.tweenPercentage === undefined)
112
+ return;
113
+ actions.setFocusedEaseSegment({
114
+ animationId: keyframe.animationId,
115
+ collidingAnimationTargets: keyframe.collidingAnimationTargets,
116
+ tweenPercentage: keyframe.tweenPercentage,
117
+ elementId,
118
+ });
119
+ trackStudioSegmentEaseEdit({ action: "open" });
120
+ }
121
+ : undefined
122
+ }
123
+ onCopyProperties={(elementId, keyframe) => {
124
+ const entry = keyframeCache.get(elementId);
125
+ // Match the existing keyframe lookup tolerance so copied properties
126
+ // follow the same nearby-keyframe selection as the editor.
127
+ const keyframeValue = entry?.keyframes.find(
128
+ (item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
129
+ );
130
+ if (!keyframeValue) return false;
131
+ return copyTextToClipboard(JSON.stringify(keyframeValue.properties, null, 2));
132
+ }}
133
+ />
134
+ );
135
+ }
136
+
137
+ export function TimelineClipMenuOverlay() {
138
+ const { state } = useTimelineContext();
139
+ const overlay = state.overlays;
140
+ const { clipContextMenu, setClipContextMenu } = overlay;
141
+ const { selectedElementId, sessionEpoch } = state;
142
+ const targetEpoch = clipContextMenu?.sessionEpoch;
143
+ const element = clipContextMenu
83
144
  ? resolveTimelineContextElement({
84
145
  capturedElement: clipContextMenu.element,
85
- targetSessionEpoch: clipTargetSessionEpoch,
146
+ targetSessionEpoch: targetEpoch,
86
147
  sessionEpoch,
87
148
  selectedElementId,
88
- elements,
149
+ elements: overlay.elements,
89
150
  })
90
151
  : null;
91
- const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
152
+ useEffect(() => {
153
+ if (clipContextMenu && !element) setClipContextMenu(null);
154
+ }, [element, clipContextMenu, setClipContextMenu]);
155
+ if (!clipContextMenu || !element) return null;
156
+ const readCurrentElement = () =>
92
157
  resolveTimelineContextElement({
93
158
  capturedElement: element,
94
- targetSessionEpoch,
159
+ targetSessionEpoch: targetEpoch,
95
160
  sessionEpoch,
96
161
  selectedElementId,
97
- elements: elementsRef.current,
162
+ elements: overlay.elementsRef.current,
98
163
  });
164
+ const menu = clipContextMenu;
165
+ return (
166
+ <ClipContextMenu
167
+ x={menu.x}
168
+ y={menu.y}
169
+ element={element}
170
+ currentTime={overlay.currentTime}
171
+ onClose={() => setClipContextMenu(null)}
172
+ onSplit={(_element, time) => {
173
+ const current = readCurrentElement();
174
+ if (current) overlay.onSplitElement?.(current, time);
175
+ }}
176
+ onDelete={() => {
177
+ const current = readCurrentElement();
178
+ if (!current) return;
179
+ overlay.pinZoomBeforeEdit();
180
+ overlay.onDeleteElement?.(current);
181
+ }}
182
+ onCopy={overlay.onCopyClip}
183
+ onPaste={overlay.onPasteClip}
184
+ onDuplicate={overlay.onDuplicateClip}
185
+ canPaste={overlay.canPasteClip?.() ?? false}
186
+ />
187
+ );
188
+ }
99
189
 
100
- useEffect(() => {
101
- if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
102
- }, [keyframeElement, kfContextMenu, setKfContextMenu]);
103
-
104
- useEffect(() => {
105
- if (clipContextMenu && !clipElement) setClipContextMenu(null);
106
- }, [clipContextMenu, clipElement, setClipContextMenu]);
190
+ export function TimelineGapMenuOverlay() {
191
+ const { state } = useTimelineContext();
192
+ const menu = state.overlays.gapContextMenu;
193
+ const overlay = state.overlays;
194
+ if (!menu) return null;
195
+ return (
196
+ <TrackGapContextMenu
197
+ x={menu.x}
198
+ y={menu.y}
199
+ gapWidth={menu.gapWidth}
200
+ canCloseGap={menu.canCloseGap}
201
+ canCloseAllGaps={menu.canCloseAllGaps}
202
+ hasAnyGaps={menu.hasAnyGaps}
203
+ onClose={overlay.onDismissGapContextMenu}
204
+ onCloseGap={overlay.onCloseTrackGap}
205
+ onCloseAllGaps={overlay.onCloseAllTrackGaps}
206
+ onHoverAction={overlay.onHoverGapAction}
207
+ />
208
+ );
209
+ }
107
210
 
211
+ export function TimelineOverlays() {
108
212
  return (
109
213
  <>
110
- {showShortcutHint && !showPopover && !rangeSelection && (
111
- <TimelineShortcutHint theme={theme} />
112
- )}
113
-
114
- {showPopover && rangeSelection && (
115
- <EditPopover
116
- rangeStart={rangeSelection.start}
117
- rangeEnd={rangeSelection.end}
118
- anchorX={rangeSelection.anchorX}
119
- anchorY={rangeSelection.anchorY}
120
- onClose={() => {
121
- setShowPopover(false);
122
- setRangeSelection(null);
123
- }}
124
- />
125
- )}
126
-
127
- {kfContextMenu && keyframeElement && (
128
- <KeyframeDiamondContextMenu
129
- state={{ ...kfContextMenu, element: keyframeElement }}
130
- onClose={() => setKfContextMenu(null)}
131
- onDelete={(...args) => {
132
- if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
133
- onDeleteKeyframe?.(...args);
134
- }}
135
- onDeleteAll={(_element, animationId) => {
136
- const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
137
- if (element) onDeleteAllKeyframes?.(element, animationId);
138
- }}
139
- onMoveToPlayhead={
140
- onMoveKeyframeToPlayhead
141
- ? (_element, ...args) => {
142
- const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
143
- if (element) onMoveKeyframeToPlayhead(element, ...args);
144
- }
145
- : undefined
146
- }
147
- // Routed to the same focused-ease-segment path a segment click takes,
148
- // so the menu advertises the editor that exists rather than growing a
149
- // second one. Offered only for a keyframe that names a tween to focus.
150
- onEditEase={
151
- kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
152
- ? (elementId, keyframe) => {
153
- if (
154
- keyframe.animationId === undefined ||
155
- keyframe.tweenPercentage === undefined
156
- ) {
157
- return;
158
- }
159
- actions.setFocusedEaseSegment({
160
- animationId: keyframe.animationId,
161
- collidingAnimationTargets: keyframe.collidingAnimationTargets,
162
- tweenPercentage: keyframe.tweenPercentage,
163
- elementId,
164
- });
165
- trackStudioSegmentEaseEdit({ action: "open" });
166
- }
167
- : undefined
168
- }
169
- onCopyProperties={(elementId, keyframe) => {
170
- const entry = keyframeCache.get(elementId);
171
- // Tolerance match on clip-%, the same basis the cache is keyed on —
172
- // an exact float compare misses a keyframe the menu just opened over.
173
- const kf = entry?.keyframes.find(
174
- (item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
175
- );
176
- if (!kf) return false;
177
- return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
178
- }}
179
- />
180
- )}
181
-
182
- {clipContextMenu && clipElement && (
183
- <ClipContextMenu
184
- x={clipContextMenu.x}
185
- y={clipContextMenu.y}
186
- element={clipElement}
187
- currentTime={currentTime}
188
- onClose={() => setClipContextMenu(null)}
189
- onSplit={(_element, time) => {
190
- const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
191
- if (element) onSplitElement?.(element, time);
192
- }}
193
- onDelete={() => {
194
- const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
195
- if (!element) return;
196
- pinZoomBeforeEdit();
197
- onDeleteElement?.(element);
198
- }}
199
- onCopy={onCopyClip}
200
- onPaste={onPasteClip}
201
- onDuplicate={onDuplicateClip}
202
- canPaste={canPasteClip?.() ?? false}
203
- />
204
- )}
205
-
206
- {gapContextMenu && (
207
- <TrackGapContextMenu
208
- x={gapContextMenu.x}
209
- y={gapContextMenu.y}
210
- gapWidth={gapContextMenu.gapWidth}
211
- canCloseGap={gapContextMenu.canCloseGap}
212
- canCloseAllGaps={gapContextMenu.canCloseAllGaps}
213
- hasAnyGaps={gapContextMenu.hasAnyGaps}
214
- onClose={onDismissGapContextMenu}
215
- onCloseGap={onCloseTrackGap}
216
- onCloseAllGaps={onCloseAllTrackGaps}
217
- onHoverAction={onHoverGapAction}
218
- />
219
- )}
214
+ <TimelineShortcutHintOverlay />
215
+ <TimelineEditPopoverOverlay />
216
+ <TimelineKeyframeMenuOverlay />
217
+ <TimelineClipMenuOverlay />
218
+ <TimelineGapMenuOverlay />
220
219
  </>
221
220
  );
222
221
  }
@@ -0,0 +1,101 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { TimelineContextValue } from "./TimelineProvider";
5
+ import { TimelineContextProvider, useTimelineContext } from "./TimelineProvider";
6
+ import { TimelineClipMenu, TimelineLanes, TimelinePlayhead, TimelineRuler } from "./TimelineParts";
7
+ import { createHappyDomRootHarness } from "./testRootHarness";
8
+
9
+ vi.mock("./TimelineRuler", () => ({
10
+ TimelineRuler: () => <div data-testid="timeline-ruler" />,
11
+ }));
12
+ vi.mock("./TimelineLanes", () => ({
13
+ TimelineLanes: ({ renderClipContent }: { renderClipContent?: unknown }) => {
14
+ const { state } = useTimelineContext();
15
+ return (
16
+ <div data-testid="timeline-clip">
17
+ {state.elements[0]?.id}
18
+ {renderClipContent ? <span data-testid="lane-render-bridge" /> : null}
19
+ </div>
20
+ );
21
+ },
22
+ }));
23
+
24
+ const { mount } = createHappyDomRootHarness();
25
+
26
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27
+
28
+ afterEach(() => {
29
+ document.body.innerHTML = "";
30
+ });
31
+
32
+ describe("Timeline parts composition", () => {
33
+ it("reads a shared provider context when composed without Timeline or Frame", () => {
34
+ const host = document.createElement("div");
35
+ document.body.append(host);
36
+ const root = mount(host);
37
+ const onSplitElement = vi.fn();
38
+ const element = { id: "clip-a", key: "clip-a", tag: "video", start: 0, duration: 2, track: 0 };
39
+ const value = {
40
+ state: {
41
+ timelineReady: true,
42
+ elements: [element],
43
+ selectedElementId: "clip-a",
44
+ sessionEpoch: 1,
45
+ keyframeCache: new Map(),
46
+ canvas: { isScrubbing: false } as TimelineContextValue["state"]["canvas"],
47
+ overlays: {
48
+ elements: [element],
49
+ elementsRef: { current: [element] },
50
+ theme: {} as TimelineContextValue["state"]["overlays"]["theme"],
51
+ showShortcutHint: false,
52
+ showPopover: false,
53
+ rangeSelection: null,
54
+ setShowPopover: vi.fn(),
55
+ setRangeSelection: vi.fn(),
56
+ kfContextMenu: null,
57
+ setKfContextMenu: vi.fn(),
58
+ onDeleteKeyframe: vi.fn(),
59
+ onDeleteAllKeyframes: vi.fn(),
60
+ onMoveKeyframeToPlayhead: vi.fn(),
61
+ clipContextMenu: { x: 10, y: 10, element, sessionEpoch: 1 },
62
+ setClipContextMenu: vi.fn(),
63
+ currentTime: 1,
64
+ onSplitElement,
65
+ pinZoomBeforeEdit: vi.fn(),
66
+ gapContextMenu: null,
67
+ onDismissGapContextMenu: vi.fn(),
68
+ onCloseTrackGap: vi.fn(),
69
+ onCloseAllTrackGaps: vi.fn(),
70
+ onHoverGapAction: vi.fn(),
71
+ },
72
+ },
73
+ actions: {
74
+ renderClipContent: vi.fn(),
75
+ renderClipOverlay: undefined,
76
+ setFocusedEaseSegment: vi.fn(),
77
+ },
78
+ meta: { razorGuide: null } as TimelineContextValue["meta"],
79
+ } satisfies TimelineContextValue;
80
+
81
+ try {
82
+ act(() => {
83
+ root.render(
84
+ <TimelineContextProvider value={value}>
85
+ <TimelineRuler />
86
+ <TimelineLanes />
87
+ <TimelinePlayhead />
88
+ <TimelineClipMenu />
89
+ </TimelineContextProvider>,
90
+ );
91
+ });
92
+
93
+ expect(host.querySelector("[data-testid='timeline-ruler']")).not.toBeNull();
94
+ expect(host.querySelector("[data-testid='timeline-clip']")?.textContent).toBe("clip-a");
95
+ expect(host.querySelector("[data-testid='lane-render-bridge']")).not.toBeNull();
96
+ expect(document.querySelector("[role='menu'][aria-label='Clip actions']")).not.toBeNull();
97
+ } finally {
98
+ act(() => root.unmount());
99
+ }
100
+ });
101
+ });