@hyperframes/studio 0.7.54 → 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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.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 +40582 -37803
  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/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,126 +1,103 @@
1
1
  import { useRef, useState, useCallback, useMemo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import {
4
- resolveTimelineMove,
5
- resolveTimelineResize,
6
- resolveTimelineAutoScroll,
7
- type BlockedTimelineEditIntent,
8
- type TimelineStackingReorderIntent,
4
+ applyTimelineAutoScrollStep,
5
+ resolveTimelineAutoScrollLoopAction,
6
+ resolveTimelineDragEscape,
9
7
  } from "./timelineEditing";
10
8
  import { usePlayerStore } from "../store/playerStore";
11
9
  import type { TimelineElement } from "../store/playerStore";
12
- import { TRACK_H } from "./timelineLayout";
13
- import { isMusicTrack } from "../../utils/timelineInspector";
10
+ import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
14
11
  import { mergeUserBeats } from "../../utils/beatEditing";
15
- import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
12
+ import {
13
+ buildTimelineGroupResizeMembers,
14
+ type TimelineGroupResizeSession,
15
+ } from "./timelineGroupEditing";
16
+ import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
17
+ import { commitDraggedClipMove } from "./timelineClipDragCommit";
18
+ import type { StackingPatch } from "./timelineStackingSync";
19
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
20
+ import {
21
+ computeDragPreview,
22
+ computeResizePreview,
23
+ previewGroupResize,
24
+ type ResizePreviewResult,
25
+ } from "./timelineClipDragPreview";
16
26
  import type {
17
- TimelineGroupMoveChange,
18
- TimelineGroupResizeChange,
19
- } from "../../hooks/useTimelineGroupEditing";
27
+ DraggedClipState,
28
+ ResizingClipState,
29
+ BlockedClipState,
30
+ } from "./timelineClipDragTypes";
20
31
  import {
21
- buildTimelineSnapTargets,
22
- snapEdgesToTargets,
23
- snapResizeEdgeToTargets,
24
- type TimelineSnapKind,
25
- } from "./timelineSnapTargets";
26
- import { resolveDragPreviewPlacement } from "./timelineClipDragPreview";
27
- import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
32
+ beginTimelineOptimisticGesture,
33
+ rollbackLatestTimelineOptimisticGesture,
34
+ } from "./timelineOptimisticRevision";
35
+ import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
28
36
 
29
- const EMPTY_BEAT_TIMES: number[] = [];
30
-
31
- /* ── Shared state types ─────────────────────────────────────────── */
32
- export interface DraggedClipState {
33
- element: TimelineElement;
34
- originClientX: number;
35
- originClientY: number;
36
- originScrollLeft: number;
37
- originScrollTop: number;
38
- pointerClientX: number;
39
- pointerClientY: number;
40
- pointerOffsetX: number;
41
- pointerOffsetY: number;
42
- previewStart: number;
43
- previewTrack: number;
44
- previewLayerId: TimelineLayerId;
45
- previewLayerIndex: number;
46
- /** Beat time the clip will snap to on drop, for the grid-line highlight. */
47
- snapBeatTime: number | null;
48
- snapGuideTime: number | null;
49
- snapGuideKind: TimelineSnapKind | null;
50
- /** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
51
- previewStackingReorder: TimelineStackingReorderIntent | null;
52
- started: boolean;
53
- }
37
+ export type {
38
+ DraggedClipState,
39
+ ResizingClipState,
40
+ BlockedClipState,
41
+ } from "./timelineClipDragTypes";
54
42
 
55
- export interface ResizingClipState {
56
- element: TimelineElement;
57
- edge: "start" | "end";
58
- originClientX: number;
59
- previewStart: number;
60
- previewDuration: number;
61
- previewPlaybackStart?: number;
62
- snapGuideTime: number | null;
63
- snapGuideKind: TimelineSnapKind | null;
64
- started: boolean;
65
- }
66
-
67
- export interface BlockedClipState {
68
- element: TimelineElement;
69
- intent: BlockedTimelineEditIntent;
70
- originClientX: number;
71
- originClientY: number;
72
- started: boolean;
73
- }
43
+ const EMPTY_BEAT_TIMES: number[] = [];
74
44
 
75
45
  /* ── Hook ───────────────────────────────────────────────────────── */
76
46
  interface UseTimelineClipDragInput {
77
47
  scrollRef: React.RefObject<HTMLDivElement | null>;
78
48
  ppsRef: React.RefObject<number>;
79
- trackOrderRef: React.RefObject<TimelineLayerId[]>;
80
- timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
81
- timelineElementsRef: React.RefObject<TimelineElement[]>;
49
+ durationRef: React.RefObject<number>;
50
+ trackOrderRef: React.RefObject<number[]>;
82
51
  onMoveElement?: (
83
52
  element: TimelineElement,
84
- updates: Pick<TimelineElement, "start" | "track"> & {
85
- stackingReorder?: TimelineStackingReorderIntent | null;
86
- },
53
+ updates: Pick<TimelineElement, "start" | "track">,
54
+ ) => Promise<void> | void;
55
+ onMoveElements?: (
56
+ edits: Array<{
57
+ element: TimelineElement;
58
+ updates: Pick<TimelineElement, "start" | "track">;
59
+ }>,
87
60
  ) => Promise<void> | void;
88
61
  onResizeElement?: (
89
62
  element: TimelineElement,
90
63
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
91
64
  ) => Promise<void> | void;
92
- onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
93
- onResizeElements?: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
94
- onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
95
- onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
96
- onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
65
+ onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
66
+ onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
97
67
  setShowPopover: (show: boolean) => void;
98
- /** Stable ref to the range selection setter, wired after mount to break circular dependency. */
68
+ /** Stable ref to the range selection setter wired after mount to break circular dependency. */
99
69
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
70
+ /**
71
+ * Lane ↔ stacking unification (see research/STAGE3-NEEDED-WIRING.md). When both
72
+ * are supplied and a lane-change drag commits, the edited clip(s) get z-index
73
+ * patches so their stacking matches lane order relative to time-overlapping
74
+ * clips. Provisioned by the timeline layer (Timeline.tsx) from the preview
75
+ * iframe + the canvas z-order persist path; forwarded straight to
76
+ * commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
77
+ */
78
+ readZIndex?: (element: TimelineElement) => number;
79
+ onStackingPatches?: (patches: StackingPatch[]) => void;
100
80
  }
101
81
 
102
82
  export function useTimelineClipDrag({
103
83
  scrollRef,
104
84
  ppsRef,
85
+ durationRef,
105
86
  trackOrderRef,
106
- timelineLayersRef,
107
- timelineElementsRef,
108
87
  onMoveElement,
109
- onResizeElement,
110
88
  onMoveElements,
89
+ onResizeElement,
111
90
  onResizeElements,
112
- onPreviewMoveElements,
113
- onPreviewResizeElements,
114
91
  onBlockedEditAttempt,
115
92
  setShowPopover,
116
93
  setRangeSelectionRef,
94
+ readZIndex,
95
+ onStackingPatches,
117
96
  }: UseTimelineClipDragInput) {
118
97
  const updateElement = usePlayerStore((s) => s.updateElement);
119
98
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
120
99
  const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
121
100
  const beatEdits = usePlayerStore((s) => s.beatEdits);
122
- const playhead = usePlayerStore((s) => s.currentTime);
123
- const compositionDuration = usePlayerStore((s) => s.duration);
124
101
  const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
125
102
  const musicPlaybackStart = usePlayerStore(
126
103
  (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
@@ -146,30 +123,45 @@ export function useTimelineClipDrag({
146
123
  musicDuration,
147
124
  ]);
148
125
 
149
- const beatTimesRef = useRef<number[]>([]);
150
- beatTimesRef.current = adjustedBeatTimes;
151
- const playheadRef = useRef(0);
152
- playheadRef.current = playhead;
153
- const compositionDurationRef = useRef(0);
154
- compositionDurationRef.current = compositionDuration;
126
+ const elements = usePlayerStore((s) => s.elements);
127
+ const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
128
+ const snapContextRef = useRef<{ beatTimes: number[]; enabled: boolean }>({
129
+ beatTimes: [],
130
+ enabled: true,
131
+ });
132
+ snapContextRef.current = {
133
+ beatTimes: adjustedBeatTimes,
134
+ enabled: timelineSnapEnabled,
135
+ };
136
+ const elementsRef = useRef(elements);
137
+ elementsRef.current = elements;
138
+
139
+ // Perf (frozen-per-gesture): the snap-target set and the audio-track set are
140
+ // fixed for the duration of one drag/resize (the store is not re-authored mid
141
+ // gesture), so build each ONCE and reuse it across every pointermove and every
142
+ // auto-scroll frame. Both caches are cleared at gesture teardown
143
+ // (stopClipDragAutoScroll), so the next gesture rebuilds against fresh state.
144
+ const snapTargetsCacheRef = useRef<Map<string, TimelineSnapTarget[]>>(new Map());
145
+ const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
155
146
 
156
147
  const buildSnapTargets = useCallback(
157
- (element: TimelineElement) => {
158
- const draggedKey = element.key ?? element.id;
159
- const selected = selectedElementIdsRef.current;
160
- // In a group drag every selected clip moves together, so none of them may act
161
- // as a snap target for the others; exclude the whole set, not just the grabbed clip.
162
- const excludedKeys =
163
- selected.size > 1 && selected.has(draggedKey) ? selected : new Set([draggedKey]);
164
- return buildTimelineSnapTargets({
165
- elements: timelineElementsRef.current,
166
- excludedKeys,
167
- playhead: playheadRef.current,
168
- compDuration: compositionDurationRef.current,
169
- beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
148
+ (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
149
+ // Magnet off no targets and no scan; do NOT cache so a mid-gesture toggle
150
+ // back on starts scanning immediately (preserves the existing skip).
151
+ if (!snapContextRef.current.enabled) return [];
152
+ const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
153
+ const cached = snapTargetsCacheRef.current.get(cacheKey);
154
+ if (cached) return cached;
155
+ const targets = collectTimelineSnapTargets({
156
+ elements: elementsRef.current,
157
+ playheadTime: usePlayerStore.getState().currentTime,
158
+ beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
159
+ excludeElementKey,
170
160
  });
161
+ snapTargetsCacheRef.current.set(cacheKey, targets);
162
+ return targets;
171
163
  },
172
- [timelineElementsRef],
164
+ [],
173
165
  );
174
166
 
175
167
  const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
@@ -183,95 +175,125 @@ export function useTimelineClipDrag({
183
175
  const blockedClipRef = useRef<BlockedClipState | null>(null);
184
176
  const suppressClickRef = useRef(false);
185
177
 
178
+ // Active multi-select group-resize session (restored from main 36413da7f): set
179
+ // lazily on the first resize pointermove when the grabbed clip is part of a
180
+ // capability-clean multi-selection (null ⇒ single-clip resize). Holds the
181
+ // pre-gesture snapshot so the non-grabbed members (previewed through the store)
182
+ // roll back on escape / cancel / failed persist.
183
+ const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
184
+
185
+ // Restore the non-grabbed group members to their pre-gesture timing (the
186
+ // grabbed clip renders from resizingClip state, so it is never written during
187
+ // preview). `all` also restores the grabbed clip after a committed persist fails.
188
+ const restoreGroupResizeMembers = useCallback(
189
+ (session: TimelineGroupResizeSession, all = false) => {
190
+ for (const m of session.members) {
191
+ if (!all && m.key === session.grabbedKey) continue;
192
+ updateElement(m.key, {
193
+ start: m.start,
194
+ duration: m.duration,
195
+ playbackStart: m.playbackStart,
196
+ });
197
+ }
198
+ },
199
+ [updateElement],
200
+ );
201
+
186
202
  const onMoveElementRef = useRef(onMoveElement);
187
203
  onMoveElementRef.current = onMoveElement;
188
- const onResizeElementRef = useRef(onResizeElement);
189
- onResizeElementRef.current = onResizeElement;
190
204
  const onMoveElementsRef = useRef(onMoveElements);
191
205
  onMoveElementsRef.current = onMoveElements;
206
+ const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
207
+ onBlockedEditAttemptRef.current = onBlockedEditAttempt;
208
+ const onResizeElementRef = useRef(onResizeElement);
209
+ onResizeElementRef.current = onResizeElement;
192
210
  const onResizeElementsRef = useRef(onResizeElements);
193
211
  onResizeElementsRef.current = onResizeElements;
194
- const onPreviewMoveElementsRef = useRef(onPreviewMoveElements);
195
- onPreviewMoveElementsRef.current = onPreviewMoveElements;
196
- const onPreviewResizeElementsRef = useRef(onPreviewResizeElements);
197
- onPreviewResizeElementsRef.current = onPreviewResizeElements;
198
- const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds);
199
- selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds);
212
+ const readZIndexRef = useRef(readZIndex);
213
+ readZIndexRef.current = readZIndex;
214
+ const onStackingPatchesRef = useRef(onStackingPatches);
215
+ onStackingPatchesRef.current = onStackingPatches;
200
216
 
201
217
  const clipDragScrollRaf = useRef(0);
202
- const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
203
- const {
204
- previewGroupMove,
205
- previewGroupResize,
206
- commitGroupMove,
207
- commitGroupResize,
208
- clearGroupDragSessions,
209
- } = useTimelineClipGroupDrag({
210
- timelineElementsRef,
211
- updateElement,
212
- onMoveElementsRef,
213
- onResizeElementsRef,
214
- onPreviewMoveElementsRef,
215
- onPreviewResizeElementsRef,
216
- });
217
-
218
- // fallow-ignore-next-line complexity
218
+ const clipDragPointerRef = useRef<{
219
+ clientX: number;
220
+ clientY: number;
221
+ } | null>(null);
222
+
223
+ // Recompute the dragged-clip preview for a pointer position. The heavy lifting
224
+ // (move + snap + group clamp + drop placement) is a tested pure function so
225
+ // what runs here is what's verified — see timelineClipDragPreview.
219
226
  const updateDraggedClipPreview = useCallback(
220
227
  (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
221
- const scroll = scrollRef.current;
222
- const nextMove = resolveTimelineMove(
223
- {
224
- start: drag.element.start,
225
- track: drag.element.track,
226
- duration: drag.element.duration,
227
- originClientX: drag.originClientX,
228
- originClientY: drag.originClientY,
229
- originScrollLeft: drag.originScrollLeft,
230
- originScrollTop: drag.originScrollTop,
231
- currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
232
- currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
233
- pixelsPerSecond: ppsRef.current,
234
- trackHeight: TRACK_H,
235
- maxStart: Number.POSITIVE_INFINITY,
236
- trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack),
237
- layerOrder: trackOrderRef.current,
238
- timelineLayers: timelineLayersRef.current,
239
- stackingElement: drag.element,
240
- stackingElements: timelineElementsRef.current,
241
- },
242
- clientX,
243
- clientY,
244
- );
245
- const snap = snapEdgesToTargets(
246
- nextMove.start,
247
- drag.element.duration,
248
- buildSnapTargets(drag.element),
249
- ppsRef.current,
250
- { maxStart: Number.POSITIVE_INFINITY },
251
- );
252
- const groupMove = previewGroupMove(drag.element, selectedElementIdsRef.current, snap.start);
253
- const placement = resolveDragPreviewPlacement(drag, nextMove, groupMove);
254
- return {
255
- ...drag,
256
- started: true,
257
- pointerClientX: clientX,
258
- pointerClientY: clientY,
259
- ...placement,
260
- snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
261
- snapGuideTime: snap.snapTime,
262
- snapGuideKind: snap.snapKind,
263
- };
228
+ // Build the audio-track set once per gesture (see snapTargetsCacheRef): it
229
+ // only feeds zone-aware drop placement and is frozen while dragging.
230
+ if (!dragAudioTracksRef.current) {
231
+ dragAudioTracksRef.current = new Set(
232
+ elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
233
+ );
234
+ }
235
+ return computeDragPreview(drag, clientX, clientY, {
236
+ scroll: scrollRef.current,
237
+ pps: ppsRef.current,
238
+ duration: durationRef.current,
239
+ trackOrder: trackOrderRef.current,
240
+ elements: elementsRef.current,
241
+ selectedKeys: usePlayerStore.getState().selectedElementIds,
242
+ buildSnapTargets,
243
+ audioTracks: dragAudioTracksRef.current,
244
+ });
245
+ },
246
+ [scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
247
+ );
248
+
249
+ // Recompute the trim preview for a pointer x. Shared by the pointermove resize
250
+ // branch and the edge auto-scroll stepper (re-runs as content scrolls under a
251
+ // stationary pointer). computeResizePreview is pure; here we only apply state.
252
+ const applyResizePointer = useCallback(
253
+ (resize: ResizingClipState, clientX: number) => {
254
+ const next = computeResizePreview(resize, clientX, {
255
+ scroll: scrollRef.current,
256
+ pps: ppsRef.current,
257
+ buildSnapTargets,
258
+ });
259
+ const setResizeState = (v: ResizePreviewResult) =>
260
+ setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
261
+
262
+ // Group resize: a capability-clean multi-selection resizes rigidly by one
263
+ // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
264
+ // drives the raw delta and renders from resizingClip state; non-grabbed
265
+ // members preview through the store (their store value stays pristine).
266
+ const grabbedKey = resize.element.key ?? resize.element.id;
267
+ let session = groupResizeRef.current;
268
+ if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
269
+ const members = buildTimelineGroupResizeMembers(
270
+ elementsRef.current,
271
+ usePlayerStore.getState().selectedElementIds,
272
+ grabbedKey,
273
+ resize.edge,
274
+ );
275
+ session = members
276
+ ? {
277
+ grabbedKey,
278
+ edge: resize.edge,
279
+ members,
280
+ changes: [],
281
+ hasChanged: false,
282
+ }
283
+ : null;
284
+ groupResizeRef.current = session;
285
+ }
286
+
287
+ if (!session) {
288
+ setResizeState(next);
289
+ return;
290
+ }
291
+ previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
264
292
  },
265
- [
266
- scrollRef,
267
- ppsRef,
268
- trackOrderRef,
269
- timelineLayersRef,
270
- timelineElementsRef,
271
- buildSnapTargets,
272
- previewGroupMove,
273
- ],
293
+ [scrollRef, ppsRef, buildSnapTargets, updateElement],
274
294
  );
295
+ const applyResizePointerRef = useRef(applyResizePointer);
296
+ applyResizePointerRef.current = applyResizePointer;
275
297
 
276
298
  const stopClipDragAutoScroll = useCallback(() => {
277
299
  clipDragPointerRef.current = null;
@@ -279,48 +301,47 @@ export function useTimelineClipDrag({
279
301
  cancelAnimationFrame(clipDragScrollRaf.current);
280
302
  clipDragScrollRaf.current = 0;
281
303
  }
304
+ // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
305
+ // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
306
+ // touch groupResizeRef — commit reads it after this runs.
307
+ snapTargetsCacheRef.current.clear();
308
+ dragAudioTracksRef.current = null;
282
309
  }, []);
283
310
 
284
311
  const stepClipDragAutoScroll = useCallback(() => {
285
312
  clipDragScrollRaf.current = 0;
286
313
  const drag = draggedClipRef.current;
314
+ const resize = resizingClipRef.current;
287
315
  const pointer = clipDragPointerRef.current;
288
316
  const scroll = scrollRef.current;
289
- if (!drag || !pointer || !scroll) return;
290
-
291
- const rect = scroll.getBoundingClientRect();
292
- const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
293
- if (delta.x === 0 && delta.y === 0) return;
294
-
295
- const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
296
- const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
297
- const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
298
- const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
299
- if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return;
300
-
301
- scroll.scrollLeft = nextScrollLeft;
302
- scroll.scrollTop = nextScrollTop;
303
- setDraggedClip((prev) =>
304
- prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
305
- );
317
+ if ((!drag && !resize) || !pointer || !scroll) return;
318
+ if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
319
+
320
+ if (drag) {
321
+ setDraggedClip((prev) =>
322
+ prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
323
+ );
324
+ } else if (resize) {
325
+ // Re-run the trim preview so the edge keeps tracking while the content
326
+ // scrolls under the stationary pointer (scroll-compensated pointer x).
327
+ applyResizePointerRef.current(resize, pointer.clientX);
328
+ }
306
329
  clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
307
330
  }, [scrollRef, updateDraggedClipPreview]);
308
331
 
309
332
  const syncClipDragAutoScroll = useCallback(
310
333
  (clientX: number, clientY: number) => {
311
334
  clipDragPointerRef.current = { clientX, clientY };
312
- const scroll = scrollRef.current;
313
- if (!scroll) return;
314
- const rect = scroll.getBoundingClientRect();
315
- const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
316
- if (delta.x === 0 && delta.y === 0) {
317
- if (clipDragScrollRaf.current) {
318
- cancelAnimationFrame(clipDragScrollRaf.current);
319
- clipDragScrollRaf.current = 0;
320
- }
321
- return;
322
- }
323
- if (!clipDragScrollRaf.current) {
335
+ const action = resolveTimelineAutoScrollLoopAction(
336
+ scrollRef.current,
337
+ clientX,
338
+ clientY,
339
+ clipDragScrollRaf.current !== 0,
340
+ );
341
+ if (action === "stop") {
342
+ cancelAnimationFrame(clipDragScrollRaf.current);
343
+ clipDragScrollRaf.current = 0;
344
+ } else if (action === "start") {
324
345
  clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
325
346
  }
326
347
  },
@@ -329,12 +350,6 @@ export function useTimelineClipDrag({
329
350
 
330
351
  const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
331
352
  updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
332
- const commitGroupMoveRef = useRef(commitGroupMove);
333
- commitGroupMoveRef.current = commitGroupMove;
334
- const commitGroupResizeRef = useRef(commitGroupResize);
335
- commitGroupResizeRef.current = commitGroupResize;
336
- const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
337
- clearGroupDragSessionsRef.current = clearGroupDragSessions;
338
353
  const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
339
354
  syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
340
355
  const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
@@ -347,121 +362,39 @@ export function useTimelineClipDrag({
347
362
  });
348
363
  };
349
364
 
350
- // fallow-ignore-next-line complexity
351
- const handleWindowPointerMove = (e: PointerEvent) => {
352
- const drag = draggedClipRef.current;
353
- const resize = resizingClipRef.current;
354
- const blocked = blockedClipRef.current;
365
+ /* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */
366
+ const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => {
367
+ const distance = Math.abs(e.clientX - resize.originClientX);
368
+ if (!resize.started && distance < 2) return;
355
369
 
356
- if (resize) {
357
- const distance = Math.abs(e.clientX - resize.originClientX);
358
- if (!resize.started && distance < 2) return;
370
+ setShowPopover(false);
371
+ setRangeSelectionRef.current?.(null);
372
+
373
+ applyResizePointerRef.current(resize, e.clientX);
374
+ // Edge auto-scroll during a trim, exactly like the move branch — lets a
375
+ // right-edge trim keep extending past the current viewport (the stepper
376
+ // re-runs the scroll-compensated preview each frame).
377
+ syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
378
+ };
359
379
 
380
+ const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => {
381
+ const distance = Math.hypot(
382
+ e.clientX - blocked.originClientX,
383
+ e.clientY - blocked.originClientY,
384
+ );
385
+ const threshold = blocked.intent === "move" ? 4 : 2;
386
+ if (!blocked.started && distance < threshold) return;
387
+ if (!blocked.started) {
388
+ blocked.started = true;
389
+ blockedClipRef.current = blocked;
390
+ suppressClickRef.current = true;
360
391
  setShowPopover(false);
361
392
  setRangeSelectionRef.current?.(null);
362
-
363
- const sourceRemaining =
364
- resize.element.sourceDuration != null
365
- ? Math.max(
366
- 0,
367
- (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
368
- Math.max(resize.element.playbackRate ?? 1, 0.1),
369
- )
370
- : Number.POSITIVE_INFINITY;
371
- const normalizedTag = resize.element.tag.toLowerCase();
372
- const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
373
- const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
374
- const maxEnd = resize.element.start + sourceRemaining;
375
- let nextResize = resolveTimelineResize(
376
- {
377
- start: resize.element.start,
378
- duration: resize.element.duration,
379
- originClientX: resize.originClientX,
380
- pixelsPerSecond: ppsRef.current,
381
- minStart: 0,
382
- maxEnd,
383
- playbackStart:
384
- resize.edge === "start" && canSeedPlaybackStart
385
- ? (resize.element.playbackStart ?? 0)
386
- : resize.element.playbackStart,
387
- playbackRate: resize.element.playbackRate,
388
- },
389
- resize.edge,
390
- e.clientX,
391
- );
392
-
393
- const snap = snapResizeEdgeToTargets(
394
- resize.edge,
395
- nextResize.start,
396
- nextResize.duration,
397
- buildSnapTargets(resize.element),
398
- ppsRef.current,
399
- {
400
- minDuration: 0.05,
401
- maxEnd,
402
- maxLeftDelta:
403
- resize.edge === "start" && nextResize.playbackStart != null
404
- ? nextResize.playbackStart / playbackRate
405
- : Number.POSITIVE_INFINITY,
406
- },
407
- );
408
- if (snap.snapTime != null) {
409
- const unsnappedStart = nextResize.start;
410
- nextResize = { ...nextResize, start: snap.start, duration: snap.duration };
411
- if (resize.edge === "start" && nextResize.playbackStart != null) {
412
- const delta = unsnappedStart - snap.start;
413
- nextResize = {
414
- ...nextResize,
415
- playbackStart:
416
- Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
417
- 1000,
418
- };
419
- }
420
- }
421
- const groupResize = previewGroupResize(
422
- resize.element,
423
- selectedElementIdsRef.current,
424
- resize.edge,
425
- nextResize,
426
- );
427
- if (groupResize.active) {
428
- nextResize = groupResize.updates;
429
- }
430
- setResizingClip((prev) =>
431
- prev
432
- ? {
433
- ...prev,
434
- started: true,
435
- previewStart: nextResize.start,
436
- previewDuration: nextResize.duration,
437
- previewPlaybackStart: nextResize.playbackStart,
438
- snapGuideTime: snap.snapTime,
439
- snapGuideKind: snap.snapKind,
440
- }
441
- : prev,
442
- );
443
- return;
444
- }
445
-
446
- if (blocked) {
447
- const distance = Math.hypot(
448
- e.clientX - blocked.originClientX,
449
- e.clientY - blocked.originClientY,
450
- );
451
- const threshold = blocked.intent === "move" ? 4 : 2;
452
- if (!blocked.started && distance < threshold) return;
453
- if (!blocked.started) {
454
- blocked.started = true;
455
- blockedClipRef.current = blocked;
456
- suppressClickRef.current = true;
457
- setShowPopover(false);
458
- setRangeSelectionRef.current?.(null);
459
- onBlockedEditAttempt?.(blocked.element, blocked.intent);
460
- }
461
- return;
393
+ onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
462
394
  }
395
+ };
463
396
 
464
- if (!drag) return;
397
+ const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => {
465
398
  const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
466
399
  if (!drag.started && distance < 4) return;
467
400
 
@@ -474,60 +407,77 @@ export function useTimelineClipDrag({
474
407
  syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
475
408
  };
476
409
 
477
- // fallow-ignore-next-line complexity
478
- const handleWindowPointerUp = () => {
479
- stopClipDragAutoScrollRef.current();
480
-
410
+ const handleWindowPointerMove = (e: PointerEvent) => {
481
411
  const resize = resizingClipRef.current;
482
- if (resize) {
483
- resizingClipRef.current = null;
484
- setResizingClip(null);
485
- if (!resize.started) return;
412
+ if (resize) return handleResizePointerMove(e, resize);
413
+ const blocked = blockedClipRef.current;
414
+ if (blocked) return handleBlockedPointerMove(e, blocked);
415
+ const drag = draggedClipRef.current;
416
+ if (drag) handleDragPointerMove(e, drag);
417
+ };
486
418
 
487
- suppressClickRef.current = true;
488
- clearSuppressedClick();
419
+ /* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
420
+ const commitResizePointerUp = (resize: ResizingClipState) => {
421
+ resizingClipRef.current = null;
422
+ setResizingClip(null);
423
+ const groupSession = groupResizeRef.current;
424
+ groupResizeRef.current = null;
425
+ if (!resize.started) {
426
+ // No preview ran, so no group store-mutation to undo; guard is defensive.
427
+ if (groupSession) restoreGroupResizeMembers(groupSession);
428
+ return;
429
+ }
489
430
 
490
- if (commitGroupResizeRef.current(resize.element)) return;
431
+ suppressClickRef.current = true;
432
+ clearSuppressedClick();
491
433
 
492
- const hasChanged =
493
- resize.previewStart !== resize.element.start ||
494
- resize.previewDuration !== resize.element.duration ||
495
- resize.previewPlaybackStart !== resize.element.playbackStart;
496
- if (!hasChanged) return;
434
+ if (groupSession) {
435
+ commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
436
+ return;
437
+ }
497
438
 
498
- updateElement(resize.element.key ?? resize.element.id, {
439
+ const hasChanged =
440
+ resize.previewStart !== resize.element.start ||
441
+ resize.previewDuration !== resize.element.duration ||
442
+ resize.previewPlaybackStart !== resize.element.playbackStart;
443
+ if (!hasChanged) return;
444
+
445
+ const resizeKey = resize.element.key ?? resize.element.id;
446
+ const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
447
+ updateElement(resizeKey, {
448
+ start: resize.previewStart,
449
+ duration: resize.previewDuration,
450
+ playbackStart: resize.previewPlaybackStart,
451
+ });
452
+
453
+ Promise.resolve(
454
+ onResizeElementRef.current?.(resize.element, {
499
455
  start: resize.previewStart,
500
456
  duration: resize.previewDuration,
501
457
  playbackStart: resize.previewPlaybackStart,
502
- });
503
-
504
- Promise.resolve(
505
- onResizeElementRef.current?.(resize.element, {
506
- start: resize.previewStart,
507
- duration: resize.previewDuration,
508
- playbackStart: resize.previewPlaybackStart,
509
- }),
510
- ).catch((error) => {
511
- updateElement(resize.element.key ?? resize.element.id, {
512
- start: resize.element.start,
513
- duration: resize.element.duration,
514
- playbackStart: resize.element.playbackStart,
515
- });
516
- console.error("[Timeline] Failed to persist clip resize", error);
517
- });
518
- return;
519
- }
458
+ }),
459
+ ).catch((error) => {
460
+ rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
461
+ {
462
+ key: resizeKey,
463
+ updates: {
464
+ start: resize.element.start,
465
+ duration: resize.element.duration,
466
+ playbackStart: resize.element.playbackStart,
467
+ },
468
+ },
469
+ ]);
470
+ console.error("[Timeline] Failed to persist clip resize", error);
471
+ });
472
+ };
520
473
 
521
- const blocked = blockedClipRef.current;
522
- if (blocked) {
523
- blockedClipRef.current = null;
524
- if (!blocked.started) return;
525
- clearSuppressedClick();
526
- return;
527
- }
474
+ const finishBlockedPointerUp = (blocked: BlockedClipState) => {
475
+ blockedClipRef.current = null;
476
+ if (!blocked.started) return;
477
+ clearSuppressedClick();
478
+ };
528
479
 
529
- const drag = draggedClipRef.current;
530
- if (!drag) return;
480
+ const commitDragPointerUp = (drag: DraggedClipState) => {
531
481
  draggedClipRef.current = null;
532
482
  setDraggedClip(null);
533
483
  if (!drag.started) return;
@@ -535,40 +485,84 @@ export function useTimelineClipDrag({
535
485
  suppressClickRef.current = true;
536
486
  clearSuppressedClick();
537
487
 
538
- if (commitGroupMoveRef.current(drag.element)) return;
488
+ // Commit the drag — insert (new track), main-track ripple (reflow contiguous),
489
+ // a plain single-clip move, or a multi-selection move (every selected clip
490
+ // shifts by the dragged clip's time delta). See timelineClipDragCommit.
491
+ commitDraggedClipMove(drag, {
492
+ elements: elementsRef.current,
493
+ trackOrder: trackOrderRef.current,
494
+ updateElement,
495
+ onMoveElement: onMoveElementRef.current,
496
+ onMoveElements: onMoveElementsRef.current,
497
+ selectedKeys: usePlayerStore.getState().selectedElementIds,
498
+ // Lane ↔ stacking: engages only when the timeline layer provisions both
499
+ // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
500
+ readZIndex: readZIndexRef.current,
501
+ onStackingPatches: onStackingPatchesRef.current,
502
+ });
503
+ };
539
504
 
540
- const hasStackingReorder =
541
- drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
542
- const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
543
- if (!hasChanged) return;
505
+ const handleWindowPointerUp = () => {
506
+ stopClipDragAutoScrollRef.current();
544
507
 
545
- updateElement(drag.element.key ?? drag.element.id, {
546
- start: drag.previewStart,
547
- });
508
+ const resize = resizingClipRef.current;
509
+ if (resize) return commitResizePointerUp(resize);
548
510
 
549
- Promise.resolve(
550
- onMoveElementRef.current?.(drag.element, {
551
- start: drag.previewStart,
552
- track: drag.element.track,
553
- stackingReorder: drag.previewStackingReorder,
554
- }),
555
- ).catch((error) => {
556
- updateElement(drag.element.key ?? drag.element.id, {
557
- start: drag.element.start,
558
- });
559
- console.error("[Timeline] Failed to persist clip move", error);
511
+ const blocked = blockedClipRef.current;
512
+ if (blocked) return finishBlockedPointerUp(blocked);
513
+
514
+ const drag = draggedClipRef.current;
515
+ if (!drag) {
516
+ // Escape-cancel leaves the click suppressor armed so the click this
517
+ // pointerup generates can't act on the clip; disarm it right after.
518
+ if (suppressClickRef.current) clearSuppressedClick();
519
+ return;
520
+ }
521
+ commitDragPointerUp(drag);
522
+ };
523
+
524
+ // Escape cancels the in-progress gesture: no commit, no undo entry. The
525
+ // previews live only in the drag/resize state (the store is untouched
526
+ // until the pointerup commit), so clearing them restores the pre-drag
527
+ // timeline. Clip drags never take pointer capture (all tracking runs on
528
+ // these window listeners), so there is no capture to release; the null
529
+ // refs make the remaining pointermove/pointerup a no-op.
530
+ const handleWindowKeyDown = (e: KeyboardEvent) => {
531
+ const decision = resolveTimelineDragEscape({
532
+ key: e.key,
533
+ drag: draggedClipRef.current,
534
+ resize: resizingClipRef.current,
535
+ blocked: blockedClipRef.current,
560
536
  });
537
+ if (!decision.cancel) return;
538
+ e.preventDefault();
539
+ e.stopPropagation();
540
+ stopClipDragAutoScrollRef.current();
541
+ draggedClipRef.current = null;
542
+ setDraggedClip(null);
543
+ resizingClipRef.current = null;
544
+ setResizingClip(null);
545
+ // Undo any group-resize preview store-mutation (non-grabbed members) so the
546
+ // cancelled gesture restores the pre-drag timeline, like the single-clip path.
547
+ const groupSession = groupResizeRef.current;
548
+ groupResizeRef.current = null;
549
+ if (groupSession) restoreGroupResizeMembers(groupSession);
550
+ blockedClipRef.current = null;
551
+ // The pointer is usually still down; keep the suppressor armed until the
552
+ // eventual pointerup (which disarms it) so its click can't reselect.
553
+ if (decision.suppressClick) suppressClickRef.current = true;
561
554
  };
562
555
 
563
556
  window.addEventListener("pointermove", handleWindowPointerMove);
564
557
  window.addEventListener("pointerup", handleWindowPointerUp);
565
558
  window.addEventListener("pointercancel", handleWindowPointerUp);
559
+ window.addEventListener("keydown", handleWindowKeyDown, true);
566
560
  return () => {
567
- clearGroupDragSessionsRef.current();
568
561
  stopClipDragAutoScrollRef.current();
569
562
  window.removeEventListener("pointermove", handleWindowPointerMove);
570
563
  window.removeEventListener("pointerup", handleWindowPointerUp);
571
564
  window.removeEventListener("pointercancel", handleWindowPointerUp);
565
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
572
566
  };
573
567
  });
574
568