@hyperframes/studio 0.8.57 → 0.8.59

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 (68) hide show
  1. package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +577 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +22537 -21600
  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/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
  14. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  15. package/src/components/editor/audioInspectorInput.ts +55 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  17. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  18. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  19. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  20. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  21. package/src/components/editor/useColorGradingController.ts +2 -6
  22. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  23. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  24. package/src/components/nle/NLEContext.tsx +8 -2
  25. package/src/components/nle/NLEPreview.test.ts +1 -0
  26. package/src/components/nle/TimelinePane.tsx +29 -23
  27. package/src/components/nle/useCompositionStack.ts +1 -1
  28. package/src/contexts/TimelineEditContext.tsx +4 -0
  29. package/src/hooks/useCompositionDimensions.test.tsx +0 -1
  30. package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
  31. package/src/hooks/useSdkSession.ts +31 -4
  32. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  33. package/src/index.ts +25 -1
  34. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  35. package/src/player/components/Player.test.ts +34 -0
  36. package/src/player/components/Timeline.test.ts +22 -31
  37. package/src/player/components/Timeline.tsx +34 -7
  38. package/src/player/components/TimelineCanvas.tsx +10 -56
  39. package/src/player/components/TimelineClip.tsx +14 -1
  40. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  41. package/src/player/components/TimelineClipFades.tsx +302 -0
  42. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  43. package/src/player/components/TimelineOverlays.tsx +164 -165
  44. package/src/player/components/TimelineParts.test.tsx +101 -0
  45. package/src/player/components/TimelineParts.tsx +74 -0
  46. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  47. package/src/player/components/TimelineProvider.tsx +25 -0
  48. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  49. package/src/player/components/TimelineRulerPart.tsx +22 -0
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  52. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  53. package/src/player/components/useTimelineProviderState.tsx +19 -12
  54. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  55. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  56. package/src/player/hooks/usePlayerHandle.ts +94 -0
  57. package/src/player/index.ts +7 -0
  58. package/src/player/lib/timelineElementHelpers.ts +20 -0
  59. package/src/player/store/timelineElement.ts +5 -0
  60. package/src/styles/theme.css +4 -0
  61. package/src/utils/audioMeterMath.test.ts +44 -0
  62. package/src/utils/audioMeterMath.ts +79 -0
  63. package/src/utils/audioMeterVisibility.ts +14 -0
  64. package/src/utils/studioHelpers.ts +21 -0
  65. package/src/utils/studioUiPreferences.test.ts +10 -0
  66. package/src/utils/studioUiPreferences.ts +5 -0
  67. package/src/utils/timelineAssetDrop.test.ts +32 -0
  68. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -0,0 +1,302 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type CSSProperties,
6
+ type KeyboardEvent,
7
+ type PointerEvent,
8
+ } from "react";
9
+ import {
10
+ HF_AUDIO_FADE_IN_ATTR,
11
+ HF_AUDIO_FADE_OUT_ATTR,
12
+ clampFadesToDuration,
13
+ formatFadeSeconds,
14
+ } from "@hyperframes/core/audio-fade";
15
+ import type { TimelineElement } from "../store/playerStore";
16
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
17
+
18
+ type FadeEdge = "in" | "out";
19
+
20
+ const HANDLE_SIZE = 10;
21
+ const HANDLE_HIT = 16;
22
+ /** Pixels of pointer travel before a press on the handle counts as a drag. */
23
+ const DRAG_THRESHOLD_PX = 2;
24
+
25
+ interface TimelineClipFadesProps {
26
+ el: TimelineElement;
27
+ pps: number;
28
+ widthPx: number;
29
+ /** Handles show on hover/selection; the shaded ramps show whenever a fade is set. */
30
+ showHandles: boolean;
31
+ }
32
+
33
+ /** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
34
+ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
35
+ const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
36
+ useTimelineEditContextOptional();
37
+ const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
38
+
39
+ // Draft holds the value under the pointer during a drag; it also bridges
40
+ // the gap between release and the store re-reading the file, so the ramp
41
+ // does not snap back for a frame.
42
+ const [draft, setDraft] = useState<{ edge: FadeEdge; seconds: number } | null>(null);
43
+ const [dragging, setDragging] = useState<FadeEdge | null>(null);
44
+ const authoredIn = el.fadeIn ?? 0;
45
+ const authoredOut = el.fadeOut ?? 0;
46
+ useEffect(() => setDraft(null), [authoredIn, authoredOut]);
47
+
48
+ const fades = clampFadesToDuration(
49
+ {
50
+ fadeIn: draft?.edge === "in" ? draft.seconds : authoredIn,
51
+ fadeOut: draft?.edge === "out" ? draft.seconds : authoredOut,
52
+ },
53
+ el.duration,
54
+ );
55
+ const inPx = Math.min(widthPx, fades.fadeIn * pps);
56
+ const outPx = Math.min(widthPx, fades.fadeOut * pps);
57
+
58
+ const gesture = useRef<{
59
+ edge: FadeEdge;
60
+ pointerId: number;
61
+ originClientX: number;
62
+ originSeconds: number;
63
+ otherSeconds: number;
64
+ moved: boolean;
65
+ last: number;
66
+ } | null>(null);
67
+
68
+ const attrFor = (edge: FadeEdge) =>
69
+ edge === "in" ? HF_AUDIO_FADE_IN_ATTR : HF_AUDIO_FADE_OUT_ATTR;
70
+ const attrText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : null);
71
+
72
+ const secondsAt = (clientX: number): number => {
73
+ const g = gesture.current;
74
+ if (!g) return 0;
75
+ const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
76
+ // Fade-in grows to the right, fade-out grows to the left.
77
+ const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
78
+ const limit = Math.max(0, el.duration - g.otherSeconds);
79
+ const clamped = Math.min(limit, Math.max(0, raw));
80
+ return Math.round(clamped * 100) / 100;
81
+ };
82
+
83
+ const onHandlePointerDown = (edge: FadeEdge) => (e: PointerEvent<HTMLDivElement>) => {
84
+ if (e.button !== 0 || !canEdit) return;
85
+ // Ours, not the clip's: a press here must not start a move or trim.
86
+ e.stopPropagation();
87
+ e.preventDefault();
88
+ e.currentTarget.setPointerCapture(e.pointerId);
89
+ gesture.current = {
90
+ edge,
91
+ pointerId: e.pointerId,
92
+ originClientX: e.clientX,
93
+ originSeconds: edge === "in" ? authoredIn : authoredOut,
94
+ otherSeconds: edge === "in" ? authoredOut : authoredIn,
95
+ moved: false,
96
+ last: edge === "in" ? authoredIn : authoredOut,
97
+ };
98
+ setDragging(edge);
99
+ };
100
+
101
+ const onHandlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
102
+ const g = gesture.current;
103
+ if (!g || g.pointerId !== e.pointerId) return;
104
+ if (!g.moved && Math.abs(e.clientX - g.originClientX) < DRAG_THRESHOLD_PX) return;
105
+ g.moved = true;
106
+ const seconds = secondsAt(e.clientX);
107
+ if (seconds === g.last) return;
108
+ g.last = seconds;
109
+ setDraft({ edge: g.edge, seconds });
110
+ onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(seconds));
111
+ };
112
+
113
+ type Gesture = NonNullable<typeof gesture.current>;
114
+
115
+ /** Ends the pointer gesture and returns it, or null when the event is not ours. */
116
+ const endGesture = (e: PointerEvent<HTMLDivElement>): Gesture | null => {
117
+ const g = gesture.current;
118
+ if (!g || g.pointerId !== e.pointerId) return null;
119
+ gesture.current = null;
120
+ setDragging(null);
121
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
122
+ e.currentTarget.releasePointerCapture(e.pointerId);
123
+ }
124
+ return g;
125
+ };
126
+
127
+ /** Puts the live document back where the file has it and drops the draft. */
128
+ const revertGesture = (g: Gesture) => {
129
+ if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
130
+ setDraft(null);
131
+ };
132
+
133
+ const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
134
+ const g = endGesture(e);
135
+ if (!g) return;
136
+ if (cancelled || !g.moved) return revertGesture(g);
137
+ setDraft({ edge: g.edge, seconds: g.last });
138
+ void onSetElementAttributeQuiet?.(
139
+ el,
140
+ attrFor(g.edge),
141
+ attrText(g.last),
142
+ g.edge === "in" ? "Fade in" : "Fade out",
143
+ );
144
+ };
145
+
146
+ const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
147
+ const g = gesture.current;
148
+ if (e.key !== "Escape" || !g) return;
149
+ e.preventDefault();
150
+ gesture.current = null;
151
+ setDragging(null);
152
+ revertGesture(g);
153
+ };
154
+
155
+ const showIn = fades.fadeIn > 0;
156
+ const showOut = fades.fadeOut > 0;
157
+ const handlesVisible = showHandles || dragging !== null;
158
+ if (!showIn && !showOut && !handlesVisible) return null;
159
+
160
+ const handleStyle = (leftPx: number): CSSProperties => ({
161
+ position: "absolute",
162
+ top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
163
+ left: leftPx - HANDLE_HIT / 2,
164
+ width: HANDLE_HIT,
165
+ height: HANDLE_HIT,
166
+ display: "flex",
167
+ alignItems: "center",
168
+ justifyContent: "center",
169
+ cursor: "ew-resize",
170
+ zIndex: 6,
171
+ opacity: handlesVisible ? 1 : 0,
172
+ pointerEvents: handlesVisible && canEdit ? "auto" : "none",
173
+ touchAction: "none",
174
+ outline: "none",
175
+ });
176
+
177
+ return (
178
+ <>
179
+ {(showIn || showOut) && (
180
+ <svg
181
+ aria-hidden="true"
182
+ data-testid="clip-fade-ramps"
183
+ style={{
184
+ position: "absolute",
185
+ inset: 0,
186
+ width: "100%",
187
+ height: "100%",
188
+ pointerEvents: "none",
189
+ zIndex: 5,
190
+ overflow: "visible",
191
+ }}
192
+ viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
193
+ preserveAspectRatio="none"
194
+ >
195
+ {/* The shaded wedge is the gain that is NOT there: above the ramp. */}
196
+ {showIn && (
197
+ <>
198
+ <polygon
199
+ data-testid="clip-fade-in"
200
+ points={`0,0 ${inPx},0 0,100`}
201
+ fill="var(--timeline-fade-shade)"
202
+ />
203
+ <line
204
+ x1={0}
205
+ y1={100}
206
+ x2={inPx}
207
+ y2={0}
208
+ stroke="var(--clip-handle)"
209
+ strokeOpacity={0.9}
210
+ strokeWidth={1.25}
211
+ vectorEffect="non-scaling-stroke"
212
+ />
213
+ </>
214
+ )}
215
+ {showOut && (
216
+ <>
217
+ <polygon
218
+ data-testid="clip-fade-out"
219
+ points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
220
+ fill="var(--timeline-fade-shade)"
221
+ />
222
+ <line
223
+ x1={widthPx - outPx}
224
+ y1={0}
225
+ x2={widthPx}
226
+ y2={100}
227
+ stroke="var(--clip-handle)"
228
+ strokeOpacity={0.9}
229
+ strokeWidth={1.25}
230
+ vectorEffect="non-scaling-stroke"
231
+ />
232
+ </>
233
+ )}
234
+ </svg>
235
+ )}
236
+ {canEdit && (
237
+ <>
238
+ <div
239
+ role="slider"
240
+ tabIndex={-1}
241
+ aria-label="Fade in"
242
+ aria-valuemin={0}
243
+ aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
244
+ aria-valuenow={fades.fadeIn}
245
+ aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
246
+ data-testid="clip-fade-handle-in"
247
+ title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
248
+ style={handleStyle(inPx)}
249
+ onPointerDown={onHandlePointerDown("in")}
250
+ onPointerMove={onHandlePointerMove}
251
+ onPointerUp={(e) => finish(e, false)}
252
+ onPointerCancel={(e) => finish(e, true)}
253
+ onKeyDown={onHandleKeyDown}
254
+ onClick={(e) => e.stopPropagation()}
255
+ onDoubleClick={(e) => e.stopPropagation()}
256
+ >
257
+ <FadeDot active={dragging === "in"} />
258
+ </div>
259
+ <div
260
+ role="slider"
261
+ tabIndex={-1}
262
+ aria-label="Fade out"
263
+ aria-valuemin={0}
264
+ aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
265
+ aria-valuenow={fades.fadeOut}
266
+ aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
267
+ data-testid="clip-fade-handle-out"
268
+ title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
269
+ style={handleStyle(widthPx - outPx)}
270
+ onPointerDown={onHandlePointerDown("out")}
271
+ onPointerMove={onHandlePointerMove}
272
+ onPointerUp={(e) => finish(e, false)}
273
+ onPointerCancel={(e) => finish(e, true)}
274
+ onKeyDown={onHandleKeyDown}
275
+ onClick={(e) => e.stopPropagation()}
276
+ onDoubleClick={(e) => e.stopPropagation()}
277
+ >
278
+ <FadeDot active={dragging === "out"} />
279
+ </div>
280
+ </>
281
+ )}
282
+ </>
283
+ );
284
+ }
285
+
286
+ function FadeDot({ active }: { active: boolean }) {
287
+ return (
288
+ <span
289
+ aria-hidden="true"
290
+ style={{
291
+ display: "block",
292
+ width: HANDLE_SIZE,
293
+ height: HANDLE_SIZE,
294
+ borderRadius: "50%",
295
+ background: active ? "var(--timeline-fade-dot-active)" : "var(--clip-handle)",
296
+ boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
297
+ transform: active ? "scale(1.15)" : undefined,
298
+ transition: "transform 80ms ease-out",
299
+ }}
300
+ />
301
+ );
302
+ }
@@ -65,9 +65,9 @@ function GroupNameButton({
65
65
  <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
66
66
  ▤
67
67
  </span>
68
- {/* Wraps rather than truncating — a name that needs a hover to be read
69
- is no use in a column you scan. */}
70
- <span className="min-w-0 wrap-break-word text-left font-medium leading-tight">{label}</span>
68
+ <span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
69
+ {label}
70
+ </span>
71
71
  <span
72
72
  className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
73
73
  aria-hidden="true"
@@ -109,9 +109,7 @@ export function TimelineGroupHeader({
109
109
  }}
110
110
  >
111
111
  {/* One line, like a track header's: caret and name, then every control
112
- anchored to the right edge. The name wraps and the controls are
113
- `shrink-0`, so they hold the edge and the name gives way — no second
114
- line needed to keep five controls off the label. */}
112
+ anchored to the right edge. */}
115
113
  <button
116
114
  type="button"
117
115
  tabIndex={-1}
@@ -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
  }