@hyperframes/studio 0.8.58 → 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 (52) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +548 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21596 -20725
  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/audioInspectorInput.test.ts +55 -0
  14. package/src/components/editor/audioInspectorInput.ts +55 -0
  15. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  19. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  20. package/src/components/editor/useColorGradingController.ts +2 -6
  21. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  22. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  23. package/src/components/nle/TimelinePane.tsx +29 -23
  24. package/src/contexts/TimelineEditContext.tsx +4 -0
  25. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  26. package/src/index.ts +16 -0
  27. package/src/player/components/Timeline.test.ts +22 -31
  28. package/src/player/components/Timeline.tsx +34 -7
  29. package/src/player/components/TimelineCanvas.tsx +10 -56
  30. package/src/player/components/TimelineClip.tsx +14 -1
  31. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  32. package/src/player/components/TimelineClipFades.tsx +302 -0
  33. package/src/player/components/TimelineOverlays.tsx +164 -165
  34. package/src/player/components/TimelineParts.test.tsx +101 -0
  35. package/src/player/components/TimelineParts.tsx +74 -0
  36. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  37. package/src/player/components/TimelineProvider.tsx +25 -0
  38. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  39. package/src/player/components/TimelineRulerPart.tsx +22 -0
  40. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  41. package/src/player/components/useTimelineProviderState.tsx +19 -12
  42. package/src/player/lib/timelineElementHelpers.ts +20 -0
  43. package/src/player/store/timelineElement.ts +5 -0
  44. package/src/styles/theme.css +4 -0
  45. package/src/utils/audioMeterMath.test.ts +44 -0
  46. package/src/utils/audioMeterMath.ts +79 -0
  47. package/src/utils/audioMeterVisibility.ts +14 -0
  48. package/src/utils/studioHelpers.ts +21 -0
  49. package/src/utils/studioUiPreferences.test.ts +10 -0
  50. package/src/utils/studioUiPreferences.ts +5 -0
  51. package/src/utils/timelineAssetDrop.test.ts +32 -0
  52. package/src/utils/timelineAssetDrop.ts +6 -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
  }
@@ -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
+ });
@@ -0,0 +1,74 @@
1
+ import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas";
2
+ import {
3
+ TimelineClipMenuOverlay as TimelineClipMenuImpl,
4
+ TimelineEditPopoverOverlay as TimelineEditPopoverImpl,
5
+ TimelineGapMenuOverlay as TimelineGapMenuImpl,
6
+ TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl,
7
+ TimelineShortcutHintOverlay as TimelineShortcutHintImpl,
8
+ } from "./TimelineOverlays";
9
+ import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes";
10
+ import { PlayheadIndicator } from "./PlayheadIndicator";
11
+ import { useTimelineContext } from "./TimelineProvider";
12
+ import { TimelineEmptyState } from "./TimelineEmptyState";
13
+ import { TimelineRulerPart } from "./TimelineRulerPart";
14
+
15
+ /** The provider-backed canvas shell. */
16
+ export function TimelineFrame() {
17
+ return <TimelineCanvasImpl />;
18
+ }
19
+
20
+ /** The provider-backed ruler. */
21
+ export const TimelineRuler = TimelineRulerPart;
22
+
23
+ /**
24
+ * The provider-backed lane renderer. TimelineFrame remains the composed Studio
25
+ * canvas; this part is exported for hosts that own the canvas arrangement.
26
+ */
27
+ export function TimelineLanes() {
28
+ const { state, actions } = useTimelineContext();
29
+ const props = state.canvas;
30
+ return (
31
+ <TimelineLanesImpl
32
+ {...props}
33
+ renderClipContent={actions.renderClipContent}
34
+ renderClipOverlay={actions.renderClipOverlay}
35
+ />
36
+ );
37
+ }
38
+
39
+ /** The provider-backed playhead indicator. */
40
+ export function TimelinePlayhead() {
41
+ const { state } = useTimelineContext();
42
+ const props = state.canvas;
43
+ return <PlayheadIndicator scrubbing={props.isScrubbing} />;
44
+ }
45
+
46
+ /** The provider-backed razor guide. */
47
+ export function TimelineRazorGuide() {
48
+ const { meta } = useTimelineContext();
49
+ return meta.razorGuide;
50
+ }
51
+
52
+ /** The composed overlay surface used by the Studio variant. */
53
+ export { TimelineOverlays } from "./TimelineOverlays";
54
+
55
+ export function TimelineEmptyStatePart() {
56
+ const { meta } = useTimelineContext();
57
+ return <TimelineEmptyState {...meta.emptyState} />;
58
+ }
59
+
60
+ export function TimelineShortcutHint() {
61
+ return <TimelineShortcutHintImpl />;
62
+ }
63
+ export function TimelineEditPopover() {
64
+ return <TimelineEditPopoverImpl />;
65
+ }
66
+ export function TimelineClipMenu() {
67
+ return <TimelineClipMenuImpl />;
68
+ }
69
+ export function TimelineKeyframeMenu() {
70
+ return <TimelineKeyframeMenuImpl />;
71
+ }
72
+ export function TimelineGapMenu() {
73
+ return <TimelineGapMenuImpl />;
74
+ }
@@ -0,0 +1,58 @@
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 { Timeline } from "./Timeline";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { useTimelineContext } from "./TimelineProvider";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ function TimelinePartsVariant() {
18
+ const { meta } = useTimelineContext();
19
+ return React.createElement(
20
+ "div",
21
+ meta.containerProps,
22
+ React.createElement(
23
+ "div",
24
+ meta.viewportProps,
25
+ React.createElement(Timeline.Frame),
26
+ React.createElement(Timeline.RazorGuide),
27
+ ),
28
+ React.createElement(Timeline.Overlays),
29
+ );
30
+ }
31
+
32
+ describe("Timeline provider boundary", () => {
33
+ it("keeps the composed Timeline markup equal to its provider parts", () => {
34
+ usePlayerStore.setState({
35
+ duration: 4,
36
+ timelineReady: true,
37
+ elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }],
38
+ });
39
+ const composedHost = document.createElement("div");
40
+ const partsHost = document.createElement("div");
41
+ document.body.append(composedHost, partsHost);
42
+ const composedRoot = createRoot(composedHost);
43
+ const partsRoot = createRoot(partsHost);
44
+ act(() => {
45
+ composedRoot.render(React.createElement(Timeline));
46
+ partsRoot.render(
47
+ React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)),
48
+ );
49
+ });
50
+ const normalizeMarkup = (markup: string) =>
51
+ markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes");
52
+ expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML));
53
+ act(() => {
54
+ composedRoot.unmount();
55
+ partsRoot.unmount();
56
+ });
57
+ });
58
+ });
@@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
17
17
  import type { ResizingClipState } from "./useTimelineClipDrag";
18
18
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
19
19
  import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
20
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
21
+ import type { TimelineSnapTarget } from "./timelineSnapping";
20
22
  import type { TimelineElement } from "../store/playerStore";
21
23
  import type { KeyframeCacheEntry } from "../store/keyframeSlice";
22
24
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
@@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme";
24
26
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
25
27
  import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
26
28
  import { useTimelineProviderState } from "./useTimelineProviderState";
29
+ import {
30
+ TimelineEditProvider,
31
+ useTimelineEditContextValue,
32
+ } from "../../contexts/TimelineEditContext";
27
33
 
28
34
  export type TimelineCanvasState = Omit<
29
35
  TimelineLaneBaseProps,
@@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit<
51
57
  beatDragging: boolean;
52
58
  onResizeElement: TimelineEditCallbacks["onResizeElement"];
53
59
  onMoveElement: TimelineEditCallbacks["onMoveElement"];
60
+ draggedElement: TimelineElement | null;
61
+ snapGuide: TimelineSnapTarget | null;
62
+ multiDragPreview: MultiDragPreviewInput | null;
63
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
64
+ onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
65
+ onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
66
+ onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
54
67
  };
55
68
 
56
69
  export interface ClipContextMenuState {
@@ -166,6 +179,18 @@ export interface TimelineContextValue {
166
179
  const TimelineContext = createContext<TimelineContextValue | null>(null);
167
180
 
168
181
  export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
182
+ const editContext = useTimelineEditContextValue();
183
+ if (!editContext) {
184
+ return (
185
+ <TimelineEditProvider value={props}>
186
+ <TimelineProviderState {...props}>{children}</TimelineProviderState>
187
+ </TimelineEditProvider>
188
+ );
189
+ }
190
+ return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
191
+ }
192
+
193
+ function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
169
194
  const value = useTimelineProviderState(props);
170
195
  return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
171
196
  }
@@ -50,7 +50,7 @@ export function useTimelineRazorInteraction({
50
50
  return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
51
51
  }
52
52
 
53
- export function TimelineRazorGuide({ x }: { x: number }) {
53
+ export function TimelineRazorGuideOverlay({ x }: { x: number }) {
54
54
  return (
55
55
  <div
56
56
  className="absolute top-0 bottom-0 pointer-events-none z-10"