@hyperframes/studio 0.7.55 → 0.7.56

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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40414 -37680
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,173 +1,46 @@
1
- import { Fragment, memo, type ReactNode } from "react";
2
- import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
- import { TimelineClip } from "./TimelineClip";
4
- import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
1
+ import { memo } from "react";
5
2
  import { TimelineRuler } from "./TimelineRuler";
6
- import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
7
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
+ import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
5
+ import { getRenderedTimelineElement } from "./timelineTheme";
8
6
  import {
9
- getTimelineEditCapabilities,
10
- resolveBlockedTimelineEditIntent,
11
- type TimelineRangeSelection,
12
- } from "./timelineEditing";
13
- import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
14
- import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
15
- import {
16
- usePlayerStore,
17
- type TimelineElement,
18
- type KeyframeCacheEntry,
19
- } from "../store/playerStore";
20
- import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
21
- import type { TrackVisualStyle } from "./timelineIcons";
22
- import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
23
- import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
24
- import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
7
+ GUTTER,
8
+ TRACK_H,
9
+ RULER_H,
10
+ CLIP_Y,
11
+ TRACKS_TOP_PAD,
12
+ TRACKS_BOTTOM_PAD,
13
+ getTimelineRowTop,
14
+ } from "./timelineLayout";
15
+ import { usePlayerStore } from "../store/playerStore";
16
+ import type { ResizingClipState } from "./useTimelineClipDrag";
17
+ import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
25
18
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
26
- import { isMusicTrack } from "../../utils/timelineInspector";
27
- import { TimelineLayerGutter } from "./TimelineLayerGutter";
28
- import {
29
- shouldShowTimelineLayerGroupHeader,
30
- TimelineLayerGroupHeader,
31
- } from "./TimelineLayerGroupHeader";
32
- import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
33
- import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
34
- import { TimelineDragGhost } from "./TimelineDragGhost";
35
- import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays";
36
- import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
37
-
38
- function ClipLintDot({ element }: { element: TimelineElement }) {
39
- const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
40
- if (!lint || lint.count === 0) return null;
41
- return (
42
- <span
43
- className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
44
- style={{ top: 7, right: 7 }}
45
- title={lint.messages.join("\n")}
46
- />
47
- );
48
- }
19
+ import type { Rect } from "../../utils/marqueeGeometry";
20
+ import { TimelineClip } from "./TimelineClip";
21
+ import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
22
+ import { renderClipChildren } from "./timelineClipChildren";
49
23
 
50
- interface TimelineCanvasProps {
24
+ interface TimelineCanvasProps extends TimelineLaneBaseProps {
51
25
  major: number[];
52
26
  minor: number[];
53
- pps: number;
54
- trackContentWidth: number;
55
27
  totalH: number;
56
28
  effectiveDuration: number;
57
29
  majorTickInterval: number;
58
30
  rangeSelection: TimelineRangeSelection | null;
59
- marqueeRect: TimelineMarqueeOverlayRect | null;
60
- theme: TimelineTheme;
61
- displayTrackOrder: TimelineLayerId[];
62
- trackOrder: TimelineLayerId[];
63
- tracks: StackingTimelineLayer[];
64
- trackStyles: Map<TimelineLayerId, TrackVisualStyle>;
65
- selectedElementId: string | null;
66
- hoveredClip: string | null;
67
- draggedClip: DraggedClipState | null;
31
+ /** Live rubber-band multi-select rectangle (canvas coordinates), or null. */
32
+ marqueeRect: Rect | null;
68
33
  resizingClip: ResizingClipState | null;
69
- blockedClipRef: React.RefObject<BlockedClipState | null>;
70
- suppressClickRef: React.RefObject<boolean>;
71
- scrollRef: React.RefObject<HTMLDivElement | null>;
72
- renderClipContent?: (
73
- element: TimelineElement,
74
- style: { clip: string; label: string },
75
- ) => ReactNode;
76
- renderClipOverlay?: (element: TimelineElement) => ReactNode;
34
+ /** Playhead is being actively scrubbed — fills the grab-handle head. */
35
+ isScrubbing: boolean;
77
36
  playheadRef: React.RefObject<HTMLDivElement | null>;
78
- onDrillDown?: (element: TimelineElement) => void;
79
- onSelectElement?: (element: TimelineElement | null) => void;
80
- setHoveredClip: (key: string | null) => void;
81
- setShowPopover: (v: boolean) => void;
82
- setRangeSelection: (v: null) => void;
83
- setResizingClip: (v: ResizingClipState | null) => void;
84
- setDraggedClip: (v: DraggedClipState | null) => void;
85
- setSelectedElementId: (id: string | null) => void;
86
- syncClipDragAutoScroll: (x: number, y: number) => void;
87
- shiftClickClipRef: React.RefObject<{
88
- element: TimelineElement;
89
- anchorX: number;
90
- anchorY: number;
91
- } | null>;
92
- getPreviewElement: (element: TimelineElement) => TimelineElement;
93
- getTrackStyle: (tag: string) => TrackVisualStyle;
94
- keyframeCache?: Map<string, KeyframeCacheEntry>;
95
- selectedKeyframes: Set<string>;
96
- currentTime: number;
97
- onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
98
- onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
99
- onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
100
- onMoveKeyframe?: (
101
- elementId: string,
102
- fromClipPercentage: number,
103
- toClipPercentage: number,
104
- ) => void;
105
- onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
106
- beatAnalysis?: MusicBeatAnalysis | null;
107
37
  }
108
38
 
109
- export const TimelineCanvas = memo(function TimelineCanvas({
110
- major,
111
- minor,
112
- pps,
113
- trackContentWidth,
114
- totalH,
115
- effectiveDuration,
116
- majorTickInterval,
117
- rangeSelection,
118
- marqueeRect,
119
- theme,
120
- displayTrackOrder,
121
- trackOrder,
122
- tracks,
123
- trackStyles,
124
- selectedElementId,
125
- hoveredClip,
126
- draggedClip,
127
- resizingClip,
128
- blockedClipRef,
129
- suppressClickRef,
130
- scrollRef,
131
- renderClipContent,
132
- renderClipOverlay,
133
- playheadRef,
134
- onDrillDown,
135
- onSelectElement,
136
- setHoveredClip,
137
- setShowPopover,
138
- setRangeSelection,
139
- setResizingClip,
140
- setDraggedClip,
141
- setSelectedElementId,
142
- syncClipDragAutoScroll,
143
- shiftClickClipRef,
144
- getPreviewElement,
145
- getTrackStyle,
146
- keyframeCache,
147
- selectedKeyframes,
148
- currentTime,
149
- onClickKeyframe,
150
- onShiftClickKeyframe,
151
- onContextMenuKeyframe,
152
- onMoveKeyframe,
153
- onContextMenuClip,
154
- beatAnalysis,
155
- }: TimelineCanvasProps) {
156
- const {
157
- onResizeElement,
158
- onMoveElement,
159
- onToggleTrackHidden,
160
- onToggleElementHidden,
161
- onRazorSplit,
162
- onRazorSplitAll,
163
- } = useTimelineEditContextOptional();
39
+ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
40
+ const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
41
+ const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
42
+ useTimelineEditContextOptional();
164
43
  const beatDragging = usePlayerStore((s) => s.beatDragging);
165
- const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
166
- const activeSnapGuideTime = draggedClip?.started
167
- ? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime)
168
- : resizingClip?.started
169
- ? resizingClip.snapGuideTime
170
- : null;
171
44
  const draggedElement = draggedClip?.element ?? null;
172
45
  const activeDraggedElement =
173
46
  draggedClip?.started === true && draggedElement
@@ -178,6 +51,28 @@ export const TimelineCanvas = memo(function TimelineCanvas({
178
51
  previewTrack: draggedClip.previewTrack,
179
52
  })
180
53
  : null;
54
+ // The drag ghost follows the cursor freely (both axes) — CapCut-style. The
55
+ // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
56
+ // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
57
+ const draggedRowIndex =
58
+ draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
59
+ // Live multi-selection drag: while a selected clip is dragged, ALL selected
60
+ // clips move together as one rigid formation. The GRABBED clip is the free
61
+ // ghost below; its co-selected "passengers" slide by the SAME group-clamped
62
+ // delta (cheap translateX, no re-layout) — the delta is derived from the
63
+ // grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
64
+ // the wall together and never deforms. Matches what the commit will do — see
65
+ // timelineMultiDragPreview + commit.
66
+ const multiDragPreview: MultiDragPreviewInput | null =
67
+ draggedClip?.started === true && draggedElement
68
+ ? {
69
+ dragStarted: true,
70
+ draggedKey: draggedElement.key ?? draggedElement.id,
71
+ draggedOriginStart: draggedElement.start,
72
+ draggedPreviewStart: draggedClip.previewStart,
73
+ selectedKeys: selectedElementIds,
74
+ }
75
+ : null;
181
76
  const activeDraggedPosition =
182
77
  draggedClip?.started === true && activeDraggedElement && scrollRef.current
183
78
  ? {
@@ -194,388 +89,179 @@ export const TimelineCanvas = memo(function TimelineCanvas({
194
89
  }
195
90
  : null;
196
91
 
197
- const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
198
- <>
199
- {renderClipOverlay?.(element)}
200
- {!renderClipContent && <ClipLintDot element={element} />}
201
- {renderClipContent && (
202
- <div className="absolute inset-0 overflow-hidden">
203
- {renderClipContent(element, clipStyle)}
204
- </div>
205
- )}
206
- </>
207
- );
208
- const activeDropPlacement =
209
- draggedClip?.started === true ? (draggedClip.previewStackingReorder?.placement ?? null) : null;
210
-
211
92
  return (
212
- // minWidth:100% makes the lanes and ruler fill the panel when the composition
213
- // is narrower than the viewport (zoomed out); content stays in time coords.
214
93
  <div
215
94
  className="relative"
216
- style={{ height: totalH, width: GUTTER + trackContentWidth, minWidth: "100%" }}
95
+ style={{ height: props.totalH, width: GUTTER + props.trackContentWidth }}
217
96
  >
218
97
  <TimelineRuler
219
- major={major}
220
- minor={minor}
221
- pps={pps}
222
- trackContentWidth={trackContentWidth}
223
- totalH={totalH}
224
- effectiveDuration={effectiveDuration}
225
- majorTickInterval={majorTickInterval}
226
- theme={theme}
227
- beatAnalysis={beatAnalysis}
98
+ major={props.major}
99
+ minor={props.minor}
100
+ pps={props.pps}
101
+ trackContentWidth={props.trackContentWidth}
102
+ totalH={props.totalH}
103
+ effectiveDuration={props.effectiveDuration}
104
+ majorTickInterval={props.majorTickInterval}
105
+ theme={props.theme}
106
+ beatAnalysis={props.beatAnalysis}
228
107
  />
229
108
 
230
- {
231
- // fallow-ignore-next-line complexity
232
- displayTrackOrder.map((layerId, rowIndex) => {
233
- const layer = tracks.find((item) => item.id === layerId) ?? null;
234
- const previousLayerId = displayTrackOrder[rowIndex - 1];
235
- const previousLayer = previousLayerId
236
- ? (tracks.find((item) => item.id === previousLayerId) ?? null)
237
- : null;
238
- const els = layer?.elements ?? [];
239
- const ts = trackStyles.get(layerId) ?? getTrackStyle("");
240
- const isAudioLayer = layer?.kind === "audio";
241
- const isFirstAudioLayer = isAudioLayer && previousLayer?.kind !== "audio";
242
- const isPendingTrack =
243
- draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0;
244
- const baseRowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
245
- const dropIndicator = resolveTimelineDropIndicator({
246
- placement: activeDropPlacement,
247
- layerId,
248
- layerOrder: displayTrackOrder,
249
- });
250
- const rowBackground =
251
- dropIndicator?.kind === "onto"
252
- ? theme.clipBackgroundActive
253
- : isAudioLayer
254
- ? `linear-gradient(90deg, ${theme.gutterBackground} 0, ${baseRowBackground} 74px)`
255
- : baseRowBackground;
256
- const showGroupHeader = shouldShowTimelineLayerGroupHeader(
257
- layer?.contextKey ?? "",
258
- previousLayer?.contextKey ?? "",
259
- );
260
- const rowTrack = layer?.placementTrack ?? els[0]?.track ?? 0;
261
- // The beat-dot strip occupies the top of this track's lane (active track,
262
- // or the music track when nothing is selected). When shown, keyframe
263
- // diamonds shrink + drop to the bottom half so they don't collide with it.
264
- const beatStripOnTrack =
265
- (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
266
- (selectedElementId
267
- ? els.some((e) => (e.key ?? e.id) === selectedElementId)
268
- : els.some(isMusicTrack));
269
- const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
270
- return (
271
- <Fragment key={layerId}>
272
- {showGroupHeader && layer && (
273
- <TimelineLayerGroupHeader
274
- contextKey={layer.contextKey}
275
- trackContentWidth={trackContentWidth}
276
- theme={theme}
277
- accentColor={ts.accent}
278
- />
279
- )}
280
- <div
281
- className="relative flex"
282
- style={{
283
- height: TRACK_H,
284
- background: rowBackground,
285
- borderTop: isFirstAudioLayer ? `2px solid ${theme.rulerBorder}` : undefined,
286
- borderBottom: `1px solid ${theme.rowBorder}`,
287
- boxShadow:
288
- dropIndicator?.kind === "onto" ? `inset 0 0 0 1px ${ts.accent}` : undefined,
289
- }}
290
- >
291
- <TimelineLayerGutter
292
- isAudio={isAudioLayer}
293
- isTrackHidden={isTrackHidden}
294
- rowTrack={rowTrack}
295
- theme={theme}
296
- onToggleHidden={() => {
297
- if (onToggleElementHidden && els.length > 0) {
298
- for (const element of els) {
299
- void onToggleElementHidden(element.key ?? element.id, !isTrackHidden);
300
- }
301
- return;
302
- }
303
- void onToggleTrackHidden?.(rowTrack, !isTrackHidden);
304
- }}
305
- />
306
- <div
307
- style={{
308
- width: trackContentWidth,
309
- opacity: isTrackHidden ? 0.35 : 1,
310
- transition: "opacity 120ms ease",
311
- }}
312
- className="relative"
313
- >
314
- {layer?.contextKey && (
315
- <span
316
- className="absolute bottom-0 top-0 pointer-events-none"
317
- style={{
318
- left: 0,
319
- width: 2,
320
- background: ts.accent,
321
- opacity: 0.45,
322
- zIndex: 2,
323
- }}
324
- />
325
- )}
326
- {dropIndicator?.kind === "line" && (
327
- <TimelineDropInsertionLine edge={dropIndicator.edge} accentColor={ts.accent} />
328
- )}
329
- {/* Faint beat lines in every track's background (behind the clips);
330
- the active snap target is highlighted. */}
331
- <BeatBackgroundLines
332
- beatTimes={beatAnalysis?.beatTimes}
333
- beatStrengths={beatAnalysis?.beatStrengths}
334
- pps={pps}
335
- highlightTime={activeSnapGuideTime}
336
- />
337
- {/* Beat dots on the active track (the one holding the selection),
338
- falling back to the music track when nothing is selected. */}
339
- {beatStripOnTrack && (
340
- <BeatStrip
341
- beatTimes={beatAnalysis?.beatTimes}
342
- beatStrengths={beatAnalysis?.beatStrengths}
343
- pps={pps}
344
- />
345
- )}
346
- {isPendingTrack && (
347
- <div
348
- className="absolute inset-0 flex items-center"
349
- style={{
350
- paddingLeft: 16,
351
- color: ts.label,
352
- fontSize: 11,
353
- letterSpacing: 0,
354
- textTransform: "uppercase",
355
- opacity: 0.5,
356
- }}
357
- >
358
- New track
359
- </div>
360
- )}
361
- {
362
- // fallow-ignore-next-line complexity
363
- els.map((el) => {
364
- const clipStyle = getTrackStyle(el.tag);
365
- const elementKey = el.key ?? el.id;
366
- const capabilities = getTimelineEditCapabilities(el);
367
- const isSelected = selectedElementIds.has(elementKey);
368
- const isComposition = !!el.compositionSrc;
369
- // elementKey (el.key ?? el.id) is already unique per clip; do NOT
370
- // fold in the map index, or a splice/reorder remounts every clip
371
- // at/after the change (DOM flash, drag interruption).
372
- const clipKey = elementKey;
373
- const isDraggingClip =
374
- draggedClip?.started === true &&
375
- (draggedElement?.key ?? draggedElement?.id) === elementKey;
376
- if (isDraggingClip) return null;
377
- const previewElement = getPreviewElement(el);
378
- return (
379
- <TimelineClip
380
- key={clipKey}
381
- onContextMenu={(e: React.MouseEvent) => {
382
- e.preventDefault();
383
- onContextMenuClip?.(e, el);
384
- }}
385
- el={previewElement}
386
- pps={pps}
387
- clipY={CLIP_Y}
388
- isSelected={isSelected}
389
- isHovered={hoveredClip === clipKey}
390
- isDragging={false}
391
- hasCustomContent={!!renderClipContent}
392
- capabilities={capabilities}
393
- theme={theme}
394
- isComposition={isComposition}
395
- onHoverStart={() => setHoveredClip(clipKey)}
396
- onHoverEnd={() => setHoveredClip(null)}
397
- onResizeStart={(edge, e) => {
398
- if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
399
- if (edge === "start" && !capabilities.canTrimStart) return;
400
- if (edge === "end" && !capabilities.canTrimEnd) return;
401
- e.stopPropagation();
402
- blockedClipRef.current = null;
403
- setShowPopover(false);
404
- setRangeSelection(null);
405
- setResizingClip({
406
- element: el,
407
- edge,
408
- originClientX: e.clientX,
409
- previewStart: el.start,
410
- previewDuration: el.duration,
411
- previewPlaybackStart: el.playbackStart,
412
- snapGuideTime: null,
413
- snapGuideKind: null,
414
- started: false,
415
- });
416
- }}
417
- onPointerDown={
418
- // fallow-ignore-next-line complexity
419
- (e) => {
420
- if (e.button !== 0) return;
421
- if (usePlayerStore.getState().activeTool === "razor") return;
422
- if (e.shiftKey) {
423
- shiftClickClipRef.current = {
424
- element: el,
425
- anchorX: e.clientX,
426
- anchorY: e.clientY,
427
- };
428
- return;
429
- }
430
- const target = e.currentTarget as HTMLElement;
431
- const rect = target.getBoundingClientRect();
432
- const blockedIntent = resolveBlockedTimelineEditIntent({
433
- width: rect.width,
434
- offsetX: e.clientX - rect.left,
435
- handleWidth: CLIP_HANDLE_W,
436
- capabilities,
437
- });
438
- if (
439
- blockedIntent &&
440
- ((blockedIntent === "move" && onMoveElement) ||
441
- (blockedIntent !== "move" && onResizeElement))
442
- ) {
443
- blockedClipRef.current = {
444
- element: el,
445
- intent: blockedIntent,
446
- originClientX: e.clientX,
447
- originClientY: e.clientY,
448
- started: false,
449
- };
450
- return;
451
- }
452
- if (!onMoveElement || !capabilities.canMove) return;
453
- blockedClipRef.current = null;
454
- setShowPopover(false);
455
- setRangeSelection(null);
456
- setDraggedClip({
457
- element: el,
458
- originClientX: e.clientX,
459
- originClientY: e.clientY,
460
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
461
- originScrollTop: scrollRef.current?.scrollTop ?? 0,
462
- pointerClientX: e.clientX,
463
- pointerClientY: e.clientY,
464
- pointerOffsetX: e.clientX - rect.left,
465
- pointerOffsetY: e.clientY - rect.top,
466
- previewStart: el.start,
467
- previewTrack: el.track,
468
- previewLayerId: layerId,
469
- previewLayerIndex: rowIndex,
470
- previewStackingReorder: null,
471
- snapBeatTime: null,
472
- snapGuideTime: null,
473
- snapGuideKind: null,
474
- started: false,
475
- });
476
- syncClipDragAutoScroll(e.clientX, e.clientY);
477
- }
478
- }
479
- onClick={(e) => {
480
- e.stopPropagation();
481
- if (suppressClickRef.current) return;
482
- const { activeTool } = usePlayerStore.getState();
483
- if (activeTool === "razor" && onRazorSplit) {
484
- const clipRect = (
485
- e.currentTarget as HTMLElement
486
- ).getBoundingClientRect();
487
- const clickOffsetX = e.clientX - clipRect.left;
488
- const splitTime = previewElement.start + clickOffsetX / pps;
489
- const clampedTime = Math.max(
490
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
491
- Math.min(
492
- previewElement.start +
493
- previewElement.duration -
494
- SPLIT_BOUNDARY_EPSILON_S,
495
- splitTime,
496
- ),
497
- );
498
- if (e.shiftKey && onRazorSplitAll) {
499
- onRazorSplitAll(clampedTime);
500
- } else {
501
- onRazorSplit(el, clampedTime);
502
- }
503
- return;
504
- }
505
- const nextElement = isSelected ? null : el;
506
- setSelectedElementId(nextElement ? elementKey : null);
507
- onSelectElement?.(nextElement);
508
- }}
509
- onDoubleClick={(e) => {
510
- e.stopPropagation();
511
- if (suppressClickRef.current) return;
512
- if (isComposition && onDrillDown) onDrillDown(el);
513
- }}
514
- >
515
- {renderClipChildren(previewElement, clipStyle)}
516
- {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
517
- <TimelineClipDiamonds
518
- keyframesData={keyframeCache.get(elementKey)!}
519
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
520
- clipHeightPx={TRACK_H - 2 * CLIP_Y}
521
- beatsActive={beatStripOnTrack}
522
- beatTimes={beatAnalysis?.beatTimes}
523
- clipStart={previewElement.start}
524
- clipDurationSeconds={previewElement.duration}
525
- pixelsPerSecond={pps}
526
- accentColor={clipStyle.accent}
527
- isSelected={isSelected}
528
- currentPercentage={
529
- previewElement.duration > 0
530
- ? ((currentTime - previewElement.start) /
531
- previewElement.duration) *
532
- 100
533
- : 0
534
- }
535
- elementId={elementKey}
536
- selectedKeyframes={selectedKeyframes}
537
- onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
538
- onShiftClickKeyframe={onShiftClickKeyframe}
539
- onContextMenuKeyframe={onContextMenuKeyframe}
540
- onMoveKeyframe={onMoveKeyframe}
541
- suppressClickRef={suppressClickRef}
542
- />
543
- )}
544
- </TimelineClip>
545
- );
546
- })
547
- }
548
- </div>
549
- </div>
550
- </Fragment>
551
- );
552
- })
553
- }
109
+ {/* Breathing room between the sticky ruler and the first track lane — the
110
+ top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
111
+ <div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
112
+
113
+ <TimelineLanes
114
+ {...props}
115
+ draggedElement={draggedElement}
116
+ multiDragPreview={multiDragPreview}
117
+ onToggleTrackHidden={onToggleTrackHidden}
118
+ onResizeElement={onResizeElement}
119
+ onMoveElement={onMoveElement}
120
+ onRazorSplit={onRazorSplit}
121
+ onRazorSplitAll={onRazorSplitAll}
122
+ />
123
+
124
+ {/* Breathing room below the last track lane (~1.5 track heights) — a real
125
+ scrollable surface, so a clip can be dragged into the void to create a
126
+ new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
127
+ <div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
128
+
129
+ {/* Drop placeholder — a clip-sized slot at the exact landing spot (target
130
+ lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
131
+ {draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
132
+ <div
133
+ className="absolute pointer-events-none"
134
+ style={{
135
+ top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
136
+ left: GUTTER + draggedClip.previewStart * props.pps,
137
+ width: Math.max(draggedClip.element.duration * props.pps, 4),
138
+ height: TRACK_H - CLIP_Y * 2,
139
+ border: "1px solid rgba(60,230,172,0.55)",
140
+ background: "rgba(60,230,172,0.12)",
141
+ borderRadius: 4,
142
+ zIndex: 30,
143
+ }}
144
+ />
145
+ )}
146
+
147
+ {/* Insertion line — a new track will be inserted at this boundary on drop.
148
+ Shown while the pointer is near a lane boundary (insert mode). */}
149
+ {draggedClip?.started && draggedClip.insertRow != null && (
150
+ <div
151
+ className="absolute pointer-events-none"
152
+ style={{
153
+ top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
154
+ left: GUTTER,
155
+ width: props.trackContentWidth,
156
+ height: 1,
157
+ background: "#3CE6AC",
158
+ boxShadow: "0 0 3px rgba(60,230,172,0.5)",
159
+ zIndex: 55,
160
+ }}
161
+ />
162
+ )}
163
+
164
+ {/* Snap guide for non-beat targets during clip drag */}
165
+ {draggedClip?.started && draggedClip.snapTime != null && draggedClip.snapType !== "beat" && (
166
+ <div
167
+ className="absolute pointer-events-none"
168
+ style={{
169
+ left: GUTTER + draggedClip.snapTime * props.pps,
170
+ top: RULER_H,
171
+ bottom: 0,
172
+ width: 1,
173
+ background: draggedClip.snapType === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
174
+ boxShadow:
175
+ draggedClip.snapType === "playhead"
176
+ ? "0 0 6px rgba(60,230,172,0.5)"
177
+ : "0 0 6px rgba(255,255,255,0.4)",
178
+ zIndex: 60,
179
+ }}
180
+ />
181
+ )}
554
182
 
183
+ {/* Drag ghost */}
555
184
  {activeDraggedElement && activeDraggedPosition && (
556
- <TimelineDragGhost
557
- element={activeDraggedElement}
558
- position={activeDraggedPosition}
559
- pps={pps}
560
- selectedElementId={selectedElementId}
561
- hasCustomContent={!!renderClipContent}
562
- theme={theme}
185
+ <div
186
+ className="absolute pointer-events-none"
187
+ style={{
188
+ top: activeDraggedPosition.top,
189
+ left: activeDraggedPosition.left,
190
+ width: Math.max(activeDraggedElement.duration * props.pps, 4),
191
+ height: TRACK_H - CLIP_Y * 2,
192
+ zIndex: 40,
193
+ }}
563
194
  >
564
- {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
565
- </TimelineDragGhost>
195
+ <TimelineClip
196
+ el={{ ...activeDraggedElement, start: 0 }}
197
+ pps={props.pps}
198
+ clipY={0}
199
+ isSelected={
200
+ props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
201
+ }
202
+ isHovered={false}
203
+ isDragging={true}
204
+ hasCustomContent={!!props.renderClipContent}
205
+ capabilities={getTimelineEditCapabilities(activeDraggedElement)}
206
+ theme={props.theme}
207
+ isComposition={!!activeDraggedElement.compositionSrc}
208
+ onHoverStart={() => {}}
209
+ onHoverEnd={() => {}}
210
+ onResizeStart={() => {}}
211
+ onClick={() => {}}
212
+ onDoubleClick={() => {}}
213
+ >
214
+ {renderClipChildren(
215
+ activeDraggedElement,
216
+ props.getTrackStyle(activeDraggedElement.tag),
217
+ props.renderClipContent,
218
+ props.renderClipOverlay,
219
+ )}
220
+ </TimelineClip>
221
+ </div>
566
222
  )}
567
223
 
568
- <TimelineSelectionOverlays
569
- rangeSelection={rangeSelection}
570
- marqueeRect={marqueeRect}
571
- pps={pps}
572
- accentColor={getTrackStyle("").accent}
573
- />
224
+ {/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
225
+ MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
226
+ {props.marqueeRect && (
227
+ <div
228
+ aria-hidden="true"
229
+ className="absolute pointer-events-none"
230
+ style={{
231
+ left: props.marqueeRect.left,
232
+ top: props.marqueeRect.top,
233
+ width: props.marqueeRect.width,
234
+ height: props.marqueeRect.height,
235
+ background: "rgba(60,230,172,0.10)",
236
+ border: "1px dashed rgba(60,230,172,0.7)",
237
+ borderRadius: 2,
238
+ zIndex: 70,
239
+ }}
240
+ />
241
+ )}
242
+
243
+ {/* Range highlight */}
244
+ {props.rangeSelection && (
245
+ <div
246
+ className="absolute pointer-events-none"
247
+ style={{
248
+ left:
249
+ GUTTER + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
250
+ width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
251
+ top: RULER_H,
252
+ bottom: 0,
253
+ backgroundColor: "rgba(59, 130, 246, 0.12)",
254
+ borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
255
+ borderRight: "1px solid rgba(59, 130, 246, 0.4)",
256
+ zIndex: 50,
257
+ }}
258
+ />
259
+ )}
574
260
 
575
261
  {/* Playhead — hidden while dragging a beat so its guideline doesn't
576
262
  track the scrub and clutter the beat being moved. */}
577
263
  <div
578
- ref={playheadRef}
264
+ ref={props.playheadRef}
579
265
  className="absolute top-0 bottom-0 pointer-events-none"
580
266
  style={{
581
267
  left: `${GUTTER}px`,
@@ -583,7 +269,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
583
269
  display: beatDragging ? "none" : undefined,
584
270
  }}
585
271
  >
586
- <PlayheadIndicator />
272
+ <PlayheadIndicator scrubbing={props.isScrubbing} />
587
273
  </div>
588
274
  </div>
589
275
  );