@hyperframes/studio 0.8.55 → 0.8.57

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 (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { TimelineClip } from "./TimelineClip";
8
8
  import type { TimelineEditCapabilities } from "./timelineEditing";
9
+ import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
9
10
 
10
11
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
12
  configurable: true,
@@ -27,11 +28,13 @@ function renderClip({
27
28
  pps = 100,
28
29
  isSelected = false,
29
30
  hasCustomContent = true,
31
+ theme = defaultTimelineTheme,
30
32
  }: {
31
33
  element: TimelineElement;
32
34
  pps?: number;
33
35
  isSelected?: boolean;
34
36
  hasCustomContent?: boolean;
37
+ theme?: TimelineTheme;
35
38
  }) {
36
39
  const host = document.createElement("div");
37
40
  document.body.append(host);
@@ -48,6 +51,7 @@ function renderClip({
48
51
  isHovered={false}
49
52
  hasCustomContent={hasCustomContent}
50
53
  capabilities={capabilities}
54
+ theme={theme}
51
55
  isComposition={false}
52
56
  onHoverStart={vi.fn()}
53
57
  onHoverEnd={vi.fn()}
@@ -115,6 +119,54 @@ describe("TimelineClip", () => {
115
119
  act(() => root.unmount());
116
120
  });
117
121
 
122
+ it("passes clip and handle theme tokens to the rendered elements", () => {
123
+ const theme: TimelineTheme = {
124
+ ...defaultTimelineTheme,
125
+ clipBackground: "var(--test-clip-bg)",
126
+ clipBackgroundActive: "var(--test-clip-bg-active)",
127
+ clipBackgroundHover: "var(--test-clip-bg-hover)",
128
+ clipBackgroundDragging: "var(--test-clip-bg-dragging)",
129
+ clipBorder: "var(--test-clip-border)",
130
+ clipBorderHover: "var(--test-clip-border-hover)",
131
+ clipBorderActive: "var(--test-clip-border-active)",
132
+ handleColor: "var(--test-handle)",
133
+ };
134
+ const { host, root } = renderClip({
135
+ element: { id: "themed", label: "Themed", tag: "div", start: 0, duration: 1, track: 0 },
136
+ isSelected: true,
137
+ theme,
138
+ });
139
+ const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
140
+ expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--test-clip-bg)");
141
+ expect(clip.style.getPropertyValue("--clip-border-active")).toBe(
142
+ "var(--test-clip-border-active)",
143
+ );
144
+ expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--test-handle)");
145
+ expect(clip.querySelector<HTMLElement>(".timeline-clip__handle-bar")?.style.background).toBe(
146
+ "var(--clip-handle)",
147
+ );
148
+ act(() => root.unmount());
149
+ });
150
+
151
+ it("keeps default token references off the properties they resolve", () => {
152
+ const { host, root } = renderClip({
153
+ element: {
154
+ id: "default-theme",
155
+ label: "Default",
156
+ tag: "div",
157
+ start: 0,
158
+ duration: 1,
159
+ track: 0,
160
+ },
161
+ isSelected: true,
162
+ });
163
+ const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
164
+ expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--timeline-clip-bg)");
165
+ expect(clip.style.getPropertyValue("--clip-bg")).not.toBe("var(--clip-bg)");
166
+ expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--timeline-handle)");
167
+ act(() => root.unmount());
168
+ });
169
+
118
170
  it("is a roving native button with explicit selection semantics", () => {
119
171
  const { host, onClick, root } = renderClip({
120
172
  element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
@@ -64,6 +64,16 @@ export const TimelineClip = memo(function TimelineClip({
64
64
  const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
65
65
  const startLabel = el.start.toFixed(1);
66
66
  const endLabel = (el.start + el.duration).toFixed(1);
67
+ const themeVariables = {
68
+ "--clip-bg": theme.clipBackground,
69
+ "--clip-bg-active": theme.clipBackgroundActive,
70
+ "--clip-bg-hover": theme.clipBackgroundHover,
71
+ "--clip-bg-dragging": theme.clipBackgroundDragging,
72
+ "--clip-border": theme.clipBorder,
73
+ "--clip-border-hover": theme.clipBorderHover,
74
+ "--clip-border-active": theme.clipBorderActive,
75
+ "--clip-handle": theme.handleColor,
76
+ } as CSSProperties;
67
77
  const clipClassName = [
68
78
  "timeline-clip",
69
79
  "absolute",
@@ -81,6 +91,7 @@ export const TimelineClip = memo(function TimelineClip({
81
91
  top: clipY,
82
92
  ...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
83
93
  borderRadius: theme.clipRadius,
94
+ ...themeVariables,
84
95
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
85
96
  // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
86
97
  cursor: "default",
@@ -144,7 +155,7 @@ export const TimelineClip = memo(function TimelineClip({
144
155
  bottom: 6,
145
156
  width: 2,
146
157
  borderRadius: 1,
147
- background: "rgba(255, 255, 255, 0.55)",
158
+ background: "var(--clip-handle)",
148
159
  opacity: handleOpacity * 0.6,
149
160
  }}
150
161
  />
@@ -174,7 +185,7 @@ export const TimelineClip = memo(function TimelineClip({
174
185
  bottom: 6,
175
186
  width: 2,
176
187
  borderRadius: 1,
177
- background: "rgba(255, 255, 255, 0.55)",
188
+ background: "var(--clip-handle)",
178
189
  opacity: handleOpacity * 0.6,
179
190
  }}
180
191
  />
@@ -1,19 +1,22 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act, createElement } from "react";
3
- import { createRoot, type Root } from "react-dom/client";
4
3
  import { afterEach, describe, expect, it, vi } from "vitest";
5
4
  import type { TimelineElement } from "../store/playerStore";
6
5
  import { usePlayerStore } from "../store/playerStore";
7
6
  import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
8
7
  import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
8
+ import {
9
+ TimelineContextProvider,
10
+ type TimelineContextValue,
11
+ type TimelineOverlaysState,
12
+ } from "./TimelineProvider";
9
13
  import { defaultTimelineTheme } from "./timelineTheme";
14
+ import { createHappyDomRootHarness } from "./testRootHarness";
10
15
 
11
16
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
17
 
13
- const roots: Root[] = [];
18
+ const { mount } = createHappyDomRootHarness();
14
19
  afterEach(() => {
15
- for (const root of roots.splice(0)) act(() => root.unmount());
16
- document.body.innerHTML = "";
17
20
  usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
18
21
  });
19
22
 
@@ -76,8 +79,7 @@ function renderKeyframeOverlay(options: {
76
79
  }) {
77
80
  const container = document.createElement("div");
78
81
  document.body.appendChild(container);
79
- const root = createRoot(container);
80
- roots.push(root);
82
+ const root = mount(container);
81
83
  const elements = [options.currentElement];
82
84
  const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
83
85
  const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
@@ -90,6 +92,64 @@ function renderKeyframeOverlay(options: {
90
92
  percentage: 50,
91
93
  animationId: "child-position",
92
94
  };
95
+ const overlays = {
96
+ elements,
97
+ elementsRef: { current: elements },
98
+ theme: defaultTimelineTheme,
99
+ showShortcutHint: false,
100
+ showPopover: false,
101
+ rangeSelection: null,
102
+ setShowPopover: vi.fn(),
103
+ setRangeSelection: vi.fn(),
104
+ kfContextMenu: menu,
105
+ setKfContextMenu,
106
+ onDeleteKeyframe: vi.fn(),
107
+ onDeleteAllKeyframes,
108
+ onMoveKeyframeToPlayhead: vi.fn(),
109
+ clipContextMenu: null,
110
+ setClipContextMenu: vi.fn(),
111
+ currentTime: 0,
112
+ onSplitElement: vi.fn(),
113
+ pinZoomBeforeEdit: vi.fn(),
114
+ onDeleteElement: vi.fn(),
115
+ gapContextMenu: null,
116
+ onDismissGapContextMenu: vi.fn(),
117
+ onCloseTrackGap: vi.fn(),
118
+ onCloseAllTrackGaps: vi.fn(),
119
+ onHoverGapAction: vi.fn(),
120
+ } satisfies TimelineOverlaysState;
121
+ const contextValue = {
122
+ state: {
123
+ timelineReady: true,
124
+ elements,
125
+ selectedElementId: null,
126
+ sessionEpoch: 0,
127
+ keyframeCache: new Map(),
128
+ canvas: {} as TimelineContextValue["state"]["canvas"],
129
+ overlays,
130
+ },
131
+ actions: {
132
+ renderClipContent: undefined,
133
+ renderClipOverlay: undefined,
134
+ setFocusedEaseSegment: vi.fn(),
135
+ },
136
+ meta: {} as TimelineContextValue["meta"],
137
+ } satisfies TimelineContextValue;
138
+
139
+ function TestTimelineContext({ value }: { value: TimelineContextValue }) {
140
+ const selectedElementId = usePlayerStore((state) => state.selectedElementId);
141
+ const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
142
+ return createElement(
143
+ TimelineContextProvider,
144
+ {
145
+ value: {
146
+ ...value,
147
+ state: { ...value.state, selectedElementId, sessionEpoch },
148
+ },
149
+ },
150
+ createElement(TimelineOverlays),
151
+ );
152
+ }
93
153
 
94
154
  act(() => {
95
155
  usePlayerStore.setState({
@@ -97,32 +157,7 @@ function renderKeyframeOverlay(options: {
97
157
  timelineSessionEpoch: 2,
98
158
  });
99
159
  root.render(
100
- createElement(TimelineOverlays, {
101
- elements,
102
- elementsRef: { current: elements },
103
- theme: defaultTimelineTheme,
104
- showShortcutHint: false,
105
- showPopover: false,
106
- rangeSelection: null,
107
- setShowPopover: vi.fn(),
108
- setRangeSelection: vi.fn(),
109
- kfContextMenu: menu,
110
- setKfContextMenu,
111
- onDeleteKeyframe: vi.fn(),
112
- onDeleteAllKeyframes,
113
- onMoveKeyframeToPlayhead: vi.fn(),
114
- clipContextMenu: null,
115
- setClipContextMenu: vi.fn(),
116
- currentTime: 0,
117
- onSplitElement: vi.fn(),
118
- pinZoomBeforeEdit: vi.fn(),
119
- onDeleteElement: vi.fn(),
120
- gapContextMenu: null,
121
- onDismissGapContextMenu: vi.fn(),
122
- onCloseTrackGap: vi.fn(),
123
- onCloseAllTrackGaps: vi.fn(),
124
- onHoverGapAction: vi.fn(),
125
- }),
160
+ createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
126
161
  );
127
162
  });
128
163
  return { setKfContextMenu, onDeleteAllKeyframes };
@@ -1,67 +1,13 @@
1
- import { useEffect, type MutableRefObject } from "react";
1
+ import { useEffect } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { usePlayerStore } from "../store/playerStore";
4
- import type { TimelineTheme } from "./timelineTheme";
5
- import type { TimelineRangeSelection } from "./timelineEditing";
6
- import type { TimelineEditCallbacks } from "./timelineCallbacks";
7
3
  import { EditPopover } from "./EditModal";
8
- import {
9
- KeyframeDiamondContextMenu,
10
- type KeyframeDiamondContextMenuState,
11
- } from "./KeyframeDiamondContextMenu";
4
+ import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
12
5
  import { ClipContextMenu } from "./ClipContextMenu";
13
6
  import { TrackGapContextMenu } from "./TrackGapContextMenu";
14
7
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
15
8
  import { copyTextToClipboard } from "../../utils/clipboard";
16
9
  import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
17
-
18
- export interface ClipContextMenuState {
19
- x: number;
20
- y: number;
21
- element: TimelineElement;
22
- sessionEpoch: number;
23
- }
24
-
25
- /** Resolved model for the empty-lane-space (track gap) context menu. */
26
- interface TrackGapContextMenuState {
27
- x: number;
28
- y: number;
29
- gapWidth: number | null;
30
- canCloseGap: boolean;
31
- canCloseAllGaps: boolean;
32
- hasAnyGaps: boolean;
33
- }
34
-
35
- interface TimelineOverlaysProps {
36
- elements: readonly TimelineElement[];
37
- elementsRef: MutableRefObject<readonly TimelineElement[]>;
38
- theme: TimelineTheme;
39
- showShortcutHint: boolean;
40
- showPopover: boolean;
41
- rangeSelection: TimelineRangeSelection | null;
42
- setShowPopover: (value: boolean) => void;
43
- setRangeSelection: (value: TimelineRangeSelection | null) => void;
44
- kfContextMenu: KeyframeDiamondContextMenuState | null;
45
- setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
46
- onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
47
- onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
48
- onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
49
- clipContextMenu: ClipContextMenuState | null;
50
- setClipContextMenu: (value: ClipContextMenuState | null) => void;
51
- currentTime: number;
52
- onSplitElement: TimelineEditCallbacks["onSplitElement"];
53
- pinZoomBeforeEdit: () => void;
54
- onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
55
- onCopyClip?: () => boolean;
56
- onPasteClip?: () => Promise<void>;
57
- onDuplicateClip?: () => Promise<boolean>;
58
- canPasteClip?: () => boolean;
59
- gapContextMenu: TrackGapContextMenuState | null;
60
- onDismissGapContextMenu: () => void;
61
- onCloseTrackGap: () => void;
62
- onCloseAllTrackGaps: () => void;
63
- onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
64
- }
10
+ import { useTimelineContext } from "./TimelineProvider";
65
11
 
66
12
  interface TimelineContextTargetInput {
67
13
  capturedElement: TimelineElement;
@@ -85,56 +31,43 @@ export function resolveTimelineContextElement({
85
31
  return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
86
32
  }
87
33
 
88
- function readTimelineContextElement(
89
- capturedElement: TimelineElement,
90
- targetSessionEpoch: number | undefined,
91
- elements: readonly TimelineElement[],
92
- ): TimelineElement | null {
93
- const state = usePlayerStore.getState();
94
- return resolveTimelineContextElement({
95
- capturedElement,
96
- targetSessionEpoch,
97
- sessionEpoch: state.timelineSessionEpoch,
98
- selectedElementId: state.selectedElementId,
99
- elements,
100
- });
101
- }
102
-
103
34
  // The timeline's floating overlays, rendered as siblings above the scroll area:
104
35
  // the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
105
36
  // and the clip context menu.
106
- export function TimelineOverlays({
107
- elements,
108
- elementsRef,
109
- theme,
110
- showShortcutHint,
111
- showPopover,
112
- rangeSelection,
113
- setShowPopover,
114
- setRangeSelection,
115
- kfContextMenu,
116
- setKfContextMenu,
117
- onDeleteKeyframe,
118
- onDeleteAllKeyframes,
119
- onMoveKeyframeToPlayhead,
120
- clipContextMenu,
121
- setClipContextMenu,
122
- currentTime,
123
- onSplitElement,
124
- pinZoomBeforeEdit,
125
- onDeleteElement,
126
- onCopyClip,
127
- onPasteClip,
128
- onDuplicateClip,
129
- canPasteClip,
130
- gapContextMenu,
131
- onDismissGapContextMenu,
132
- onCloseTrackGap,
133
- onCloseAllTrackGaps,
134
- onHoverGapAction,
135
- }: TimelineOverlaysProps) {
136
- const selectedElementId = usePlayerStore((state) => state.selectedElementId);
137
- const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
37
+ export function TimelineOverlays() {
38
+ 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;
70
+ const { selectedElementId, sessionEpoch, keyframeCache } = state;
138
71
  const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
139
72
  const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
140
73
  const keyframeElement = kfContextMenu
@@ -156,7 +89,13 @@ export function TimelineOverlays({
156
89
  })
157
90
  : null;
158
91
  const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
159
- readTimelineContextElement(element, targetSessionEpoch, elementsRef.current);
92
+ resolveTimelineContextElement({
93
+ capturedElement: element,
94
+ targetSessionEpoch,
95
+ sessionEpoch,
96
+ selectedElementId,
97
+ elements: elementsRef.current,
98
+ });
160
99
 
161
100
  useEffect(() => {
162
101
  if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
@@ -217,7 +156,7 @@ export function TimelineOverlays({
217
156
  ) {
218
157
  return;
219
158
  }
220
- usePlayerStore.getState().setFocusedEaseSegment({
159
+ actions.setFocusedEaseSegment({
221
160
  animationId: keyframe.animationId,
222
161
  collidingAnimationTargets: keyframe.collidingAnimationTargets,
223
162
  tweenPercentage: keyframe.tweenPercentage,
@@ -228,7 +167,7 @@ export function TimelineOverlays({
228
167
  : undefined
229
168
  }
230
169
  onCopyProperties={(elementId, keyframe) => {
231
- const entry = usePlayerStore.getState().keyframeCache.get(elementId);
170
+ const entry = keyframeCache.get(elementId);
232
171
  // Tolerance match on clip-%, the same basis the cache is keyed on —
233
172
  // an exact float compare misses a keyframe the menu just opened over.
234
173
  const kf = entry?.keyframes.find(
@@ -0,0 +1,191 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ComponentProps,
5
+ type CSSProperties,
6
+ type MouseEvent,
7
+ type PointerEvent,
8
+ type ReactNode,
9
+ type RefCallback,
10
+ type UIEvent,
11
+ } from "react";
12
+ import type { TimelineEmptyState } from "./TimelineEmptyState";
13
+ import type { TimelineProps } from "./TimelineTypes";
14
+ import type { TimelineRangeSelection } from "./timelineEditing";
15
+ import type { TimelineDropPlacement } from "./timelineCallbacks";
16
+ import type { Rect } from "../../utils/marqueeGeometry";
17
+ import type { ResizingClipState } from "./useTimelineClipDrag";
18
+ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
19
+ import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
20
+ import type { TimelineElement } from "../store/playerStore";
21
+ import type { KeyframeCacheEntry } from "../store/keyframeSlice";
22
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
23
+ import type { TimelineTheme } from "./timelineTheme";
24
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
25
+ import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
26
+ import { useTimelineProviderState } from "./useTimelineProviderState";
27
+
28
+ export type TimelineCanvasState = Omit<
29
+ TimelineLaneBaseProps,
30
+ | "setRangeSelection"
31
+ | "setResizingClip"
32
+ | "setDraggedClip"
33
+ | "renderClipContent"
34
+ | "renderClipOverlay"
35
+ > & {
36
+ major: number[];
37
+ minor: number[];
38
+ totalH: number;
39
+ effectiveDuration: number;
40
+ majorTickInterval: number;
41
+ rangeSelection: TimelineRangeSelection | null;
42
+ marqueeRect: Rect | null;
43
+ resizingClip: ResizingClipState | null;
44
+ isScrubbing: boolean;
45
+ playheadRef: React.RefObject<HTMLDivElement | null>;
46
+ laneGapStrips: TimelineLaneGapStrips[];
47
+ dropPreview: TimelineDropPlacement | null;
48
+ setRangeSelection: (value: TimelineRangeSelection | null) => void;
49
+ setResizingClip: (value: ResizingClipState | null) => void;
50
+ setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
51
+ beatDragging: boolean;
52
+ onResizeElement: TimelineEditCallbacks["onResizeElement"];
53
+ onMoveElement: TimelineEditCallbacks["onMoveElement"];
54
+ };
55
+
56
+ export interface ClipContextMenuState {
57
+ x: number;
58
+ y: number;
59
+ element: TimelineElement;
60
+ sessionEpoch: number;
61
+ }
62
+
63
+ export interface TrackGapContextMenuState {
64
+ x: number;
65
+ y: number;
66
+ gapWidth: number | null;
67
+ canCloseGap: boolean;
68
+ canCloseAllGaps: boolean;
69
+ hasAnyGaps: boolean;
70
+ }
71
+
72
+ export interface TimelineOverlaysState {
73
+ elements: readonly TimelineElement[];
74
+ elementsRef: { current: readonly TimelineElement[] };
75
+ theme: TimelineTheme;
76
+ showShortcutHint: boolean;
77
+ showPopover: boolean;
78
+ rangeSelection: TimelineRangeSelection | null;
79
+ setShowPopover: (value: boolean) => void;
80
+ setRangeSelection: (value: TimelineRangeSelection | null) => void;
81
+ kfContextMenu: KeyframeDiamondContextMenuState | null;
82
+ setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
83
+ onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
84
+ onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
85
+ onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
86
+ clipContextMenu: ClipContextMenuState | null;
87
+ setClipContextMenu: (value: ClipContextMenuState | null) => void;
88
+ currentTime: number;
89
+ onSplitElement: TimelineEditCallbacks["onSplitElement"];
90
+ pinZoomBeforeEdit: () => void;
91
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
92
+ onCopyClip?: () => boolean;
93
+ onPasteClip?: () => Promise<void>;
94
+ onDuplicateClip?: () => Promise<boolean>;
95
+ canPasteClip?: () => boolean;
96
+ gapContextMenu: TrackGapContextMenuState | null;
97
+ onDismissGapContextMenu: () => void;
98
+ onCloseTrackGap: () => void;
99
+ onCloseAllTrackGaps: () => void;
100
+ onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
101
+ }
102
+
103
+ export interface TimelineContainerProps {
104
+ ref: RefCallback<HTMLDivElement>;
105
+ "aria-label": string;
106
+ "data-timeline-element-count": number;
107
+ className: string;
108
+ onMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
109
+ onMouseLeave: () => void;
110
+ style: CSSProperties;
111
+ }
112
+
113
+ export interface TimelineViewportProps {
114
+ ref: RefCallback<HTMLDivElement>;
115
+ "data-timeline-scroll-viewport": boolean;
116
+ "data-timeline-auto-scroll-left-inset": number;
117
+ tabIndex: number;
118
+ className: string;
119
+ onScroll: (event: UIEvent<HTMLDivElement>) => void;
120
+ onFocus: (event: React.FocusEvent<HTMLDivElement>) => void;
121
+ onBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
122
+ onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
123
+ onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
124
+ onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
125
+ onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
126
+ onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
127
+ onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
128
+ onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
129
+ onLostPointerCapture: (event: PointerEvent<HTMLDivElement>) => void;
130
+ }
131
+
132
+ export interface TimelineState {
133
+ timelineReady: boolean;
134
+ elements: readonly TimelineElement[];
135
+ selectedElementId: string | null;
136
+ sessionEpoch: number;
137
+ keyframeCache: Map<string, KeyframeCacheEntry>;
138
+ canvas: TimelineCanvasState;
139
+ overlays: TimelineOverlaysState;
140
+ }
141
+
142
+ export interface TimelineActions {
143
+ renderClipContent: TimelineLaneBaseProps["renderClipContent"];
144
+ renderClipOverlay: TimelineLaneBaseProps["renderClipOverlay"];
145
+ setFocusedEaseSegment: (target: {
146
+ animationId: string;
147
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
148
+ tweenPercentage: number;
149
+ elementId: string;
150
+ }) => void;
151
+ }
152
+
153
+ export interface TimelineMeta {
154
+ emptyState: ComponentProps<typeof TimelineEmptyState>;
155
+ containerProps: TimelineContainerProps;
156
+ viewportProps: TimelineViewportProps;
157
+ razorGuide: ReactNode;
158
+ }
159
+
160
+ export interface TimelineContextValue {
161
+ state: TimelineState;
162
+ actions: TimelineActions;
163
+ meta: TimelineMeta;
164
+ }
165
+
166
+ const TimelineContext = createContext<TimelineContextValue | null>(null);
167
+
168
+ export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
169
+ const value = useTimelineProviderState(props);
170
+ return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
171
+ }
172
+
173
+ export function TimelineContextProvider({
174
+ value,
175
+ children,
176
+ }: {
177
+ value: TimelineContextValue;
178
+ children: ReactNode;
179
+ }) {
180
+ return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
181
+ }
182
+
183
+ export function useTimelineContext(): TimelineContextValue {
184
+ const value = useTimelineContextOptional();
185
+ if (value === null) throw new Error("useTimelineContext must be used inside TimelineProvider");
186
+ return value;
187
+ }
188
+
189
+ function useTimelineContextOptional(): TimelineContextValue | null {
190
+ return useContext(TimelineContext);
191
+ }