@hyperframes/studio 0.7.55 → 0.7.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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,8 +1,21 @@
1
- import { useRef, useState, useCallback } from "react";
2
- import { buildClipRangeSelection, type TimelineRangeSelection } from "./timelineEditing";
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import {
3
+ buildClipRangeSelection,
4
+ applyTimelineAutoScrollStep,
5
+ resolveTimelineAutoScrollLoopAction,
6
+ type TimelineRangeSelection,
7
+ } from "./timelineEditing";
3
8
  import type { TimelineElement } from "../store/playerStore";
4
- import { liveTime } from "../store/playerStore";
9
+ import { liveTime, usePlayerStore } from "../store/playerStore";
5
10
  import { GUTTER } from "./timelineLayout";
11
+ import {
12
+ computeMarqueeSelection,
13
+ getMarqueeRect,
14
+ isMarqueeDrag,
15
+ isTimelineRulerPress,
16
+ type MarqueeClipInput,
17
+ } from "./timelineMarquee";
18
+ import type { Rect } from "../../utils/marqueeGeometry";
6
19
 
7
20
  interface UseTimelineRangeSelectionInput {
8
21
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -15,11 +28,69 @@ interface UseTimelineRangeSelectionInput {
15
28
  dragScrollRaf: React.RefObject<number>;
16
29
  isDragging: React.RefObject<boolean>;
17
30
  setShowPopover: (v: boolean) => void;
31
+ elementsRef: React.RefObject<TimelineElement[]>;
32
+ trackOrderRef: React.RefObject<number[]>;
33
+ onSelectElement?: (element: TimelineElement | null) => void;
34
+ }
35
+
36
+ interface MarqueeDragState {
37
+ originX: number;
38
+ originY: number;
39
+ /** Pre-drag selection, restored on Escape-cancel. */
40
+ baseIds: Set<string>;
41
+ basePrimary: string | null;
42
+ /** Union new hits with baseIds (shift/cmd/ctrl at pointerdown). */
43
+ additive: boolean;
44
+ /** True once the pointer travelled past the click threshold. */
45
+ active: boolean;
46
+ }
47
+
48
+ function snapshotSelection(): { ids: Set<string>; primary: string | null } {
49
+ const s = usePlayerStore.getState();
50
+ const ids = new Set(s.selectedElementIds);
51
+ if (s.selectedElementId) ids.add(s.selectedElementId);
52
+ return { ids, primary: s.selectedElementId };
53
+ }
54
+
55
+ function toMarqueeClips(elements: TimelineElement[]): MarqueeClipInput[] {
56
+ return elements.map((el) => ({
57
+ id: el.key ?? el.id,
58
+ start: el.start,
59
+ duration: el.duration,
60
+ track: el.track,
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * Compute the live selection for a marquee rect and commit it to the store.
66
+ * Shift held mid-drag (or cmd/ctrl at pointerdown) unions the new hits with the
67
+ * pre-drag selection (marquee.baseIds / basePrimary).
68
+ */
69
+ function commitMarqueeSelection(
70
+ rect: Rect,
71
+ additive: boolean,
72
+ marquee: MarqueeDragState,
73
+ elements: TimelineElement[],
74
+ trackOrder: number[],
75
+ pps: number,
76
+ ): void {
77
+ const { ids, primaryId } = computeMarqueeSelection({
78
+ clips: toMarqueeClips(elements),
79
+ trackOrder,
80
+ pps,
81
+ marquee: rect,
82
+ baseSelection: additive ? marquee.baseIds : undefined,
83
+ });
84
+ const store = usePlayerStore.getState();
85
+ // Primary FIRST: setSelectedElementId collapses the multi-select set, so the set
86
+ // must be written after it or the marquee selection would be wiped every frame.
87
+ store.setSelectedElementId(primaryId ?? (additive ? marquee.basePrimary : null));
88
+ store.setSelectedElementIds(ids);
18
89
  }
19
90
 
20
91
  export function useTimelineRangeSelection({
21
92
  scrollRef,
22
- ppsRef: _ppsRef,
93
+ ppsRef,
23
94
  effectiveDuration: _effectiveDuration,
24
95
  pps,
25
96
  onSeek: _onSeek,
@@ -28,9 +99,15 @@ export function useTimelineRangeSelection({
28
99
  dragScrollRaf,
29
100
  isDragging,
30
101
  setShowPopover,
102
+ elementsRef,
103
+ trackOrderRef,
104
+ onSelectElement,
31
105
  }: UseTimelineRangeSelectionInput) {
32
106
  const isRangeSelecting = useRef(false);
33
107
  const rangeAnchorTime = useRef(0);
108
+ // Reactive mirror of the scrub gesture (isDragging is a ref, so it can't drive
109
+ // rendering). Drives the playhead head's filled-vs-hollow state.
110
+ const [isScrubbing, setIsScrubbing] = useState(false);
34
111
  const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
35
112
  const shiftClickClipRef = useRef<{
36
113
  element: TimelineElement;
@@ -41,55 +118,174 @@ export function useTimelineRangeSelection({
41
118
  const seekRafRef = useRef(0);
42
119
  const pendingClientXRef = useRef(0);
43
120
 
121
+ // Marquee (rubber-band) multi-select on the empty timeline body.
122
+ const marqueeRef = useRef<MarqueeDragState | null>(null);
123
+ const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
124
+ // Edge auto-scroll during a marquee drag: last pointer position (client-space)
125
+ // + shift flag, re-applied each RAF frame while the view scrolls under a
126
+ // stationary pointer, so the marquee can extend past the visible area.
127
+ const marqueePointerRef = useRef<{ clientX: number; clientY: number; shiftKey: boolean } | null>(
128
+ null,
129
+ );
130
+ const marqueeScrollRaf = useRef(0);
131
+
132
+ /** Pointer position → canvas/content coordinates (same space as clip rects). */
133
+ const toContentPoint = useCallback(
134
+ (clientX: number, clientY: number): { x: number; y: number } | null => {
135
+ const el = scrollRef.current;
136
+ if (!el) return null;
137
+ const rect = el.getBoundingClientRect();
138
+ return {
139
+ x: clientX - rect.left + el.scrollLeft,
140
+ y: clientY - rect.top + el.scrollTop,
141
+ };
142
+ },
143
+ [scrollRef],
144
+ );
145
+
146
+ // Recompute the marquee rect + live selection for a client-space pointer.
147
+ // Content-space (folds in scrollLeft/scrollTop), so re-running it after the
148
+ // view scrolls naturally extends the rect toward the newly revealed area.
149
+ // Shared by the pointermove handler and the edge auto-scroll stepper.
150
+ const applyMarqueeAtClient = useCallback(
151
+ (clientX: number, clientY: number, shiftKey: boolean) => {
152
+ const marquee = marqueeRef.current;
153
+ if (!marquee) return;
154
+ const point = toContentPoint(clientX, clientY);
155
+ if (!point) return;
156
+ if (!marquee.active && !isMarqueeDrag(marquee.originX, marquee.originY, point.x, point.y)) {
157
+ return;
158
+ }
159
+ marquee.active = true;
160
+ const rect = getMarqueeRect(marquee.originX, marquee.originY, point.x, point.y);
161
+ setMarqueeRect(rect);
162
+ // Live selection: every clip the box currently covers. Shift held
163
+ // mid-drag (or cmd/ctrl at pointerdown) adds to the prior selection.
164
+ const additive = marquee.additive || shiftKey;
165
+ commitMarqueeSelection(
166
+ rect,
167
+ additive,
168
+ marquee,
169
+ elementsRef.current ?? [],
170
+ trackOrderRef.current ?? [],
171
+ ppsRef.current,
172
+ );
173
+ },
174
+ [toContentPoint, elementsRef, trackOrderRef, ppsRef],
175
+ );
176
+
177
+ const stopMarqueeAutoScroll = useCallback(() => {
178
+ marqueePointerRef.current = null;
179
+ if (marqueeScrollRaf.current) {
180
+ cancelAnimationFrame(marqueeScrollRaf.current);
181
+ marqueeScrollRaf.current = 0;
182
+ }
183
+ }, []);
184
+
185
+ // Edge auto-scroll while marquee-dragging: mirrors stepClipDragAutoScroll —
186
+ // scroll the container toward the edge zone the pointer is in, then re-run the
187
+ // marquee at the (unchanged) client pointer so the rect + selection extend
188
+ // under the scroll delta. Self-perpetuating RAF until the pointer leaves the
189
+ // edge zones or the gesture ends.
190
+ const stepMarqueeAutoScroll = useCallback(() => {
191
+ marqueeScrollRaf.current = 0;
192
+ const marquee = marqueeRef.current;
193
+ const pointer = marqueePointerRef.current;
194
+ const scroll = scrollRef.current;
195
+ if (!marquee || !pointer || !scroll) return;
196
+ if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
197
+
198
+ // Re-run at the SAME client point: toContentPoint folds in the new scroll, so
199
+ // the marquee's moving corner tracks the revealed content.
200
+ applyMarqueeAtClient(pointer.clientX, pointer.clientY, pointer.shiftKey);
201
+ marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
202
+ }, [scrollRef, applyMarqueeAtClient]);
203
+
204
+ const syncMarqueeAutoScroll = useCallback(
205
+ (clientX: number, clientY: number, shiftKey: boolean) => {
206
+ marqueePointerRef.current = { clientX, clientY, shiftKey };
207
+ const action = resolveTimelineAutoScrollLoopAction(
208
+ scrollRef.current,
209
+ clientX,
210
+ clientY,
211
+ marqueeScrollRaf.current !== 0,
212
+ );
213
+ if (action === "stop") {
214
+ cancelAnimationFrame(marqueeScrollRaf.current);
215
+ marqueeScrollRaf.current = 0;
216
+ } else if (action === "start") {
217
+ marqueeScrollRaf.current = requestAnimationFrame(stepMarqueeAutoScroll);
218
+ }
219
+ },
220
+ [scrollRef, stepMarqueeAutoScroll],
221
+ );
222
+
223
+ // Shift-press → start a time-range selection anchored at the pressed x.
224
+ const beginRangeSelection = useCallback(
225
+ (e: React.PointerEvent) => {
226
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
227
+ isRangeSelecting.current = true;
228
+ setShowPopover(false);
229
+ const rect = scrollRef.current?.getBoundingClientRect();
230
+ if (rect) {
231
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
232
+ const time = Math.max(0, x / pps);
233
+ rangeAnchorTime.current = time;
234
+ setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
235
+ }
236
+ },
237
+ [scrollRef, pps, setShowPopover],
238
+ );
239
+
44
240
  const handlePointerDown = useCallback(
45
241
  (e: React.PointerEvent) => {
46
242
  if (e.button !== 0) return;
47
243
  if (e.shiftKey) {
48
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
49
- isRangeSelecting.current = true;
50
- setShowPopover(false);
51
- const rect = scrollRef.current?.getBoundingClientRect();
52
- if (rect) {
53
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
54
- const time = Math.max(0, x / pps);
55
- rangeAnchorTime.current = time;
56
- setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
57
- }
244
+ beginRangeSelection(e);
58
245
  return;
59
246
  }
60
247
  shiftClickClipRef.current = null;
61
248
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
62
249
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
63
- isDragging.current = true;
64
250
  setRangeSelection(null);
65
251
  setShowPopover(false);
66
- seekFromX(e.clientX);
252
+ const point = toContentPoint(e.clientX, e.clientY);
253
+ // Ruler press → scrub the playhead (the standard scrub surface). The
254
+ // ruler is sticky, so this decision uses VIEWPORT-space y — content-space
255
+ // y (which folds in scrollTop) breaks once the body is scrolled down and
256
+ // the stuck ruler visually overlays scrolled-away track rows.
257
+ const scrollRect = scrollRef.current?.getBoundingClientRect();
258
+ if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
259
+ isDragging.current = true;
260
+ setIsScrubbing(true);
261
+ seekFromX(e.clientX);
262
+ return;
263
+ }
264
+ // Empty body press → pending marquee. A plain click (no drag past the
265
+ // threshold) deselects on pointerup; a drag draws the marquee. Never scrubs.
266
+ const base = snapshotSelection();
267
+ marqueeRef.current = {
268
+ originX: point.x,
269
+ originY: point.y,
270
+ baseIds: base.ids,
271
+ basePrimary: base.primary,
272
+ additive: e.metaKey || e.ctrlKey,
273
+ active: false,
274
+ };
67
275
  },
68
- [seekFromX, pps, scrollRef, isDragging, setShowPopover],
276
+ [beginRangeSelection, seekFromX, scrollRef, isDragging, setShowPopover, toContentPoint],
69
277
  );
70
278
 
71
- const handlePointerMove = useCallback(
72
- (e: React.PointerEvent) => {
73
- if (isRangeSelecting.current) {
74
- const rect = scrollRef.current?.getBoundingClientRect();
75
- if (rect) {
76
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
77
- setRangeSelection((prev) =>
78
- prev
79
- ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
80
- : null,
81
- );
82
- }
83
- return;
84
- }
85
- if (!isDragging.current) return;
86
- pendingClientXRef.current = e.clientX;
279
+ // Scrub-drag update: live playhead feedback (liveTime) + RAF-throttled seek.
280
+ const updateScrubDrag = useCallback(
281
+ (clientX: number) => {
282
+ pendingClientXRef.current = clientX;
87
283
  // Update the playhead visual immediately via liveTime for smooth feedback,
88
284
  // then RAF-throttle the full seek (adapter + React state sync).
89
285
  const el = scrollRef.current;
90
286
  if (el) {
91
287
  const rect = el.getBoundingClientRect();
92
- const x = e.clientX - rect.left + el.scrollLeft - GUTTER;
288
+ const x = clientX - rect.left + el.scrollLeft - GUTTER;
93
289
  if (x >= 0) {
94
290
  const dur = el.scrollWidth / pps;
95
291
  liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
@@ -105,40 +301,135 @@ export function useTimelineRangeSelection({
105
301
  });
106
302
  }
107
303
  },
108
- [seekFromX, autoScrollDuringDrag, pps, scrollRef, isDragging],
304
+ [scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
305
+ );
306
+
307
+ const handlePointerMove = useCallback(
308
+ (e: React.PointerEvent) => {
309
+ if (isRangeSelecting.current) {
310
+ const rect = scrollRef.current?.getBoundingClientRect();
311
+ if (rect) {
312
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
313
+ setRangeSelection((prev) =>
314
+ prev
315
+ ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
316
+ : null,
317
+ );
318
+ }
319
+ return;
320
+ }
321
+ const marquee = marqueeRef.current;
322
+ if (marquee) {
323
+ applyMarqueeAtClient(e.clientX, e.clientY, e.shiftKey);
324
+ // Edge auto-scroll: once the drag is live, scroll when the pointer nears
325
+ // a viewport edge so the marquee can extend past the visible area.
326
+ if (marquee.active) syncMarqueeAutoScroll(e.clientX, e.clientY, e.shiftKey);
327
+ return;
328
+ }
329
+ if (!isDragging.current) return;
330
+ updateScrubDrag(e.clientX);
331
+ },
332
+ [pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
333
+ );
334
+
335
+ // Release of a shift time-range gesture: keep a real range (or a shift-click
336
+ // clip range), otherwise clear it.
337
+ const finishRangeSelection = useCallback(() => {
338
+ isRangeSelecting.current = false;
339
+ const pendingShiftClick = shiftClickClipRef.current;
340
+ shiftClickClipRef.current = null;
341
+ setRangeSelection((prev) => {
342
+ if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
343
+ setShowPopover(true);
344
+ return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
345
+ }
346
+ if (prev && Math.abs(prev.end - prev.start) > 0.2) {
347
+ setShowPopover(true);
348
+ return prev;
349
+ }
350
+ return null;
351
+ });
352
+ }, [setShowPopover]);
353
+
354
+ // Release of a marquee gesture: plain click deselects; a real drag keeps the
355
+ // live selection and notifies the primary element.
356
+ const finishMarquee = useCallback(
357
+ (marquee: MarqueeDragState) => {
358
+ marqueeRef.current = null;
359
+ stopMarqueeAutoScroll();
360
+ setMarqueeRect(null);
361
+ const store = usePlayerStore.getState();
362
+ if (!marquee.active) {
363
+ // Plain click on empty body (click-away): deselect everything.
364
+ store.setSelectedElementId(null);
365
+ store.clearSelectedElementIds();
366
+ onSelectElement?.(null);
367
+ return;
368
+ }
369
+ const primaryKey = store.selectedElementId;
370
+ const primary =
371
+ (elementsRef.current ?? []).find((el) => (el.key ?? el.id) === primaryKey) ?? null;
372
+ onSelectElement?.(primary);
373
+ },
374
+ [stopMarqueeAutoScroll, elementsRef, onSelectElement],
109
375
  );
110
376
 
111
377
  const handlePointerUp = useCallback(() => {
112
378
  if (isRangeSelecting.current) {
113
- isRangeSelecting.current = false;
114
- const pendingShiftClick = shiftClickClipRef.current;
115
- shiftClickClipRef.current = null;
116
- setRangeSelection((prev) => {
117
- if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
118
- setShowPopover(true);
119
- return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
120
- }
121
- if (prev && Math.abs(prev.end - prev.start) > 0.2) {
122
- setShowPopover(true);
123
- return prev;
124
- }
125
- return null;
126
- });
379
+ finishRangeSelection();
380
+ return;
381
+ }
382
+ const marquee = marqueeRef.current;
383
+ if (marquee) {
384
+ finishMarquee(marquee);
127
385
  return;
128
386
  }
387
+ if (!isDragging.current) return;
129
388
  if (seekRafRef.current) {
130
389
  cancelAnimationFrame(seekRafRef.current);
131
390
  seekRafRef.current = 0;
132
391
  }
133
392
  seekFromX(pendingClientXRef.current);
134
393
  isDragging.current = false;
394
+ setIsScrubbing(false);
135
395
  cancelAnimationFrame(dragScrollRaf.current);
136
- }, [isDragging, dragScrollRaf, setShowPopover, seekFromX]);
396
+ }, [isDragging, dragScrollRaf, seekFromX, finishRangeSelection, finishMarquee]);
397
+
398
+ // Escape: cancel an in-flight marquee (restores the pre-drag selection);
399
+ // otherwise clear any lingering multi-selection.
400
+ useEffect(() => {
401
+ const onKeyDown = (e: KeyboardEvent) => {
402
+ if (e.key !== "Escape") return;
403
+ const store = usePlayerStore.getState();
404
+ const marquee = marqueeRef.current;
405
+ if (marquee) {
406
+ marqueeRef.current = null;
407
+ stopMarqueeAutoScroll();
408
+ setMarqueeRect(null);
409
+ if (marquee.active) {
410
+ // Primary FIRST (see commitMarqueeSelection): it collapses the set, so
411
+ // restore the pre-drag primary before repopulating the base ids.
412
+ store.setSelectedElementId(marquee.basePrimary);
413
+ store.setSelectedElementIds(marquee.baseIds);
414
+ }
415
+ return;
416
+ }
417
+ // Escape with no marquee clears the whole selection — primary AND set.
418
+ // setSelectedElementId(null) also collapses the multi-select set.
419
+ if (store.selectedElementId || store.selectedElementIds.size > 0) {
420
+ store.setSelectedElementId(null);
421
+ }
422
+ };
423
+ window.addEventListener("keydown", onKeyDown);
424
+ return () => window.removeEventListener("keydown", onKeyDown);
425
+ }, [stopMarqueeAutoScroll]);
137
426
 
138
427
  return {
139
428
  rangeSelection,
140
429
  setRangeSelection,
141
430
  shiftClickClipRef,
431
+ marqueeRect,
432
+ isScrubbing,
142
433
  handlePointerDown,
143
434
  handlePointerMove,
144
435
  handlePointerUp,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Consumes playerStore.clipRevealRequest: when another surface (the sidebar
3
+ * asset card / audio row) asks for a clip to be revealed, smooth-scroll the
4
+ * timeline's scroll container so that clip is visible — horizontally to its
5
+ * time and vertically to its lane.
6
+ *
7
+ * The request is consumed (cleared) whether or not the clip node is found, so
8
+ * a stale request can never replay a scroll later. Respects zoom mode: in
9
+ * "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
10
+ * only the vertical axis is scrolled there.
11
+ */
12
+ import { useEffect } from "react";
13
+ import { usePlayerStore } from "../store/playerStore";
14
+ import { GUTTER, RULER_H } from "./timelineLayout";
15
+ import { computeRevealScroll } from "./timelineRevealScroll";
16
+
17
+ export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
18
+ const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
19
+
20
+ useEffect(() => {
21
+ if (!revealRequest) return;
22
+ // Consume the request first — reveal is one-shot, even when the clip node
23
+ // isn't currently rendered (e.g. drilled into a different composition).
24
+ usePlayerStore.getState().clearClipRevealRequest();
25
+
26
+ const container = scrollRef.current;
27
+ if (!container) return;
28
+ const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
29
+ if (!(clip instanceof HTMLElement)) return;
30
+
31
+ const containerRect = container.getBoundingClientRect();
32
+ const clipRect = clip.getBoundingClientRect();
33
+ const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
34
+ const clipTop = clipRect.top - containerRect.top + container.scrollTop;
35
+
36
+ const target = computeRevealScroll({
37
+ scrollLeft: container.scrollLeft,
38
+ scrollTop: container.scrollTop,
39
+ viewportWidth: container.clientWidth,
40
+ viewportHeight: container.clientHeight,
41
+ clipLeft,
42
+ clipRight: clipLeft + clipRect.width,
43
+ clipTop,
44
+ clipBottom: clipTop + clipRect.height,
45
+ stickyLeft: GUTTER,
46
+ stickyTop: RULER_H,
47
+ allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
48
+ });
49
+ if (target.left === null && target.top === null) return;
50
+ container.scrollTo({
51
+ left: target.left ?? container.scrollLeft,
52
+ top: target.top ?? container.scrollTop,
53
+ behavior: "smooth",
54
+ });
55
+ }, [revealRequest, scrollRef]);
56
+ }
@@ -0,0 +1,77 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ actions: null as null | {
12
+ previewIframeRef: { current: HTMLIFrameElement | null };
13
+ handleDomZIndexReorderCommit: ReturnType<typeof vi.fn>;
14
+ },
15
+ }));
16
+
17
+ vi.mock("../../contexts/DomEditContext", () => ({
18
+ useDomEditActionsContextOptional: () => mocks.actions,
19
+ }));
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContextOptional: () => ({ activeCompPath: "nested.html" }),
22
+ }));
23
+
24
+ import { useTimelineStackingSync } from "./useTimelineStackingSync";
25
+
26
+ describe("useTimelineStackingSync", () => {
27
+ it("forwards resolved entries and the lane gesture coalesce key", async () => {
28
+ const iframe = document.createElement("iframe");
29
+ document.body.appendChild(iframe);
30
+ const node = iframe.contentDocument!.createElement("div");
31
+ node.setAttribute("data-hf-id", "hf-a");
32
+ iframe.contentDocument!.body.appendChild(node);
33
+ const commit = vi.fn().mockResolvedValue(undefined);
34
+ mocks.actions = { previewIframeRef: { current: iframe }, handleDomZIndexReorderCommit: commit };
35
+ const element: TimelineElement = {
36
+ id: "a",
37
+ key: "a",
38
+ hfId: "hf-a",
39
+ tag: "div",
40
+ start: 0,
41
+ duration: 2,
42
+ track: 0,
43
+ sourceFile: "nested.html",
44
+ };
45
+ let apply: ((patches: Array<{ key: string; zIndex: number }>, key?: string) => unknown) | null =
46
+ null;
47
+ function Harness() {
48
+ apply = useTimelineStackingSync({
49
+ expandedElementsRef: { current: [element] },
50
+ }).applyStackingPatches;
51
+ return null;
52
+ }
53
+ const root = mountReactHarness(<Harness />);
54
+
55
+ await act(async () => {
56
+ await apply!([{ key: "a", zIndex: 8 }], "clip-lane-move:7");
57
+ });
58
+
59
+ expect(commit).toHaveBeenCalledWith(
60
+ [
61
+ expect.objectContaining({
62
+ element: node,
63
+ zIndex: 8,
64
+ sourceFile: "nested.html",
65
+ // The patch's store key rides along so the commit updates the store
66
+ // zIndex synchronously on the lane-sync path (and rolls it back on
67
+ // failure) instead of waiting for a preview reload.
68
+ key: "a",
69
+ }),
70
+ ],
71
+ "clip-lane-move:7",
72
+ );
73
+ act(() => root.unmount());
74
+ mocks.actions = null;
75
+ iframe.remove();
76
+ });
77
+ });