@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,1828 @@
1
+ /**
2
+ * Canvas timeline gesture machine (SP5 T5) — every pointer gesture the timeline
3
+ * supports, as a pure reducer.
4
+ *
5
+ * ── Why a machine, and why pure ──────────────────────────────────────────
6
+ * The DOM timeline spread its gestures across four components and two hooks,
7
+ * each attaching its own `document` mousemove/mouseup pair and each holding a
8
+ * little private state (`dragStarted`, `lastUpdated`, `snappedTo`). That works
9
+ * when the browser addresses every event for you. On one canvas surface there
10
+ * is a single pointer stream and no elements, so the state has to be explicit —
11
+ * and once it is explicit it may as well be testable.
12
+ *
13
+ * So: no DOM in this file. Points arrive already in surface-space CSS pixels,
14
+ * everything the machine needs to decide comes in as `PointerContext`, and
15
+ * everything it wants to happen goes out as `PointerEffect[]` for the component
16
+ * to perform. `pointerReducer` is a pure function of (state, event) — every
17
+ * transition below is a unit test, not a browser session.
18
+ *
19
+ * ── Parity ───────────────────────────────────────────────────────────────
20
+ * The DOM rows are the spec, and the behaviours worth naming because they are
21
+ * NOT guessable are:
22
+ *
23
+ * - **Selection happens on release, not on press.** The DOM selected in a
24
+ * `click` handler, which fires after mouseup and was suppressed whenever the
25
+ * press had turned into a drag. Same here: a press that stays under the drag
26
+ * threshold selects, one that crosses it does not.
27
+ * - **A plain click on an unselected clip also seeks** to the clicked time,
28
+ * unsnapped; an additive click, or a click on an already-selected clip, does
29
+ * not (VisualTrackRow.tsx's `if (!additive && !isSel)`). Audio bars never
30
+ * seek on click — AudioTrackRow has no such line. A caption seeks too, but
31
+ * to its own start plus half a frame rather than to the clicked time; the
32
+ * reasoning is at the seek itself, in `pointerUp`.
33
+ * - **Additive means shift OR meta OR ctrl**, and the resulting selection is
34
+ * computed by `toggleSelection`, which the host still owns: the machine emits
35
+ * `{type:'select', id, additive}` so Timeline's existing `handleSelectItem`
36
+ * applies it, including clearing the caption selection. The item↔caption
37
+ * mutual exclusivity therefore cannot drift.
38
+ * - **Per-move `onProjectChange`, commit-time `onOverlayEdit`** — the callback
39
+ * split every DOM row uses. Emitted here as `projectChange` and `commit`.
40
+ * - **Ripple mode collapses gaps on every trim move**, exactly where
41
+ * VisualTrackRow does it (after the multi-selection delta, before the
42
+ * callback), and nowhere else: moves and audio edits never collapse.
43
+ * - **Trims recompute from the project as it was when the press began**;
44
+ * moves accumulate from the last emitted project (the DOM's `lastUpdated`),
45
+ * because the cross-track search needs to see the tracks it has been pruning.
46
+ *
47
+ * ── The four trim-op bindings (plan decision 8) ──────────────────────────
48
+ * plain edge-drag trim (ripple-aware, the DOM behaviour)
49
+ * Alt + edge-drag roll the shared boundary with the adjacent neighbour
50
+ * Alt + body-drag slip the source window, item stays put
51
+ * Cmd/Ctrl + body-drag slide the item, neighbours absorb it
52
+ * Modifiers are read at press time, so releasing Alt mid-drag doesn't change
53
+ * what the gesture is. Cmd/Ctrl is also the additive-selection modifier, which
54
+ * doesn't collide: that path only fires for presses that never became drags.
55
+ */
56
+
57
+ import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
58
+ import type { Project } from '../../../types'
59
+ import { reflowMagneticLanes } from '../../audioMagnet'
60
+ import { laneOf, normalizeCaptionLanes, resolveDropLane, sameLaneNeighbours } from '../../captionLanes'
61
+ import { collapseGaps, rollEdit, slideItem, slipItem } from '../../cuts'
62
+ import { canKeyframe, enableKeyframing, moveKeyframe, setKeyframe, transformProps, valueAt } from '../../keyframeOps'
63
+ import { applyMoveDeltaToSelection, applyResizeDeltaToSelection } from '../multiSelectOps'
64
+ import { AUDIO_LANE_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, computeDerivedTiming, groupAudioLanes, mapTrackItems, moveItemAcrossTracks, normalizeTracks, resolveTargetTrackIdx, trackItems, updateAudioTrack } from '../timeline-model'
65
+ import { DRAG_THRESHOLD_PX, computeResizedItem, resizeWindowedItem, type Draggable } from '../useItemDragDrop'
66
+ import type { TimelineLayout } from './draw'
67
+ import { keyframeUnionTimes } from './keyframe-strip'
68
+ import {
69
+ PLAYHEAD_GRAB_PX,
70
+ hitTest,
71
+ isCaptionHit,
72
+ isEdgeHit,
73
+ isEmptyHit,
74
+ itemsInRect,
75
+ normalizeRect,
76
+ type HitResult,
77
+ type HitTestOptions,
78
+ type Point,
79
+ type SurfaceRect,
80
+ } from './hit-test'
81
+ import {
82
+ DEFAULT_SNAP_CONFIG,
83
+ applySnap,
84
+ createSnapState,
85
+ snapPointsExcluding,
86
+ snapPointsForSpan,
87
+ type SnapConfig,
88
+ type SnapPoint,
89
+ type SnapResult,
90
+ type SnapState,
91
+ type SnapStrength,
92
+ } from './snap'
93
+ import { timeToX, xToTime, type Viewport } from './viewport'
94
+
95
+ // ── Inputs ───────────────────────────────────────────────────────────────
96
+
97
+ export interface Modifiers {
98
+ shift: boolean
99
+ alt: boolean
100
+ meta: boolean
101
+ ctrl: boolean
102
+ }
103
+
104
+ export const NO_MODIFIERS: Modifiers = { shift: false, alt: false, meta: false, ctrl: false }
105
+
106
+ /** The DOM's additive-selection test, verbatim from both row components. */
107
+ export function isAdditive(m: Modifiers): boolean {
108
+ return m.shift || m.meta || m.ctrl
109
+ }
110
+
111
+ /** Everything the machine needs to know about the world, injected per event so
112
+ * the machine never holds a stale project or viewport of its own. */
113
+ export interface PointerContext {
114
+ project: Project
115
+ layout: TimelineLayout
116
+ viewport: Viewport
117
+ selectedIds: readonly string[]
118
+ /** Clip/audio boundaries — `computeDerivedTiming(project).snapBoundaries`. */
119
+ snapBoundaries: readonly number[]
120
+ /** Content duration plus the timeline's drag headroom. */
121
+ totalDuration: number
122
+ rippleMode: boolean
123
+ /** Where the playhead is, so item gestures can snap to it. */
124
+ playheadTime: number
125
+ /** The project's frame rate. Needed for exactly one thing: a click on a
126
+ * caption seeks half a frame INTO the segment rather than to its start —
127
+ * see the seek in `pointerUp`. No gesture's arithmetic uses it. */
128
+ fps: number
129
+ snapConfig?: SnapConfig
130
+ hitTestOptions?: HitTestOptions
131
+ /** The host's current keyframe selection, or null/undefined for none. Read
132
+ * ONLY by `pointerUp`'s `keyframe-move` branch, to decide whether a
133
+ * diamond retime should carry the selection to its new `t` — the machine
134
+ * is otherwise stateless about selection (see `KeyframeSelection`), this
135
+ * is the one gesture where "was the thing I just dragged the selected
136
+ * one?" has to be answered mid-commit rather than left to the host. */
137
+ selectedKeyframe?: KeyframeSelection | null
138
+ }
139
+
140
+ export type PointerMachineEvent =
141
+ | { type: 'pointerDown'; point: Point; modifiers: Modifiers; ctx: PointerContext }
142
+ | { type: 'pointerMove'; point: Point; modifiers: Modifiers; ctx: PointerContext }
143
+ | { type: 'pointerUp'; point: Point; modifiers: Modifiers; ctx: PointerContext }
144
+ | { type: 'doubleClick'; point: Point; modifiers: Modifiers; ctx: PointerContext }
145
+ /** Pointer lost (leave, blur, unmount). Drops the gesture WITHOUT committing;
146
+ * whatever the last `projectChange` emitted stands, exactly as it would if
147
+ * the DOM path's mouseup listener never fired. */
148
+ | { type: 'cancel' }
149
+
150
+ // ── Outputs ──────────────────────────────────────────────────────────────
151
+
152
+ export type Cursor = 'default' | 'pointer' | 'grab' | 'grabbing' | 'ew-resize' | 'nwse-resize' | 'nesw-resize' | 'crosshair'
153
+
154
+ export type PointerEffect =
155
+ /** `clock.set` — move the playhead. */
156
+ | { type: 'seek'; time: number }
157
+ /** Timeline's `handleSelectItem(id, additive)`. */
158
+ | { type: 'select'; id: string | null; additive: boolean }
159
+ /** `onProjectChange` — a live, uncommitted edit. Fires once per move. */
160
+ | { type: 'projectChange'; project: Project }
161
+ /** `onOverlayEdit` — the gesture is finished, persist it. */
162
+ | { type: 'commit'; project: Project }
163
+ /** Double-click on a clip or audio bar — `onInspectClip` / `onInspectAudio`. */
164
+ | { type: 'inspect'; target: 'visual' | 'audio'; id: string }
165
+ /** Double-click on a caption block. Deliberately NOT `inspect`: a caption has
166
+ * no inspector dialog — this routes to focusing its row in the transcript
167
+ * sidebar, which is where caption text is edited now that the inline
168
+ * contentEditable row is gone. */
169
+ | { type: 'editCaption'; id: string }
170
+ /** The surface's CSS cursor. Emitted only when it changes. */
171
+ | { type: 'cursor'; cursor: Cursor }
172
+ /** Where to draw the snap guide and how hard it is holding, or nulls to take
173
+ * it down. Emitted only when it CHANGES — a drag held against one boundary
174
+ * emits once on capture and once on release, not sixty times a second. */
175
+ | { type: 'snapGuide'; time: number | null; strength: SnapStrength | null }
176
+ /** The rubber-band box to paint, or null to take it down. */
177
+ | { type: 'marquee'; rect: SurfaceRect | null }
178
+ /** What a finished marquee caught. Separate from `select` because a marquee
179
+ * replaces (or extends) the whole selection in one step — replaying it as N
180
+ * single `select` effects would make the host apply N re-renders and, worse,
181
+ * would need the first to be non-additive and the rest additive. */
182
+ | { type: 'selectMany'; ids: string[]; additive: boolean }
183
+ /** A keyframe diamond became the selected keyframe. Item-relative `t`,
184
+ * matching `Keyframe.t`. The host holds this selection; the machine is
185
+ * stateless about it, exactly as it is about item selection. */
186
+ | { type: 'selectKeyframe'; itemId: string; t: number }
187
+
188
+ /** The host's record of which diamond is selected. A diamond is a time on an
189
+ * item — the props at that time are derived when acted on, never stored here. */
190
+ export interface KeyframeSelection {
191
+ itemId: string
192
+ /** ITEM-RELATIVE seconds, matching `Keyframe.t` and `HitResult.kfT`. Never
193
+ * absolute timeline time. */
194
+ t: number
195
+ }
196
+
197
+ // ── State ────────────────────────────────────────────────────────────────
198
+
199
+ export type GestureKind =
200
+ | 'move'
201
+ | 'trim'
202
+ | 'roll'
203
+ | 'slip'
204
+ | 'slide'
205
+ | 'audio-move'
206
+ | 'audio-trim'
207
+ /** Dragging an audio bar's fade-in or fade-out grip. See `applyAudioFade`. */
208
+ | 'audio-fade'
209
+ /** Dragging a keyframe-strip diamond to retime it. See `applyKeyframeMove`. */
210
+ | 'keyframe-move'
211
+ /** Re-timing a caption block. Rides the clip group-move path, so a mixed
212
+ * clip+caption selection travels as one body and lands as one undo. */
213
+ | 'caption-move'
214
+ /** Dragging one caption block's in/out edge. Single-segment: multi-caption
215
+ * trim is out of scope for v1. */
216
+ | 'caption-trim'
217
+ /** Dragging the playhead. Lives on the ruler strip only. */
218
+ | 'scrub'
219
+ /** Dragging a rubber-band box across empty track area to select. */
220
+ | 'marquee'
221
+
222
+ export interface Press {
223
+ origin: Point
224
+ /** The timeline time under `origin.x` when the press landed.
225
+ *
226
+ * Every gesture measures its travel from HERE, not from `origin.x`, because
227
+ * a raw pixel delta is blind to the view moving underneath it: edge
228
+ * auto-scroll pans the viewport while the pointer is held still, and
229
+ * `point.x - origin.x` is unchanged by that pan, so the dragged item would
230
+ * sit at a fixed time while the timeline slid out from under the cursor —
231
+ * the clip visibly drifting away from the hand holding it. Anchoring in
232
+ * time makes the same held pointer resolve to a later time on every panned
233
+ * frame, which is exactly what keeps the item under the cursor. */
234
+ originTime: number
235
+ modifiers: Modifiers
236
+ hit: HitResult
237
+ /** The project as it stood when the press began. Trims and the three
238
+ * neighbour-aware ops recompute from here on every move, so dragging back and
239
+ * forth cannot compound. */
240
+ baseProject: Project
241
+ /** Was the pressed item already selected? Drives the DOM's conditional seek
242
+ * on a plain click. */
243
+ wasSelected: boolean
244
+ /** The project's boundaries as they stood when the press began, already
245
+ * tiered by `tieredBoundaries` against the row pressed on.
246
+ *
247
+ * Captured, never read live. The host echoes every `projectChange` back
248
+ * through Timeline's re-render, and that re-render recomputes the boundary
249
+ * set from the echoed project — so mid-gesture the live list contains the
250
+ * dragged item's own CURRENT edges, not just its press-time ones.
251
+ * `itemSnapPoints` only excludes the press-time pair, so a gesture reading
252
+ * live boundaries would find its own last position back in the magnet list
253
+ * and snap to itself. Capturing once, here, is what makes that exclusion
254
+ * complete.
255
+ *
256
+ * This is the DEFAULT tiering — the one every gesture except a single-item
257
+ * visual/audio MOVE uses as-is. Those two re-tier against the row the item
258
+ * is currently over instead of the row it started on (see `itemSnapPoints`'s
259
+ * `current` parameter); they do so by re-running `tieredBoundaries` fresh
260
+ * each move, off `baseProject` below (not off this field), so the id-based
261
+ * exclusion stays exact and the recompute never reads a live, re-rendered
262
+ * project either. */
263
+ snapBoundaries: readonly SnapPoint[]
264
+ }
265
+
266
+ export type MachineState =
267
+ | { kind: 'idle'; cursor: Cursor }
268
+ | { kind: 'pressed'; cursor: Cursor; press: Press }
269
+ | {
270
+ kind: 'dragging'
271
+ cursor: Cursor
272
+ press: Press
273
+ gesture: GestureKind
274
+ snap: SnapState
275
+ /** The boundary the guide is currently drawn on, or null. Held here (not
276
+ * derived from `snap`) because for a span gesture `snap.snappedTo` is a
277
+ * candidate START, which is not where the guide belongs — see
278
+ * `spanSnapGuide`. */
279
+ guide: SnapGuide | null
280
+ /** The most recent project this gesture emitted — what a commit persists. */
281
+ lastProject: Project
282
+ /** Latched once the gesture has pulled clear of where it started, and
283
+ * never cleared. Arms the origin boundary so a butted clip can snap back
284
+ * onto its neighbour — see `originGuard`. */
285
+ escaped: boolean
286
+ /** `keyframe-move` only: the most recent landed `toT` this gesture has
287
+ * produced (see `Applied.keyframeLandedT`), carried forward across a
288
+ * no-op frame rather than clobbered by `undefined`. `pointerUp` reads
289
+ * this to follow the selection onto the diamond's new time. Undefined
290
+ * for every gesture but `keyframe-move`. */
291
+ keyframeT?: number
292
+ }
293
+
294
+ export function initialMachineState(): MachineState {
295
+ return { kind: 'idle', cursor: 'default' }
296
+ }
297
+
298
+ // ── Helpers ──────────────────────────────────────────────────────────────
299
+
300
+ const EPSILON = 1e-6
301
+
302
+ function clamp(v: number, lo: number, hi: number): number {
303
+ return Math.max(lo, Math.min(hi, v))
304
+ }
305
+
306
+ function travelled(a: Point, b: Point): number {
307
+ return Math.hypot(a.x - b.x, a.y - b.y)
308
+ }
309
+
310
+ /** Cursor for a resting pointer over a hit — the DOM rows' affordances:
311
+ * `cursor-ew-resize` on edge handles, `cursor-grab` on item bodies.
312
+ *
313
+ * A fade GRIP is a resize too, but of the fade envelope rather than the clip,
314
+ * and it sits at a top CORNER — so it gets a DIAGONAL resize cursor pointing
315
+ * along that corner (`nwse-resize` for a top-left fade-in grip, `nesw-resize`
316
+ * for a top-right fade-out grip). That keeps it distinguishable from an edge
317
+ * trim's horizontal `ew-resize`: both read as "drag to resize", but you can
318
+ * tell by the cursor whether you've grabbed the fade or the clip edge.
319
+ *
320
+ * Empty timeline is the plain arrow, not `pointer`. A hand cursor promises a
321
+ * thing you can click, and the gaps between clips are not a thing — they are
322
+ * where you drag out a selection. `pointer` there read as "everything on this
323
+ * surface is a button", which is the opposite of what a track area is.
324
+ * The ruler keeps `ew-resize`, because scrubbing IS a horizontal drag. */
325
+ export function cursorForHit(hit: HitResult): Cursor {
326
+ switch (hit.kind) {
327
+ case 'audio-fade':
328
+ return hit.side === 'out' ? 'nesw-resize' : 'nwse-resize'
329
+ // A diamond only ever moves horizontally along its own item — the same
330
+ // retime-in-place gesture a trim edge does, so it gets the same cursor.
331
+ case 'keyframe':
332
+ return 'ew-resize'
333
+ case 'item-edge':
334
+ case 'audio-edge':
335
+ case 'caption-edge':
336
+ return 'ew-resize'
337
+ case 'item-body':
338
+ case 'audio-body':
339
+ case 'caption-body':
340
+ return 'grab'
341
+ case 'ruler':
342
+ return 'ew-resize'
343
+ default:
344
+ return 'default'
345
+ }
346
+ }
347
+
348
+ function cursorForGesture(gesture: GestureKind): Cursor {
349
+ switch (gesture) {
350
+ case 'audio-fade':
351
+ return 'nwse-resize' // the diagonal fade-grip cursor, held through the drag
352
+ case 'trim':
353
+ case 'roll':
354
+ case 'audio-trim':
355
+ case 'caption-trim':
356
+ case 'scrub':
357
+ case 'keyframe-move':
358
+ return 'ew-resize'
359
+ case 'marquee':
360
+ return 'crosshair'
361
+ default:
362
+ return 'grabbing'
363
+ }
364
+ }
365
+
366
+ /** What a press on `hit` with `modifiers` becomes once it crosses the drag
367
+ * threshold. Null for hits that never start a drag gesture. */
368
+ export function resolveGesture(hit: HitResult, modifiers: Modifiers): GestureKind | null {
369
+ switch (hit.kind) {
370
+ case 'item-edge':
371
+ return modifiers.alt ? 'roll' : 'trim'
372
+ case 'item-body':
373
+ if (modifiers.alt) return 'slip'
374
+ return modifiers.meta || modifiers.ctrl ? 'slide' : 'move'
375
+ // Audio has no roll/slip/slide — those ops are visual-track vocabulary in
376
+ // cuts.ts — so modifiers fall through to the plain gesture.
377
+ case 'audio-edge':
378
+ return 'audio-trim'
379
+ case 'audio-body':
380
+ return 'audio-move'
381
+ // No modifiers of its own — a fade grip does exactly one thing.
382
+ case 'audio-fade':
383
+ return 'audio-fade'
384
+ // A keyframe diamond does exactly one thing too — retime.
385
+ case 'keyframe':
386
+ return 'keyframe-move'
387
+ // Captions have no roll/slip/slide either — there is no source window to
388
+ // slip and no neighbour to roll against — so modifiers fall through here
389
+ // exactly as they do for audio.
390
+ case 'caption-edge':
391
+ return 'caption-trim'
392
+ case 'caption-body':
393
+ return 'caption-move'
394
+ // Empty track area drags out a selection box. The ruler is handled on
395
+ // press (it scrubs immediately, so it never reaches this resolution), and
396
+ // is deliberately absent here.
397
+ case 'empty-row':
398
+ case 'empty-lane':
399
+ case 'background':
400
+ return 'marquee'
401
+ default:
402
+ return null
403
+ }
404
+ }
405
+
406
+ /** Snap targets for a gesture that moves an item: clip and audio boundaries
407
+ * and the playhead — minus whatever the gesture is itself dragging, which
408
+ * would otherwise pin it to where it started.
409
+ *
410
+ * Boundaries come from `press.snapBoundaries`, captured once at press time,
411
+ * NOT `ctx.snapBoundaries`. The host re-derives `ctx.snapBoundaries` from
412
+ * every echoed `projectChange`, so a live read would put the item's own
413
+ * moving edges back into the magnet list mid-gesture (see the `Press.
414
+ * snapBoundaries` doc). The playhead is read from `ctx` because it cannot
415
+ * change during an item gesture.
416
+ *
417
+ * `current`, when passed, overrides which row is tiered STRONG: instead of
418
+ * `press.snapBoundaries` (frozen at the press-time row), this re-runs
419
+ * `tieredBoundaries` fresh against `press.baseProject` — still a frozen
420
+ * project, so the id-based exclusion (`press.hit.itemId`) stays exact and
421
+ * this is no less immune to the self-snap problem above — but tiered
422
+ * against the row the caller says the item is CURRENTLY over rather than
423
+ * the row it started on. Only a single-item visual/audio MOVE passes this;
424
+ * every other gesture stays on the press-time row (see call sites). */
425
+ function itemSnapPoints(
426
+ ctx: PointerContext,
427
+ press: Press,
428
+ exclude: readonly number[],
429
+ current?: { trackIdx?: number; laneIdx?: number },
430
+ ): SnapPoint[] {
431
+ const boundaries = current
432
+ ? tieredBoundaries(press.baseProject, current.trackIdx, current.laneIdx, press.hit.itemId)
433
+ : press.snapBoundaries
434
+ // The playhead is STRONG regardless of tier: it belongs to no track, and
435
+ // parking a cut on it is always a deliberate act rather than an accident of
436
+ // what happens to be on the row above.
437
+ const points: SnapPoint[] = [...boundaries, { time: ctx.playheadTime, strength: 'strong' }]
438
+ return snapPointsExcluding(points, exclude)
439
+ }
440
+
441
+ /**
442
+ * Every boundary in the project, tiered against the row the gesture is working
443
+ * on. Captured once per press for most gestures (see `Press.snapBoundaries`);
444
+ * a single-item visual/audio MOVE instead calls this fresh on every move,
445
+ * with the CURRENT row (see `itemSnapPoints`'s `current` parameter).
446
+ *
447
+ * "Own track" used to mean the row the press LANDED on, held fixed for the
448
+ * whole gesture even once a cross-track move carried the item somewhere else
449
+ * — deliberately, to avoid the magnets under the cursor re-ranking (and the
450
+ * clip appearing to lurch) the instant it crossed a row boundary. That
451
+ * decision is REVERSED for a single-item move: dragging a clip onto another
452
+ * track and getting only the origin track's strong magnets reads as "snapping
453
+ * doesn't work over here", which is worse than the lurch it was avoiding — so
454
+ * for `move`/`audio-move` the strong tier now follows the row the item is
455
+ * CURRENTLY over, and the origin row's magnets fall back to weak the moment
456
+ * the drag has crossed into a new one. Every other gesture (trim, roll, slip,
457
+ * slide, the two audio/caption trims, scrub) never changes row mid-gesture,
458
+ * so this reversal is invisible to them — they still tier once, at press time,
459
+ * against the row they're on for their entire run.
460
+ *
461
+ * "The row it is CURRENTLY over" means the row `moveItemAcrossTracks` actually
462
+ * PLACES it on, which is not the row the vertical travel points at — see
463
+ * `applyMove`'s call site and `resolveTargetTrackIdx`. A multi-select move is
464
+ * exempt for a different reason: `applyMoveDeltaToSelection` shifts a group in
465
+ * place and never changes anyone's track, so a group's current row IS its
466
+ * press-time row and `applyGroupMove` correctly stays on the frozen tier.
467
+ *
468
+ * Audio lanes tier against the LANE, matching how `hitTest` addresses them: a
469
+ * lane can hold several tracks, and to an editor they are one row.
470
+ *
471
+ * `skipId` drops the dragged item's OWN two boundaries, by identity rather than
472
+ * by value. That distinction is the whole fix for butted clips: narration is
473
+ * recorded as takes that butt end-to-end, so a voice clip's start is numerically
474
+ * equal to its neighbour's end, and the old value-based exclusion in
475
+ * `itemSnapPoints` deleted BOTH — handing every butted clip a timeline where the
476
+ * one boundary it most wants to snap to had been quietly removed. Excluding by
477
+ * identity keeps the neighbour's boundary in the set; `originGuard` then handles
478
+ * the separate problem of not being glued to it at rest.
479
+ */
480
+ function tieredBoundaries(
481
+ project: Project,
482
+ trackIdx?: number,
483
+ laneIdx?: number,
484
+ skipId?: string,
485
+ ): SnapPoint[] {
486
+ const points: SnapPoint[] = []
487
+ trackItems(project).forEach((items, idx) => {
488
+ const strength: SnapStrength = idx === trackIdx ? 'strong' : 'weak'
489
+ for (const item of items) {
490
+ if (item.id === skipId) continue
491
+ points.push({ time: item.start, strength }, { time: item.end, strength })
492
+ }
493
+ })
494
+ // Grouped, not read off `track.lane` directly: a track with no `lane` gets
495
+ // an auto-assigned one, so a raw `track.lane ?? 0` would file every
496
+ // unlabelled track under lane 0 and call them all same-lane. `hitTest`
497
+ // addresses lanes through this same grouping, and the two have to agree or
498
+ // the tier is assigned against a row the gesture isn't on.
499
+ for (const lane of groupAudioLanes(project.audio?.tracks ?? [], computeDerivedTiming(project).contentDuration)) {
500
+ const strength: SnapStrength = lane.laneIndex === laneIdx ? 'strong' : 'weak'
501
+ for (const track of lane.tracks) {
502
+ if (track.id === skipId) continue
503
+ points.push({ time: track.start, strength }, { time: track.end, strength })
504
+ }
505
+ }
506
+ return points
507
+ }
508
+
509
+ /**
510
+ * How far this gesture has travelled since the press, in TIMELINE seconds.
511
+ *
512
+ * Measured as "the time under the cursor now, minus the time under the cursor
513
+ * at press" rather than as a pixel delta divided by the scale. The two agree
514
+ * exactly while the viewport holds still, and disagree by the scroll travelled
515
+ * the moment it does not — which is the whole point. Edge auto-scroll pans the
516
+ * view while the pointer is parked in the edge band, re-feeding the machine
517
+ * the SAME screen point every frame; a pixel delta would report no movement at
518
+ * all for those frames, pinning the dragged item to one time while the
519
+ * timeline slid past it. Reading through the live viewport instead means each
520
+ * panned frame resolves that same point to a later time, and whatever the
521
+ * gesture is moving stays under the cursor.
522
+ *
523
+ * Every horizontal gesture shares it — move, trim, roll, slip, slide, audio
524
+ * move/trim/fade, keyframe, caption — so no gesture can drift while its
525
+ * neighbours track. `hasEscaped` deliberately does NOT: a snap-release radius
526
+ * is a distance the user's HAND has to travel, which is a screen measure.
527
+ */
528
+ function dragDeltaSeconds(ctx: PointerContext, press: Press, point: Point): number {
529
+ return xToTime(point.x, ctx.viewport) - press.originTime
530
+ }
531
+
532
+ /**
533
+ * Has this gesture pulled clear of where it started?
534
+ *
535
+ * Measured in pixels from the press point, which is the same distance every
536
+ * gesture's candidate has travelled from its own origin (a move's start, a
537
+ * trim's edge, a roll's cut all track `point.x` one-for-one), so one test
538
+ * serves them all without knowing which gesture is running.
539
+ *
540
+ * LATCHED by the caller: once true it stays true for the rest of the gesture.
541
+ * That is the whole point. A guard that merely asked "am I far from the origin
542
+ * right now" could never re-arm the origin boundary, because being near it is
543
+ * exactly the moment you want it back.
544
+ */
545
+ function hasEscaped(ctx: PointerContext, press: Press, point: Point): boolean {
546
+ const releasePx = (ctx.snapConfig ?? DEFAULT_SNAP_CONFIG).releasePx
547
+ return Math.abs(point.x - press.origin.x) >= releasePx
548
+ }
549
+
550
+ /** Is `point` within grab range of the playhead line? Measured against
551
+ * `ctx.playheadTime` — the time the overlay draws the line at — through the
552
+ * live viewport, so the band tracks the line at any zoom or scroll. Y is not
553
+ * tested: the line spans the whole surface, so the grab is available on every
554
+ * row (which rows it actually WINS on is `grabsPlayhead`'s call). */
555
+ function isOverPlayhead(point: Point, ctx: PointerContext): boolean {
556
+ if (!(ctx.viewport.pxPerSecond > 0)) return false
557
+ return Math.abs(point.x - timeToX(ctx.playheadTime, ctx.viewport)) <= PLAYHEAD_GRAB_PX
558
+ }
559
+
560
+ /** A near-playhead press that should grab the playhead and scrub, exactly as
561
+ * the ruler does. Trim EDGES win over the grab — they are the precise target,
562
+ * so grabbing the playhead where it crosses a clip yields the body but never
563
+ * the handle (decision D1); everything else, clip and audio bodies and empty
564
+ * space alike, yields to the grab (D2). Fade GRIPS win over it too, by the
565
+ * same D1 reasoning — an even smaller, more deliberate target than a trim
566
+ * edge — which is why this checks `hit.kind` directly rather than folding
567
+ * into `isEdgeHit` (whose contract is specifically "describes a trim grab",
568
+ * read via `hit.edge`; a fade grip has no `edge`, only `side`). Keyframe
569
+ * diamonds win for the identical reason — same size class as a fade grip,
570
+ * same "smaller and more deliberate than a trim edge" case for the grab. */
571
+ function grabsPlayhead(hit: HitResult, point: Point, ctx: PointerContext): boolean {
572
+ return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe' && isOverPlayhead(point, ctx)
573
+ }
574
+
575
+ /**
576
+ * The origin values to keep suppressed.
577
+ *
578
+ * A gesture starts ON its own origin, so any magnet sitting there captures it
579
+ * instantly and then demands a full release radius of travel before the item
580
+ * moves at all — the item stays put, then jumps 44px in one step. That is why
581
+ * origins were excluded in the first place.
582
+ *
583
+ * But excluding them for the WHOLE gesture is what made a butted clip
584
+ * unsnappable back: the boundary you want to return to is the boundary you
585
+ * started on, and a butted neighbour contributes the very same value. Dropping
586
+ * the suppression the moment the gesture has pulled clear gives both halves —
587
+ * the clip breaks away cleanly, and the boundary re-arms behind it so butting
588
+ * the two back together catches.
589
+ */
590
+ function originGuard(escaped: boolean, origins: readonly number[]): readonly number[] {
591
+ return escaped ? [] : origins
592
+ }
593
+
594
+ /** Snap targets for dragging the playhead itself: the clip/audio boundaries.
595
+ * All STRONG — the playhead belongs to no track, so there is no "own row" to
596
+ * rank against, and parking it on a cut is the point of the gesture. */
597
+ function playheadSnapPoints(ctx: PointerContext): SnapPoint[] {
598
+ return snapPointsExcluding(ctx.snapBoundaries.map(time => ({ time, strength: 'strong' as const })), [])
599
+ }
600
+
601
+ function replaceVisualItem(project: Project, id: string, patch: Partial<VisualItem>): Project {
602
+ return {
603
+ ...project,
604
+ tracks: mapTrackItems(project, items =>
605
+ items.map(item => (item.id === id ? { ...item, ...patch } : item)),
606
+ ),
607
+ }
608
+ }
609
+
610
+ /** `replaceVisualItem`'s caption twin. `patch` is deliberately typed as a
611
+ * Partial of the whole segment rather than a start/end pair, but only ever
612
+ * called with one edge — see `applyCaptionTrim` on why nothing else moves. */
613
+ function replaceCaptionSegment(project: Project, id: string, patch: Partial<CaptionSegment>): Project {
614
+ if (!project.captions) return project
615
+ return {
616
+ ...project,
617
+ captions: {
618
+ ...project.captions,
619
+ segments: project.captions.segments.map(seg => (seg.id === id ? { ...seg, ...patch } : seg)),
620
+ },
621
+ }
622
+ }
623
+
624
+ /** The items either side of `item` on its own track, in timeline order, and
625
+ * only when they actually touch it — a gap means there is no shared boundary
626
+ * to roll. Same adjacency rule `slideItem` uses in cuts.ts. */
627
+ function adjacentOnTrack(project: Project, item: VisualItem): { prev?: VisualItem; next?: VisualItem } {
628
+ const track = trackItems(project).find(t => t.some(other => other.id === item.id))
629
+ if (!track) return {}
630
+ const sorted = [...track].sort((a, b) => a.start - b.start)
631
+ const pos = sorted.findIndex(other => other.id === item.id)
632
+ const before = pos > 0 ? sorted[pos - 1] : undefined
633
+ const after = pos >= 0 && pos < sorted.length - 1 ? sorted[pos + 1] : undefined
634
+ return {
635
+ prev: before && Math.abs(before.end - item.start) <= EPSILON ? before : undefined,
636
+ next: after && Math.abs(after.start - item.end) <= EPSILON ? after : undefined,
637
+ }
638
+ }
639
+
640
+ /** Where a guide line goes, and how hard the magnet holding it pulls. */
641
+ export interface SnapGuide {
642
+ time: number
643
+ strength: SnapStrength
644
+ }
645
+
646
+ // ── Gesture application ──────────────────────────────────────────────────
647
+
648
+ interface Applied {
649
+ effects: PointerEffect[]
650
+ snap: SnapState
651
+ lastProject: Project
652
+ /** Where the guide belongs after this move, or null for none. Never an
653
+ * effect on its own — the reducer diffs it against the last one and emits
654
+ * only on a change. */
655
+ guide: SnapGuide | null
656
+ /** `keyframe-move` only: the diamond's item-relative landed time (`toT`)
657
+ * after this move. Undefined for every other gesture, and undefined for a
658
+ * `keyframe-move` frame that clamped to a no-op — `pointerUp` falls back
659
+ * to the state's last known value (see the dragging state's `keyframeT`)
660
+ * so a settled no-op frame at release never loses it. */
661
+ keyframeLandedT?: number
662
+ }
663
+
664
+ function noChange(snap: SnapState, lastProject: Project): Applied {
665
+ return { effects: [], snap, lastProject, guide: null }
666
+ }
667
+
668
+ /** Emit a live edit, unless the op clamped to a no-op and handed back the same
669
+ * project reference (which `rollEdit`/`slipItem`/`slideItem` all do). */
670
+ function change(next: Project, previous: Project, snap: SnapState, guide: SnapGuide | null): Applied {
671
+ if (next === previous) return { effects: [], snap, lastProject: previous, guide }
672
+ return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide }
673
+ }
674
+
675
+ /**
676
+ * Which boundary a SPAN gesture actually landed on.
677
+ *
678
+ * `snapPointsForSpan` makes both edges of a dragged item magnetic by offering
679
+ * two candidate start positions per boundary — `p` (leading edge lands on it)
680
+ * and `p - duration` (trailing edge does). That is the right shape for the
681
+ * magnet and the wrong one for the guide: `snap.snappedTo` is a start, and
682
+ * drawing a line there when the item's TAIL is what caught puts the mark a
683
+ * whole clip away from the edge the user is looking at.
684
+ *
685
+ * The recovery is a membership test against the un-expanded boundary list: a
686
+ * snapped start that is itself a boundary means the head caught, anything else
687
+ * means the tail did and the boundary is one duration later.
688
+ */
689
+ function spanSnapGuide(snapped: SnapResult, duration: number, points: readonly SnapPoint[]): SnapGuide | null {
690
+ const { snappedTo, strength } = snapped
691
+ if (snappedTo === null || strength === null) return null
692
+ const head = points.some(p => Math.abs(p.time - snappedTo) <= EPSILON)
693
+ return { time: head ? snappedTo : snappedTo + duration, strength }
694
+ }
695
+
696
+ /** The guide for an EDGE gesture, suppressed when the op refused to put the
697
+ * edge where the magnet asked. Trims clamp to a 0.1s minimum duration and to
698
+ * the source window; a guide left drawn through a clamp would claim an
699
+ * alignment that isn't on screen. */
700
+ function edgeSnapGuide(snapped: SnapResult, landed: number): SnapGuide | null {
701
+ const { snappedTo, strength, time } = snapped
702
+ if (snappedTo === null || strength === null) return null
703
+ return Math.abs(landed - time) <= EPSILON ? { time: snappedTo, strength } : null
704
+ }
705
+
706
+ /** `snapped.snappedTo` as a guide, for the gestures whose snap value IS the
707
+ * boundary (roll, slide, scrub). */
708
+ function directSnapGuide(snapped: SnapResult): SnapGuide | null {
709
+ if (snapped.snappedTo === null || snapped.strength === null) return null
710
+ return { time: snapped.snappedTo, strength: snapped.strength }
711
+ }
712
+
713
+ /** The whole selection travelling as one. The DRAGGED item's leading/trailing
714
+ * edge snaps to the boundaries exactly as a single move does; the delta that
715
+ * lands is what every selected item and audio track then shifts by, clamped as
716
+ * a body so none leaves the timeline. Rebuilt from `press.baseProject` each
717
+ * move so dragging back and forth cannot compound. Shared by `applyMove`,
718
+ * `applyAudioMove` and `applyCaptionMove` — a multi-selection can mix clips,
719
+ * audio bars and captions, so the group moves the same way whichever kind the
720
+ * drag started on.
721
+ *
722
+ * `selection` is a parameter rather than a read of `ctx.selectedIds` because
723
+ * it is not always that: a freshly grabbed caption is not in the host's
724
+ * selection yet on the first move of its own drag. See `applyCaptionMove`. */
725
+ function applyGroupMove(
726
+ ctx: PointerContext,
727
+ press: Press,
728
+ point: Point,
729
+ snap: SnapState,
730
+ lastProject: Project,
731
+ escaped: boolean,
732
+ dragged: { id: string; start: number; end: number },
733
+ selection: readonly string[],
734
+ ): Applied {
735
+ const duration = dragged.end - dragged.start
736
+ const dt = dragDeltaSeconds(ctx, press, point)
737
+ const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [dragged.start, dragged.end]))
738
+ const points = snapPointsForSpan(boundaries, duration)
739
+ const snapped = applySnap(dragged.start + dt, points, ctx.viewport, snap, ctx.snapConfig)
740
+ const next = applyMoveDeltaToSelection(press.baseProject, selection, snapped.time - dragged.start, ctx.totalDuration)
741
+ return change(next, lastProject, snapped.state, spanSnapGuide(snapped, duration, boundaries))
742
+ }
743
+
744
+ function applyMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
745
+ const item = press.hit.item
746
+ if (!item || press.hit.trackIdx === undefined) return noChange(snap, lastProject)
747
+
748
+ // Multi-select move: the pressed item belongs to a selection of more than
749
+ // one, so the whole selection travels together by the same delta and keeps
750
+ // its shape. Cross-track movement is dropped for the group (see
751
+ // `applyMoveDeltaToSelection`); a single-item move keeps it, below.
752
+ if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(item.id)) {
753
+ return applyGroupMove(ctx, press, point, snap, lastProject, escaped, item, ctx.selectedIds)
754
+ }
755
+
756
+ const duration = item.end - item.start
757
+ const dt = dragDeltaSeconds(ctx, press, point)
758
+ const rawStart = clamp(item.start + dt, 0, Math.max(0, ctx.totalDuration - duration))
759
+
760
+ // Which track the item is CURRENTLY ON. Not the POINTED-AT index
761
+ // (`sourceTrackIdx - dy/24`) — `moveItemAcrossTracks` treats that as a mere
762
+ // starting point and searches outward from it, so a pointed-at track the
763
+ // dragged item's KIND can never occupy is not where it goes. Tiering against
764
+ // the pointed-at index therefore ranked the boundaries of a row the item had
765
+ // never been placed on, which is exactly what made a cross-track drag read
766
+ // as "snapping doesn't work over here": drag an overlay down into a
767
+ // same-length gap one row below and the pointed-at index lands on the VIDEO
768
+ // track under it (the 24px drag divisor is roughly half a rendered overlay
769
+ // row, so one row of real travel counts as two steps), the kind gate bounces
770
+ // the drop back up onto the row the cursor is actually over, and the gap's
771
+ // own two edges stay tiered WEAK — a 6px magnet where a 20px one belongs.
772
+ //
773
+ // Running the drop's own search removes the second copy of that arithmetic.
774
+ // (The two can still differ after a mid-drag prune, which renumbers
775
+ // `lastProject`'s tracks under the move's `baseProject`-space `sourceTrackIdx`
776
+ // — pre-existing, and orthogonal to the tier.) `kindOnly` deliberately
777
+ // keeps the COLLISION half of the search out of it — see the option's doc in
778
+ // `timeline-model.ts`; consulting it here would kill the magnet precisely
779
+ // where it is aiming and make the tier lurch between rows mid-slide. (It
780
+ // also makes `ctx.rippleMode` irrelevant to the tier, since ripple mode only
781
+ // ever relaxes that same collision gate.)
782
+ //
783
+ // Resolved against `press.baseProject`, not `lastProject`: `tieredBoundaries`
784
+ // walks `baseProject`, and `press.hit.trackIdx` indexes it too, so all three
785
+ // stay in ONE coordinate space even after a mid-drag prune has renumbered the
786
+ // live project's tracks. The dragged item is filtered out of the kind gate by
787
+ // id, so its own stale position in `baseProject` cannot affect the answer.
788
+ //
789
+ // The window is unread while `kindOnly` is on — the collision gate
790
+ // short-circuits past it — so the RAW (pre-snap) one is passed for the sake
791
+ // of the invariant rather than the arithmetic: if `kindOnly` is ever
792
+ // relaxed, the tier stays a pure function of where the pointer is instead
793
+ // of letting the magnet it produced decide which magnets exist.
794
+ //
795
+ // A past-the-end `currentTrackIdx` is `resolveTargetTrackIdx`'s signal that
796
+ // the drop would MINT a new track. That intentionally matches no row below,
797
+ // so every boundary tiers weak — correct by construction for a track with
798
+ // no neighbours yet, not a case this call site branches on explicitly.
799
+ const dy = point.y - press.origin.y
800
+ const currentTrackIdx = resolveTargetTrackIdx({
801
+ tracks: normalizeTracks(press.baseProject).tracks ?? [],
802
+ item,
803
+ start: rawStart,
804
+ end: rawStart + duration,
805
+ sourceTrackIdx: press.hit.trackIdx,
806
+ dy,
807
+ }, { kindOnly: true })
808
+
809
+ const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [item.start, item.end]), { trackIdx: currentTrackIdx })
810
+ const points = snapPointsForSpan(boundaries, duration)
811
+ const snapped = applySnap(rawStart, points, ctx.viewport, snap, ctx.snapConfig)
812
+ const start = clamp(snapped.time, 0, Math.max(0, ctx.totalDuration - duration))
813
+ // Clamped against t=0 or the end of the timeline the item is NOT where the
814
+ // magnet put it, so there is nothing to mark.
815
+ const guide = Math.abs(start - snapped.time) <= EPSILON
816
+ ? spanSnapGuide(snapped, duration, boundaries)
817
+ : null
818
+
819
+ const next: Project = {
820
+ ...lastProject,
821
+ tracks: moveItemAcrossTracks({
822
+ // Server-side shape normalization is best-effort (a project must always
823
+ // open even if migration throws), and the SSE stream's initial frame
824
+ // reads project.json straight off disk with no migration at all — so a
825
+ // legacy-shape project genuinely can reach here. Normalize defensively
826
+ // rather than assume `.tracks` is already `VisualTrack[]`.
827
+ tracks: normalizeTracks(lastProject).tracks ?? [],
828
+ item,
829
+ start,
830
+ end: start + duration,
831
+ sourceTrackIdx: press.hit.trackIdx,
832
+ dy,
833
+ makeSpace: ctx.rippleMode,
834
+ }),
835
+ }
836
+ return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
837
+ }
838
+
839
+ function applyTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
840
+ const item = press.hit.item
841
+ if (!item || !press.hit.edge) return noChange(snap, lastProject)
842
+
843
+ const edge = press.hit.edge === 'in' ? 'start' : 'end'
844
+ const initTime = edge === 'start' ? item.start : item.end
845
+ const dt = dragDeltaSeconds(ctx, press, point)
846
+ const raw = clamp(initTime + dt, 0, ctx.totalDuration)
847
+
848
+ // The opposite edge stays excluded for the whole gesture — trimming an out
849
+ // edge back onto its own in edge is a zero-length clip, not an alignment.
850
+ // Only the edge being dragged gets the re-arming treatment.
851
+ const fixedEdge = edge === 'start' ? item.end : item.start
852
+ const snapped = applySnap(
853
+ raw,
854
+ itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
855
+ ctx.viewport, snap, ctx.snapConfig,
856
+ )
857
+ const resized = computeResizedItem(item as Draggable, edge, snapped.time)
858
+
859
+ // Trims always rebuild from the pressed-at project, never from the running
860
+ // preview — otherwise ripple's gap collapse would compound move by move.
861
+ let next = replaceVisualItem(press.baseProject, item.id, {
862
+ start: resized.start,
863
+ end: resized.end,
864
+ inPoint: resized.inPoint,
865
+ outPoint: resized.outPoint,
866
+ })
867
+ if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(item.id)) {
868
+ next = applyResizeDeltaToSelection(next, item.id, ctx.selectedIds, edge, {
869
+ dStart: edge === 'start' ? resized.start - item.start : 0,
870
+ dEnd: edge === 'end' ? resized.end - item.end : 0,
871
+ })
872
+ }
873
+ if (ctx.rippleMode) next = collapseGaps(next)
874
+
875
+ const guide = edgeSnapGuide(snapped, edge === 'start' ? resized.start : resized.end)
876
+ return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
877
+ }
878
+
879
+ function applyRoll(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
880
+ const item = press.hit.item
881
+ if (!item || !press.hit.edge) return noChange(snap, lastProject)
882
+
883
+ const { prev, next: after } = adjacentOnTrack(press.baseProject, item)
884
+ const left = press.hit.edge === 'in' ? prev : item
885
+ const right = press.hit.edge === 'in' ? item : after
886
+ // No neighbour means no shared boundary. The gesture stays alive (releasing
887
+ // Alt mid-drag must not silently turn it into a trim) but does nothing.
888
+ if (!left || !right) return noChange(snap, lastProject)
889
+
890
+ const boundary = left.end
891
+ const dt = dragDeltaSeconds(ctx, press, point)
892
+ const candidate = clamp(boundary + dt, 0, ctx.totalDuration)
893
+ // A roll's origin IS the shared boundary, and both neighbours contribute it.
894
+ // The outer edges stay excluded throughout: rolling the cut onto either
895
+ // neighbour's far edge would consume that clip entirely.
896
+ const snapped = applySnap(
897
+ candidate,
898
+ itemSnapPoints(ctx, press, [
899
+ left.start, right.end,
900
+ ...originGuard(escaped, [left.end, right.start]),
901
+ ]),
902
+ ctx.viewport,
903
+ snap,
904
+ ctx.snapConfig,
905
+ )
906
+
907
+ const rolled = rollEdit(press.baseProject, left.id, right.id, snapped.time - boundary)
908
+ return change(rolled, lastProject, snapped.state, directSnapGuide(snapped))
909
+ }
910
+
911
+ function applySlip(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
912
+ const item = press.hit.item
913
+ if (!item) return noChange(snap, lastProject)
914
+
915
+ // Dragging right pulls the media rightwards under a fixed window, so EARLIER
916
+ // frames come into view — the film-under-a-gate model every NLE uses. Hence
917
+ // the sign flip. No snapping: the delta is source time, and every snap point
918
+ // the timeline knows about is timeline time.
919
+ const dt = dragDeltaSeconds(ctx, press, point)
920
+ return change(slipItem(press.baseProject, item.id, -dt), lastProject, snap, null)
921
+ }
922
+
923
+ function applySlide(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
924
+ const item = press.hit.item
925
+ if (!item) return noChange(snap, lastProject)
926
+
927
+ const dt = dragDeltaSeconds(ctx, press, point)
928
+ const candidate = clamp(item.start + dt, 0, ctx.totalDuration)
929
+ const snapped = applySnap(
930
+ candidate,
931
+ itemSnapPoints(ctx, press, originGuard(escaped, [item.start, item.end])),
932
+ ctx.viewport,
933
+ snap,
934
+ ctx.snapConfig,
935
+ )
936
+ const slid = slideItem(press.baseProject, item.id, snapped.time - item.start)
937
+ return change(slid, lastProject, snapped.state, directSnapGuide(snapped))
938
+ }
939
+
940
+ function applyAudioMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
941
+ const track = press.hit.track
942
+ if (!track || press.hit.laneIdx === undefined) return noChange(snap, lastProject)
943
+
944
+ // Multi-select move: the whole selection (clips and bars alike) travels as
945
+ // one — same rule as the visual side. A lone audio bar keeps its lane-change
946
+ // behaviour, below.
947
+ if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(track.id)) {
948
+ return applyGroupMove(ctx, press, point, snap, lastProject, escaped, track, ctx.selectedIds)
949
+ }
950
+
951
+ const duration = track.end - track.start
952
+ const dt = dragDeltaSeconds(ctx, press, point)
953
+ const rawStart = clamp(track.start + dt, 0, Math.max(0, ctx.totalDuration - duration))
954
+
955
+ // Positive dy is downward, which in the audio stack means a HIGHER lane index
956
+ // (lanes ascend downward) — the opposite of visual tracks. Computed here,
957
+ // ahead of the snap boundaries below, because `destLane` is simultaneously
958
+ // "which lane the drop will land on" and "which lane's peers should snap
959
+ // strong right now" — the same current-lane derivation `itemSnapPoints`
960
+ // needs, so one computation serves both.
961
+ const laneDelta = Math.round((point.y - press.origin.y) / AUDIO_LANE_HEIGHT_PX)
962
+ const destLane = Math.max(0, press.hit.laneIdx + laneDelta)
963
+
964
+ const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [track.start, track.end]), { laneIdx: destLane })
965
+ const points = snapPointsForSpan(boundaries, duration)
966
+ const snapped = applySnap(rawStart, points, ctx.viewport, snap, ctx.snapConfig)
967
+ const start = clamp(snapped.time, 0, Math.max(0, ctx.totalDuration - duration))
968
+ const guide = Math.abs(start - snapped.time) <= EPSILON
969
+ ? spanSnapGuide(snapped, duration, boundaries)
970
+ : null
971
+
972
+ const changes: Partial<AudioTrack> = { start, end: start + duration, lane: destLane }
973
+ // Cross-lane inheritance: a clip dropped onto another lane adopts THAT
974
+ // lane's magnet state, so the row it lands on stays consistent rather than
975
+ // mixing a magnetic clip in with non-magnetic neighbours (or vice versa).
976
+ // Grouped from `press.baseProject` — the project as it stood before this
977
+ // drag — so mid-drag lane membership never sees the dragged clip's own
978
+ // in-flight `lane` changes, and via `groupAudioLanes` so an auto-assigned
979
+ // (lane-less) neighbour is matched the same way the rail groups it, not by
980
+ // a raw `lane` field comparison. Moving onto an EMPTY lane leaves
981
+ // `magnetic` untouched: there is no row to be consistent with yet.
982
+ const destLaneGroup = groupAudioLanes(press.baseProject.audio?.tracks ?? [])
983
+ .find(l => l.laneIndex === destLane)
984
+ const destLaneTracks = (destLaneGroup?.tracks ?? []).filter(t => t.id !== track.id)
985
+ if (destLaneTracks.length > 0) {
986
+ changes.magnetic = destLaneTracks.every(t => t.magnetic === true)
987
+ }
988
+
989
+ const next = updateAudioTrack(press.baseProject, track.id, changes)
990
+ return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
991
+ }
992
+
993
+ function applyAudioTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
994
+ const track = press.hit.track
995
+ if (!track || !press.hit.edge) return noChange(snap, lastProject)
996
+
997
+ const edge = press.hit.edge === 'in' ? 'start' : 'end'
998
+ const initTime = edge === 'start' ? track.start : track.end
999
+ const dt = dragDeltaSeconds(ctx, press, point)
1000
+ const raw = clamp(initTime + dt, 0, ctx.totalDuration)
1001
+ const fixedEdge = edge === 'start' ? track.end : track.start
1002
+ const snapped = applySnap(
1003
+ raw,
1004
+ itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
1005
+ ctx.viewport, snap, ctx.snapConfig,
1006
+ )
1007
+
1008
+ // An audio track is always source-windowed, so it goes straight to
1009
+ // `resizeWindowedItem` — `computeResizedItem` would take its non-video
1010
+ // branch and move the timeline edge alone. This used to reproduce
1011
+ // AudioTrackRow's window arithmetic by hand, with the edge and the window
1012
+ // clamped independently; that is the bug documented on `resizeWindowedItem`,
1013
+ // and a bar trimmed past its media ended up claiming more timeline than it
1014
+ // had audio for.
1015
+ const resized = resizeWindowedItem(track as unknown as Draggable, edge, snapped.time)
1016
+ const changes: Partial<AudioTrack> = {
1017
+ start: resized.start,
1018
+ end: resized.end,
1019
+ inPoint: resized.inPoint,
1020
+ outPoint: resized.outPoint,
1021
+ }
1022
+
1023
+ let next = updateAudioTrack(press.baseProject, track.id, changes)
1024
+ if (ctx.selectedIds.length > 1 && ctx.selectedIds.includes(track.id)) {
1025
+ next = applyResizeDeltaToSelection(next, track.id, ctx.selectedIds, edge, {
1026
+ dStart: edge === 'start' ? resized.start - track.start : 0,
1027
+ dEnd: edge === 'end' ? resized.end - track.end : 0,
1028
+ })
1029
+ }
1030
+ const guide = edgeSnapGuide(snapped, edge === 'start' ? resized.start : resized.end)
1031
+ return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide }
1032
+ }
1033
+
1034
+ /**
1035
+ * Drag an audio bar's fade-in or fade-out GRIP. Dragging the fade-IN grip
1036
+ * RIGHT (away from the bar's start) grows `fadeIn`; dragging the fade-OUT
1037
+ * grip LEFT (away from the bar's end) grows `fadeOut` — opposite pixel signs
1038
+ * for the same "away from the corner, into the clip" motion. Dragging either
1039
+ * grip back past its own corner clamps to 0, which is how a fade is removed.
1040
+ *
1041
+ * No snapping — a fade grip has no meaningful timeline boundary to magnetize
1042
+ * to, only its own bar's corner and duration, both already enforced by the
1043
+ * clamp below — so this is simpler than `applyAudioTrim`: no
1044
+ * `itemSnapPoints`/`applySnap`, no guide, `snap` passed straight through
1045
+ * unchanged.
1046
+ *
1047
+ * Clamp: `fadeIn`/`fadeOut` are each floored at 0 (Math.max inside `clamp`),
1048
+ * and the two together may never exceed the bar's own duration — measured
1049
+ * against the OTHER side's current (committed) value, since a single drag
1050
+ * only ever touches its own side.
1051
+ */
1052
+ function applyAudioFade(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
1053
+ const track = press.hit.track
1054
+ const side = press.hit.side
1055
+ if (!track || !side) return noChange(snap, lastProject)
1056
+
1057
+ const duration = Math.max(0, track.end - track.start)
1058
+ const dt = dragDeltaSeconds(ctx, press, point)
1059
+ const initial = side === 'in' ? (track.fadeIn ?? 0) : (track.fadeOut ?? 0)
1060
+ const otherFade = side === 'in' ? (track.fadeOut ?? 0) : (track.fadeIn ?? 0)
1061
+ const raw = side === 'in' ? initial + dt : initial - dt
1062
+ const value = clamp(raw, 0, Math.max(0, duration - otherFade))
1063
+
1064
+ const changes: Partial<AudioTrack> = side === 'in' ? { fadeIn: value } : { fadeOut: value }
1065
+ const next = updateAudioTrack(press.baseProject, track.id, changes)
1066
+ return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
1067
+ }
1068
+
1069
+ /**
1070
+ * Drag a keyframe-strip diamond to retime it (plan decision 5).
1071
+ *
1072
+ * The diamond represents every prop that has a point at the SAME
1073
+ * item-relative `t` — the "union of times" decision 2 draws as one merged
1074
+ * strip — so moving it moves EVERY track that has a point there, together,
1075
+ * one `keyframeOps.moveKeyframe` call per prop. `toT` clamps to
1076
+ * `[0, item.end - item.start]`, the item's own span; there is nothing outside
1077
+ * it a keyframe could mean.
1078
+ *
1079
+ * Snapping, unlike the fade grip (which has no meaningful boundary and skips
1080
+ * `applySnap` entirely), DOES apply here: the item's own start/end and every
1081
+ * OTHER keyframe time on the SAME item are real alignments worth a magnet —
1082
+ * converted to ABSOLUTE timeline time (`item.start + t`) since `applySnap`
1083
+ * works in that unit, the same conversion `applyAudioTrim`'s `edgeSnapGuide`
1084
+ * call makes for its own landed value. `fromT`'s own instant is excluded via
1085
+ * `originGuard`, exactly as a trim excludes the edge it started on, so the
1086
+ * diamond isn't recaptured at the position it is leaving; excluded THROUGH
1087
+ * `escaped`, so dragging back allows it to re-snap onto its own start
1088
+ * position once the gesture has pulled clear — same story as a trimmed edge
1089
+ * butting back onto its origin.
1090
+ *
1091
+ * Recomputes from `press.baseProject` every move — like every other
1092
+ * bounded, single-item op (trim, fade) and unlike `move`, which accumulates
1093
+ * — so dragging back and forth cannot compound.
1094
+ *
1095
+ * LANDING ON ANOTHER KEYFRAME MERGES THE TWO. Every other keyframe time is a
1096
+ * STRONG snap target (above), so a drag is actively steered onto them — and
1097
+ * `moveKeyframe` → `normalizeTrack` resolve a same-`t` collision last-wins:
1098
+ * for any prop the two diamonds share, the DRAGGED one's value survives and
1099
+ * the target's is silently dropped. This is deliberate — it reads as "drag
1100
+ * one diamond onto another to collapse them into one" — but it IS a real data
1101
+ * loss for whichever prop's value didn't win, so don't "fix" it into a no-op
1102
+ * or a merge-by-average without a product decision first. It is a normal
1103
+ * undo step like any other keyframe edit, so it is trivially reversible.
1104
+ */
1105
+ function applyKeyframeMove(
1106
+ ctx: PointerContext,
1107
+ press: Press,
1108
+ point: Point,
1109
+ snap: SnapState,
1110
+ lastProject: Project,
1111
+ escaped: boolean,
1112
+ ): Applied {
1113
+ const item = press.hit.item
1114
+ const fromT = press.hit.kfT
1115
+ if (!canKeyframe(item) || fromT === undefined) return noChange(snap, lastProject)
1116
+
1117
+ const duration = Math.max(0, item.end - item.start)
1118
+ const dt = dragDeltaSeconds(ctx, press, point)
1119
+ const raw = clamp(fromT + dt, 0, duration)
1120
+
1121
+ const otherTimes = keyframeUnionTimes(item).filter(t => t !== fromT)
1122
+ const rawPoints: SnapPoint[] = [0, duration, ...otherTimes].map(t => ({ time: item.start + t, strength: 'strong' as const }))
1123
+ const points = snapPointsExcluding(rawPoints, originGuard(escaped, [item.start + fromT]))
1124
+ const snapped = applySnap(item.start + raw, points, ctx.viewport, snap, ctx.snapConfig)
1125
+ const toT = clamp(snapped.time - item.start, 0, duration)
1126
+
1127
+ const props = (item.keyframes ?? [])
1128
+ .filter(track => track.points.some(p => p.t === fromT))
1129
+ .map(track => track.prop)
1130
+ if (props.length === 0) return noChange(snap, lastProject)
1131
+
1132
+ let nextItem = item
1133
+ for (const prop of props) nextItem = moveKeyframe(nextItem, prop, fromT, toT)
1134
+ if (nextItem === item) return noChange(snap, lastProject)
1135
+
1136
+ const next = replaceVisualItem(press.baseProject, item.id, nextItem)
1137
+ const guide = edgeSnapGuide(snapped, item.start + toT)
1138
+ return { effects: [{ type: 'projectChange', project: next }], snap: snapped.state, lastProject: next, guide, keyframeLandedT: toT }
1139
+ }
1140
+
1141
+ /**
1142
+ * Re-time a caption block — across rows for a lone caption, and along the
1143
+ * timeline for a caption travelling with a wider selection.
1144
+ *
1145
+ * The selection expression is load-bearing. `applyMoveDeltaToSelection` moves
1146
+ * only ids it is handed, and when a drag starts on an UNSELECTED caption the
1147
+ * `selectMany` effect that selects it has not been echoed back by the host yet
1148
+ * — so `ctx.selectedIds` does not contain it on that first move, and the
1149
+ * caption would sit perfectly still while the pointer walked away from it.
1150
+ * Falling back to `[seg.id]` covers both cases in one line: a caption already
1151
+ * in a multi-selection group-moves with everything else, a freshly grabbed one
1152
+ * takes the single-caption path below.
1153
+ *
1154
+ * **Group drags never change lanes.** `applyMoveDeltaToSelection` is
1155
+ * horizontal-only — it shifts times and re-spreads word timings, and `lane`
1156
+ * rides its spread untouched — so a multi-selection travels along the timeline
1157
+ * however far the pointer moves vertically. This is the one place where "drag
1158
+ * any member and the whole selection follows" meets "drag up a row to change
1159
+ * rows", and horizontal-only wins: a group has no single source lane to
1160
+ * measure a lane delta from, and moving every member by the same lane delta
1161
+ * would silently re-stack rows the operator never pointed at.
1162
+ *
1163
+ * Snapping is the ordinary boundary set. A caption hit carries no `trackIdx`
1164
+ * or `laneIdx` — it belongs to no row — so `tieredBoundaries` ranks every clip
1165
+ * and audio boundary WEAK for it, and captions do not snap to each other at
1166
+ * all (that function reads tracks and audio only). Accepted for v1.
1167
+ *
1168
+ * Captions may NOT overlap WITHIN A LANE. On the group path
1169
+ * `applyMoveDeltaToSelection` folds a same-lane caption-neighbour bound in
1170
+ * alongside its timeline-edge bound (see `captionNeighbourBounds` in
1171
+ * multiSelectOps.ts); on the single path the rule is enforced by choosing a
1172
+ * lane the landed span actually fits in (`resolveDropLane`) rather than by
1173
+ * clamping the horizontal landing. Overlap is illegal within a lane because
1174
+ * `activeCaptionSegments` (timeline-core) hands the preview and every render
1175
+ * template EVERY live segment, lane-ascending, and a lane is a z-order with no
1176
+ * vertical offset of its own — so two segments sharing a lane and an instant
1177
+ * paint one straight over the other, in preview AND in the export. Two
1178
+ * segments on DIFFERENT lanes coinciding is the feature rows exist for.
1179
+ * `applyCaptionTrim`, below, has the matching same-lane clamp for a trim.
1180
+ */
1181
+ function applyCaptionMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
1182
+ const seg = press.hit.segment
1183
+ if (!seg || seg.id === undefined) return noChange(snap, lastProject)
1184
+ const id = seg.id
1185
+ const selection = ctx.selectedIds.includes(id) ? ctx.selectedIds : [id]
1186
+ const dragged = { id, start: seg.start, end: seg.end }
1187
+ if (selection.length > 1) {
1188
+ return applyGroupMove(ctx, press, point, snap, lastProject, escaped, dragged, selection)
1189
+ }
1190
+
1191
+ // The row the press landed in, read off the BAND rather than off the segment.
1192
+ // The two agree by construction (`groupCaptionLanes` files each segment into
1193
+ // the band for its own lane), but the band is what the operator actually
1194
+ // pointed at.
1195
+ const sourceLane = press.hit.captionLane ?? laneOf(seg)
1196
+
1197
+ // Divided by CAPTION_ROW_HEIGHT_PX (40), NOT by the 44px band pitch the
1198
+ // layout actually stacks bands at — the same deliberate shortfall
1199
+ // `applyAudioMove` (AUDIO_LANE_HEIGHT_PX) and `moveItemAcrossTracks`
1200
+ // (VISUAL_ROW_HEIGHT_PX) use, so the drag reaches the neighbouring row a few
1201
+ // pixels before the cursor has fully left the current one.
1202
+ //
1203
+ // `resolveDropLane` then computes `wanted = clamp(sourceLane - laneDelta, 0,
1204
+ // maxLane + 1)`. The MINUS is a deliberate inversion, not a slip: caption
1205
+ // lanes ascend UPWARD on screen (Phase 3 emits the bands in descending lane
1206
+ // order, so lane 0 sits lowest, immediately above the base video row),
1207
+ // whereas audio lanes ascend DOWNWARD — so for captions a downward drag
1208
+ // (positive `laneDelta`) must LOWER the lane number. A sign error here sends
1209
+ // every caption the wrong way.
1210
+ const laneDelta = Math.round((point.y - press.origin.y) / CAPTION_ROW_HEIGHT_PX)
1211
+
1212
+ // No vertical intent at all: byte-for-byte the horizontal-only behaviour,
1213
+ // same-lane neighbour clamp included. This is what an operator who never
1214
+ // drags vertically experiences, so it stays exactly the group path with a
1215
+ // selection of one rather than a second implementation of the same thing.
1216
+ if (laneDelta === 0) {
1217
+ return applyGroupMove(ctx, press, point, snap, lastProject, escaped, dragged, selection)
1218
+ }
1219
+
1220
+ // ── Vertical intent: a row change, resolved lane-first ──────────────────
1221
+ const duration = seg.end - seg.start
1222
+ const dt = dragDeltaSeconds(ctx, press, point)
1223
+ const boundaries = itemSnapPoints(ctx, press, originGuard(escaped, [seg.start, seg.end]))
1224
+ const snapped = applySnap(seg.start + dt, snapPointsForSpan(boundaries, duration), ctx.viewport, snap, ctx.snapConfig)
1225
+
1226
+ // The horizontal landing is bounded by the timeline's own edges and NOTHING
1227
+ // else — deliberately not by `applyMoveDeltaToSelection`'s neighbour clamp.
1228
+ // A caption that is leaving its row must not be held back by that row's
1229
+ // neighbours, and the row it lands in is chosen to fit it rather than being
1230
+ // clamped against. That is the whole reason a cross-row drag never teleports
1231
+ // a caption sideways hunting for a gap: the search happens in the vertical
1232
+ // axis, exactly as `moveItemAcrossTracks` searches tracks for a clip.
1233
+ //
1234
+ // Same lo/hi `applyMoveDeltaToSelection` computes for a lone caption, minus
1235
+ // its neighbour half — `hi`'s `Math.max(0, …)` floor included, so a caption
1236
+ // already overhanging `totalDuration` (clips trimmed after captioning) is
1237
+ // neither pushed further out nor yanked back in.
1238
+ const delta = clamp(snapped.time - seg.start, -seg.start, Math.max(0, ctx.totalDuration - seg.end))
1239
+ const start = seg.start + delta
1240
+ const end = seg.end + delta
1241
+
1242
+ // Fans out from `wanted` — `wanted, wanted-1, wanted+1, …`, bounded to
1243
+ // `[0, maxLane + 1]` — and takes the first lane the landed span fits in, so
1244
+ // an occupied target row hands off to the nearest free one. `maxLane + 1` is
1245
+ // empty by construction, so the search always terminates there; landing on
1246
+ // it is exactly how a NEW row gets created.
1247
+ const lane = resolveDropLane({
1248
+ segments: press.baseProject.captions?.segments ?? [],
1249
+ seg, sourceLane, laneDelta, start, end,
1250
+ })
1251
+
1252
+ // Word timings are ABSOLUTE seconds, so every word travels with the segment
1253
+ // by the same delta and nothing is respread — the identical rule
1254
+ // `shiftCaptions` (multiSelectOps.ts) applies on the group path. `text` is
1255
+ // never touched: a move is a retime.
1256
+ const patch: Partial<CaptionSegment> = { start, end, lane }
1257
+ if (seg.words) patch.words = seg.words.map(w => ({ ...w, start: w.start + delta, end: w.end + delta }))
1258
+
1259
+ // Same-reference on a true no-op, the contract `change` reads: a drag held
1260
+ // against a clamp in a row it never left emits nothing rather than an
1261
+ // identical project per pointer event.
1262
+ const next = delta === 0 && lane === sourceLane
1263
+ ? press.baseProject
1264
+ : replaceCaptionSegment(press.baseProject, id, patch)
1265
+ // Clamped against t=0 or the end of the timeline the caption is NOT where
1266
+ // the magnet asked, so there is nothing to mark — same rule as `applyMove`.
1267
+ const guide = Math.abs(start - snapped.time) <= EPSILON
1268
+ ? spanSnapGuide(snapped, duration, boundaries)
1269
+ : null
1270
+ return change(next, lastProject, snapped.state, guide)
1271
+ }
1272
+
1273
+ /** The shortest a caption may be trimmed to. Carried over from the retired DOM
1274
+ * row, which got the same floor from `beginResize`, and relied on twice: a
1275
+ * zero-length caption is unselectable and unreadable, and the click-seek in
1276
+ * `pointerUp` needs every segment to be at least one frame long. */
1277
+ export const CAPTION_MIN_DURATION_S = 0.1
1278
+
1279
+ /**
1280
+ * Drag ONE caption edge. Single-segment by design — multi-caption trim is out
1281
+ * of scope for v1, so a trim never consults the selection at all.
1282
+ *
1283
+ * Only the dragged edge is written. `text` and `words` are never touched: a
1284
+ * pure retime must not respread word timings, which is exactly why the retired
1285
+ * DOM row's commit patch carried the one numeric field and nothing else. Word
1286
+ * timings are absolute seconds, so they stay where the speech actually is; a
1287
+ * respread would silently invent new ones from the segment's new duration.
1288
+ */
1289
+ function applyCaptionTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project, escaped: boolean): Applied {
1290
+ const seg = press.hit.segment
1291
+ if (!seg || seg.id === undefined || !press.hit.edge) return noChange(snap, lastProject)
1292
+
1293
+ const edge = press.hit.edge === 'in' ? 'start' : 'end'
1294
+ const initTime = edge === 'start' ? seg.start : seg.end
1295
+ // The nearest OTHER caption SHARING THIS SEGMENT'S LANE on either side — a
1296
+ // segment on another row is not a bound, because captions on different rows
1297
+ // may overlap and all render. `sameLaneNeighbours` (captionLanes.ts) is the
1298
+ // one place that search lives; `seg` is excluded there so it is never its own
1299
+ // neighbour. Absent neighbour reads as unbounded (`±Infinity`) on that side,
1300
+ // the same convention the move clamp uses.
1301
+ //
1302
+ // Press-time, not live — same reasoning `press.baseProject` carries for the
1303
+ // snap boundaries (see the `Press` interface): the host echoes every
1304
+ // `projectChange` back through a re-render, and a neighbour search reading
1305
+ // the live project mid-drag would see this very segment's own current edge
1306
+ // reflected back as if it were a different, moving neighbour.
1307
+ const { prev, next: after } = sameLaneNeighbours(press.baseProject.captions?.segments ?? [], seg)
1308
+ const prevEnd = prev?.end ?? -Infinity
1309
+ const nextStart = after?.start ?? Infinity
1310
+
1311
+ // ── The dragged edge's LEGAL RANGE, computed once and used for everything ──
1312
+ //
1313
+ // The timeline on one side, the duration floor on the other, and a
1314
+ // same-lane caption-neighbour bound on the third: captions may never overlap
1315
+ // within a lane (see the WHY on `applyCaptionMove`), so a trim may not push
1316
+ // its edge across the adjacent SAME-LANE segment's edge either — a segment
1317
+ // on another row bounds nothing, since rows are allowed to overlap and all
1318
+ // render. All three fold into ONE range rather than
1319
+ // clamps in sequence: whichever clamp ran second would undo the first.
1320
+ // Floor-after-bounds let a segment already shorter than the floor escape the
1321
+ // timeline (a 0s–0.05s segment's in edge landed at -0.05s); bounds-after-
1322
+ // floor would break the other invariant instead. As a range, all three hold
1323
+ // at once.
1324
+ //
1325
+ // `hi` on the end side is NOT a flat `totalDuration`. That value is
1326
+ // `contentDuration + Math.max(5, contentDuration * 0.2)` (timeline-model),
1327
+ // derived from clips and audio ONLY, so a caption can outlast it whenever
1328
+ // clips were trimmed or deleted after captioning. Rare, but reachable.
1329
+ // Pinning such a segment at `totalDuration` would yank its end backwards the
1330
+ // moment its handle was touched — a silent destructive shrink triggered by
1331
+ // grabbing a handle, which is worse than the out-of-bounds write this bound
1332
+ // exists to prevent: that one needs a sub-floor segment at a boundary, this
1333
+ // one needs only an overhanging caption and a nudge. Ceiling at the segment's
1334
+ // own end instead, so a trim never pushes a caption FURTHER out than it
1335
+ // already was. A caption inside the timeline is unaffected — for it the max
1336
+ // IS `totalDuration`. `nextStart` then tightens that ceiling further when a
1337
+ // following caption IN THE SAME LANE sits closer than the timeline edge does
1338
+ // (`Infinity` when there is no such caption, so it never loosens the
1339
+ // existing bound).
1340
+ //
1341
+ // `lo` on the start side stays floored at 0 rather than mirroring `hi`
1342
+ // (`Math.min(0, seg.start)`): a negative `start` is unreachable —
1343
+ // `applyMoveDeltaToSelection` clamps a group move at `-minStart` and this
1344
+ // trim clamps at 0, so nothing in the codebase can produce one, and
1345
+ // mirroring would be generality for a state that cannot occur. `prevEnd`
1346
+ // raises that floor when a preceding caption IN THE SAME LANE sits to the
1347
+ // right of t=0 (`-Infinity` when there is no such caption, so it never
1348
+ // lowers it).
1349
+ const lo = edge === 'start' ? Math.max(0, prevEnd) : seg.start + CAPTION_MIN_DURATION_S
1350
+ const hi = edge === 'start'
1351
+ ? seg.end - CAPTION_MIN_DURATION_S
1352
+ : Math.min(Math.max(ctx.totalDuration, seg.end), nextStart)
1353
+
1354
+ // An EMPTY range means a sub-floor segment pinned against t=0, the end of
1355
+ // the timeline, or a same-lane neighbouring caption's own edge: there is no legal
1356
+ // position for this edge at all, so the trim declines to move rather than
1357
+ // inventing a least-bad one. Same shape `applyMoveDeltaToSelection` uses for
1358
+ // a group already wider than the timeline (or its own caption-neighbour
1359
+ // bound), which likewise refuses to move rather than pick a side.
1360
+ //
1361
+ // This MUST short-circuit before either clamp below. `clamp` is
1362
+ // `Math.max(lo, Math.min(hi, v))`, which silently returns `lo` when the range
1363
+ // is inverted — so an unguarded clamp would not error, it would quietly place
1364
+ // the edge at a bound that is on the wrong side of the other one.
1365
+ if (lo > hi) return noChange(snap, lastProject)
1366
+
1367
+ const dt = dragDeltaSeconds(ctx, press, point)
1368
+ const raw = clamp(initTime + dt, lo, hi)
1369
+
1370
+ // Same exclusions as every other trim: the opposite edge stays suppressed
1371
+ // for the whole gesture, and only the dragged edge's origin re-arms.
1372
+ const fixedEdge = edge === 'start' ? seg.end : seg.start
1373
+ const snapped = applySnap(
1374
+ raw,
1375
+ itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
1376
+ ctx.viewport, snap, ctx.snapConfig,
1377
+ )
1378
+ // Clamped again after the snap: a magnet can sit outside the range (the
1379
+ // playhead parked past a short caption's floor, say), and a snap is a
1380
+ // suggestion, not a licence to leave it.
1381
+ const landed = clamp(snapped.time, lo, hi)
1382
+
1383
+ // Same-reference on a no-op, the contract `change` reads. Held against a
1384
+ // clamp — dragging further past the floor produces the same edge every move —
1385
+ // this is what keeps a pinned drag from emitting an identical project per
1386
+ // pointer event, and a drag returned to its origin from committing at all.
1387
+ const next = Math.abs(landed - initTime) <= EPSILON
1388
+ ? press.baseProject
1389
+ : replaceCaptionSegment(press.baseProject, seg.id, edge === 'start' ? { start: landed } : { end: landed })
1390
+ return change(next, lastProject, snapped.state, edgeSnapGuide(snapped, landed))
1391
+ }
1392
+
1393
+ function applyScrub(ctx: PointerContext, point: Point, snap: SnapState, lastProject: Project): Applied {
1394
+ const raw = clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration)
1395
+ const snapped = applySnap(raw, playheadSnapPoints(ctx), ctx.viewport, snap, ctx.snapConfig)
1396
+ return {
1397
+ effects: [{ type: 'seek', time: snapped.time }],
1398
+ snap: snapped.state,
1399
+ lastProject,
1400
+ guide: directSnapGuide(snapped),
1401
+ }
1402
+ }
1403
+
1404
+ function applyGesture(
1405
+ gesture: GestureKind,
1406
+ ctx: PointerContext,
1407
+ press: Press,
1408
+ point: Point,
1409
+ snap: SnapState,
1410
+ lastProject: Project,
1411
+ escaped: boolean,
1412
+ ): Applied {
1413
+ // Before first layout there is no scale, so a pixel delta has no meaning.
1414
+ if (!(ctx.viewport.pxPerSecond > 0)) return noChange(snap, lastProject)
1415
+ switch (gesture) {
1416
+ case 'move': return applyMove(ctx, press, point, snap, lastProject, escaped)
1417
+ case 'trim': return applyTrim(ctx, press, point, snap, lastProject, escaped)
1418
+ case 'roll': return applyRoll(ctx, press, point, snap, lastProject, escaped)
1419
+ case 'slip': return applySlip(ctx, press, point, snap, lastProject)
1420
+ case 'slide': return applySlide(ctx, press, point, snap, lastProject, escaped)
1421
+ case 'audio-move': return applyAudioMove(ctx, press, point, snap, lastProject, escaped)
1422
+ case 'audio-trim': return applyAudioTrim(ctx, press, point, snap, lastProject, escaped)
1423
+ case 'audio-fade': return applyAudioFade(ctx, press, point, snap, lastProject)
1424
+ case 'keyframe-move': return applyKeyframeMove(ctx, press, point, snap, lastProject, escaped)
1425
+ case 'caption-move': return applyCaptionMove(ctx, press, point, snap, lastProject, escaped)
1426
+ case 'caption-trim': return applyCaptionTrim(ctx, press, point, snap, lastProject, escaped)
1427
+ case 'scrub': return applyScrub(ctx, point, snap, lastProject)
1428
+ case 'marquee': return applyMarquee(press, point, snap, lastProject)
1429
+ }
1430
+ }
1431
+
1432
+ /** The live marquee: paint the box, change nothing. The selection is applied
1433
+ * once, on release — a marquee that re-selected on every move would push a
1434
+ * selection change per pointer event through the host for a gesture the user
1435
+ * has not finished expressing. */
1436
+ function applyMarquee(press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
1437
+ return {
1438
+ effects: [{ type: 'marquee', rect: normalizeRect(press.origin, point) }],
1439
+ snap,
1440
+ lastProject,
1441
+ guide: null,
1442
+ }
1443
+ }
1444
+
1445
+ // ── Reducer ──────────────────────────────────────────────────────────────
1446
+
1447
+ export interface Transition {
1448
+ state: MachineState
1449
+ effects: PointerEffect[]
1450
+ }
1451
+
1452
+ /** The guide the surface is currently showing. Only a running gesture has one. */
1453
+ function currentGuide(state: MachineState): SnapGuide | null {
1454
+ return state.kind === 'dragging' ? state.guide : null
1455
+ }
1456
+
1457
+ /** Append a snap-guide effect when, and only when, the guide actually moves —
1458
+ * or changes tier, which changes how it is drawn.
1459
+ * Same contract as `withCursor`: the host holds the last value, so a silent
1460
+ * transition means "unchanged", never "unknown". */
1461
+ function guideEffects(previous: SnapGuide | null, next: SnapGuide | null, effects: PointerEffect[]): PointerEffect[] {
1462
+ if (previous?.time === next?.time && previous?.strength === next?.strength) return effects
1463
+ return [...effects, { type: 'snapGuide', time: next?.time ?? null, strength: next?.strength ?? null }]
1464
+ }
1465
+
1466
+ /** Append a cursor effect when, and only when, the cursor actually changes. */
1467
+ function withCursor(state: MachineState, cursor: Cursor, effects: PointerEffect[]): Transition {
1468
+ if (state.cursor === cursor) return { state, effects }
1469
+ return { state: { ...state, cursor }, effects: [...effects, { type: 'cursor', cursor }] }
1470
+ }
1471
+
1472
+ export function pointerReducer(state: MachineState, event: PointerMachineEvent): Transition {
1473
+ if (event.type === 'cancel') {
1474
+ // Carry the current cursor into the idle state first, so `withCursor` sees
1475
+ // the change it has to undo — a cancelled drag must not leave the surface
1476
+ // showing a grabbing hand. A cancelled marquee takes its box with it and
1477
+ // selects nothing, which is what cancel means.
1478
+ const cancelled: PointerEffect[] = state.kind === 'dragging' && state.gesture === 'marquee'
1479
+ ? [{ type: 'marquee', rect: null }]
1480
+ : []
1481
+ return withCursor(
1482
+ { kind: 'idle', cursor: state.cursor },
1483
+ initialMachineState().cursor,
1484
+ guideEffects(currentGuide(state), null, cancelled),
1485
+ )
1486
+ }
1487
+
1488
+ const { point, modifiers, ctx } = event
1489
+
1490
+ // Mid-drag moves are the hot path (one per pointer event, all gesture) and
1491
+ // have no use for a hit result, so they take the early exit before one is
1492
+ // computed.
1493
+ if (event.type === 'pointerMove' && state.kind === 'dragging') {
1494
+ // Latched, never cleared: once this gesture has pulled clear of its origin
1495
+ // the boundary it started on stays armed for the rest of the drag.
1496
+ const escaped = state.escaped || hasEscaped(ctx, state.press, point)
1497
+ const applied = applyGesture(state.gesture, ctx, state.press, point, state.snap, state.lastProject, escaped)
1498
+ return {
1499
+ state: {
1500
+ ...state,
1501
+ snap: applied.snap,
1502
+ guide: applied.guide,
1503
+ lastProject: applied.lastProject,
1504
+ escaped,
1505
+ keyframeT: applied.keyframeLandedT ?? state.keyframeT,
1506
+ },
1507
+ effects: guideEffects(state.guide, applied.guide, applied.effects),
1508
+ }
1509
+ }
1510
+
1511
+ // `selectedIds` is merged in here rather than left for each caller to add
1512
+ // to its own `hitTestOptions`, so every host that builds a `PointerContext`
1513
+ // gets keyframe hit-testing for free the moment it has a selection —
1514
+ // exactly like `ctx.selectedIds` itself is already required on the context,
1515
+ // not an opt-in.
1516
+ const hit = hitTest(point, ctx.layout, ctx.viewport, { ...ctx.hitTestOptions, selectedIds: ctx.selectedIds })
1517
+
1518
+ switch (event.type) {
1519
+ case 'pointerDown': {
1520
+ // The ruler, OR a press on the playhead line itself: seek straight away
1521
+ // and keep scrubbing. The ruler is the surface's dedicated playhead
1522
+ // handle — canvas mode dropped the DOM overview bar (see Scrubber.tsx) and
1523
+ // the track area's empty space now belongs to the marquee — and grabbing
1524
+ // the full-height red bar anywhere it is reachable does the same thing
1525
+ // (`grabsPlayhead`). Landing the seek on mousedown rather than mouseup is
1526
+ // what makes the drag continuous.
1527
+ const onRuler = hit.kind === 'ruler'
1528
+ if (onRuler || grabsPlayhead(hit, point, ctx)) {
1529
+ const applied = applyScrub(ctx, point, createSnapState(), ctx.project)
1530
+ // The RULER clears the selection, for the same reason pressing bare
1531
+ // timeline used to: a clip left selected while you scrub somewhere else
1532
+ // means the next keyboard action (split, ripple-delete) hits an item
1533
+ // nowhere near where you are looking. Additive presses are exempt —
1534
+ // shift is how you build a multi-selection, not how you drop one.
1535
+ // Grabbing the playhead itself does NOT clear it (decision D3): it is a
1536
+ // precise nudge in place, not a jump to somewhere else, and dropping a
1537
+ // selection every time you fine-tune the playhead would be hostile.
1538
+ const cleared: PointerEffect[] = (onRuler && !isAdditive(modifiers))
1539
+ ? [{ type: 'select', id: null, additive: false }, ...applied.effects]
1540
+ : applied.effects
1541
+ const next: MachineState = {
1542
+ kind: 'dragging',
1543
+ cursor: state.cursor,
1544
+ press: {
1545
+ origin: point,
1546
+ originTime: xToTime(point.x, ctx.viewport),
1547
+ modifiers,
1548
+ hit,
1549
+ baseProject: ctx.project,
1550
+ wasSelected: false,
1551
+ // A scrub never reads these (it uses `playheadSnapPoints`), but a
1552
+ // Press without them would be a Press with a hole in it.
1553
+ snapBoundaries: tieredBoundaries(ctx.project, hit.trackIdx, hit.laneIdx, hit.itemId),
1554
+ },
1555
+ gesture: 'scrub',
1556
+ snap: applied.snap,
1557
+ guide: applied.guide,
1558
+ lastProject: ctx.project,
1559
+ escaped: false,
1560
+ }
1561
+ return withCursor(next, cursorForGesture('scrub'), guideEffects(currentGuide(state), applied.guide, cleared))
1562
+ }
1563
+
1564
+ // On an item, or on empty track area: nothing happens yet. Whether this
1565
+ // is a click (select an item / seek) or a drag (edit / marquee) is not
1566
+ // knowable until the pointer moves or lifts. Empty space used to seek on
1567
+ // press; it now waits, because the same press may turn out to be the
1568
+ // start of a selection box and seeking first would move the playhead
1569
+ // every time you drew one.
1570
+ const press: Press = {
1571
+ origin: point,
1572
+ originTime: xToTime(point.x, ctx.viewport),
1573
+ modifiers,
1574
+ hit,
1575
+ baseProject: ctx.project,
1576
+ wasSelected: hit.itemId !== undefined && ctx.selectedIds.includes(hit.itemId),
1577
+ snapBoundaries: tieredBoundaries(ctx.project, hit.trackIdx, hit.laneIdx, hit.itemId),
1578
+ }
1579
+ return withCursor({ kind: 'pressed', cursor: state.cursor, press }, cursorForHit(hit), [])
1580
+ }
1581
+
1582
+ case 'pointerMove': {
1583
+ // Dragging already returned above; only hover and a not-yet-resolved
1584
+ // press reach here.
1585
+ if (state.kind !== 'pressed') {
1586
+ // Over the playhead line the cursor is the scrub cursor, so hovering the
1587
+ // red bar tells you it is grabbable before you press it.
1588
+ const cursor = grabsPlayhead(hit, point, ctx) ? 'ew-resize' : cursorForHit(hit)
1589
+ return withCursor(state, cursor, [])
1590
+ }
1591
+
1592
+ if (travelled(point, state.press.origin) < DRAG_THRESHOLD_PX) return { state, effects: [] }
1593
+ const gesture = resolveGesture(state.press.hit, state.press.modifiers)
1594
+ if (gesture === null) return { state, effects: [] }
1595
+ // Grabbing an UNSELECTED clip, bar or caption to move it makes it the
1596
+ // selection first, so it is the thing that moves and the thing left
1597
+ // selected after — matching every NLE. A drag that starts on an item
1598
+ // already in a multi-selection is a group move and leaves the selection
1599
+ // untouched. Note this effect only reaches `ctx.selectedIds` on the NEXT
1600
+ // event, which is why each move applier has to cope with its own dragged
1601
+ // id being absent from it (see `applyCaptionMove`).
1602
+ const draggedId = state.press.hit.itemId
1603
+ const selectFirst: PointerEffect[] =
1604
+ (gesture === 'move' || gesture === 'audio-move' || gesture === 'caption-move')
1605
+ && draggedId !== undefined && !ctx.selectedIds.includes(draggedId)
1606
+ ? [{ type: 'selectMany', ids: [draggedId], additive: false }]
1607
+ : []
1608
+ const escaped = hasEscaped(ctx, state.press, point)
1609
+ const applied = applyGesture(gesture, ctx, state.press, point, createSnapState(), state.press.baseProject, escaped)
1610
+ const next: MachineState = {
1611
+ kind: 'dragging',
1612
+ cursor: state.cursor,
1613
+ press: state.press,
1614
+ gesture,
1615
+ snap: applied.snap,
1616
+ guide: applied.guide,
1617
+ lastProject: applied.lastProject,
1618
+ escaped,
1619
+ keyframeT: applied.keyframeLandedT,
1620
+ }
1621
+ return withCursor(next, cursorForGesture(gesture), guideEffects(null, applied.guide, [...selectFirst, ...applied.effects]))
1622
+ }
1623
+
1624
+ case 'pointerUp': {
1625
+ if (state.kind === 'pressed') {
1626
+ const { hit: pressed, wasSelected } = state.press
1627
+ const effects: PointerEffect[] = []
1628
+ if (pressed.itemId !== undefined) {
1629
+ const additive = isAdditive(modifiers)
1630
+ effects.push({ type: 'select', id: pressed.itemId, additive })
1631
+ // VisualTrackRow seeks on a plain click that changes the selection;
1632
+ // AudioTrackRow never seeks. Both use the raw, unsnapped click time.
1633
+ const visual = pressed.kind === 'item-body' || pressed.kind === 'item-edge'
1634
+ if (visual && !additive && !wasSelected) {
1635
+ effects.push({ type: 'seek', time: clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration) })
1636
+ } else if (isCaptionHit(pressed) && pressed.segment && !additive && !wasSelected) {
1637
+ // A caption seeks to its OWN start, not to the clicked time —
1638
+ // the preview only offers drag handles for the segment active at
1639
+ // the playhead, so selecting one without seeking into it selects
1640
+ // something the preview cannot yet act on.
1641
+ //
1642
+ // And half a frame IN, not to `start` itself. `CaptionPreview`
1643
+ // snaps the clock to the frame grid (`t = Math.round(currentTime *
1644
+ // fps) / fps`) before the templates' own `t >= start && t < end`
1645
+ // test, and caption starts are arbitrary floats out of Whisper.
1646
+ // Seeking to exactly `start` therefore rounds DOWN into the
1647
+ // PREVIOUS segment whenever `start * fps` has a fractional part
1648
+ // below 0.5 — roughly half of all segments (start 3.44 at 30fps →
1649
+ // frame 103 → t 3.4333 < 3.44). `start + 0.5 / fps` puts the
1650
+ // snapped `t` in [start, start + 1/fps), always inside, and
1651
+ // `CAPTION_MIN_DURATION_S` guarantees no segment is under a frame.
1652
+ //
1653
+ // `ctx.fps` comes from `project.settings?.fps ?? 30`, which lets an
1654
+ // explicit `0` (falsy, but not nullish) through untouched — and
1655
+ // `0.5 / 0` is `Infinity`, which the clamp below parks at
1656
+ // `totalDuration` instead of inside the segment. Guarded here too.
1657
+ const fps = Number.isFinite(ctx.fps) && ctx.fps > 0 ? ctx.fps : 30
1658
+ effects.push({ type: 'seek', time: clamp(pressed.segment.start + 0.5 / fps, 0, ctx.totalDuration) })
1659
+ }
1660
+ } else if (isEmptyHit(pressed)) {
1661
+ // A click on empty track area that never became a marquee: seek and
1662
+ // drop the selection, which is what pressing there did before the
1663
+ // marquee took the drag half of this gesture.
1664
+ if (!isAdditive(modifiers)) effects.push({ type: 'select', id: null, additive: false })
1665
+ effects.push({ type: 'seek', time: clamp(xToTime(point.x, ctx.viewport), 0, ctx.totalDuration) })
1666
+ }
1667
+ return withCursor({ kind: 'idle', cursor: state.cursor }, cursorForHit(hit), effects)
1668
+ }
1669
+
1670
+ if (state.kind === 'dragging') {
1671
+ const effects: PointerEffect[] = []
1672
+ if (state.gesture === 'marquee') {
1673
+ // Take the box down first, then apply what it caught. A marquee never
1674
+ // touches the project, so it never commits.
1675
+ const rect = normalizeRect(state.press.origin, point)
1676
+ effects.push({ type: 'marquee', rect: null })
1677
+ effects.push({
1678
+ type: 'selectMany',
1679
+ ids: itemsInRect(rect, ctx.layout, ctx.viewport),
1680
+ additive: isAdditive(state.press.modifiers) || isAdditive(modifiers),
1681
+ })
1682
+ return withCursor(
1683
+ { kind: 'idle', cursor: state.cursor },
1684
+ cursorForHit(hit),
1685
+ guideEffects(state.guide, null, effects),
1686
+ )
1687
+ }
1688
+ // A scrub has nothing to persist, and a gesture whose every move clamped
1689
+ // to a no-op has nothing either — committing an unchanged project would
1690
+ // make the host write the file for a gesture that did nothing.
1691
+ if (state.gesture !== 'scrub' && state.lastProject !== state.press.baseProject) {
1692
+ // Snap any magnetic audio lane gapless on RELEASE, not mid-drag —
1693
+ // `reflowMagneticLanes` is idempotent and only ever touches
1694
+ // magnetic audio lanes, so visual/caption gestures and non-magnetic
1695
+ // audio are unaffected; keeping it off the transient
1696
+ // `projectChange` frames is what keeps the drag itself smooth.
1697
+ let committed = reflowMagneticLanes(state.lastProject)
1698
+ // A caption that was alone in its row leaves a HOLE lane behind it.
1699
+ // Collapse it here — after the "did anything change" gate, inside the
1700
+ // one commit — so a cross-row drag renumbers exactly once and lands
1701
+ // as a single undo entry.
1702
+ //
1703
+ // The mid-drag `projectChange` frames deliberately keep the
1704
+ // UN-normalized project: the hole lane is what keeps
1705
+ // `groupCaptionLanes` emitting an empty band there, and that band is
1706
+ // what stops the whole timeline jumping a row height under the
1707
+ // pointer half way through the gesture.
1708
+ if (state.gesture === 'caption-move') {
1709
+ const normalized = normalizeCaptionLanes(committed.captions)
1710
+ if (normalized !== committed.captions) committed = { ...committed, captions: normalized }
1711
+ }
1712
+ effects.push({ type: 'commit', project: committed })
1713
+ // CapCut-correct retime: the diamond you dragged is the diamond you
1714
+ // have selected, even though its `t` just changed underneath the
1715
+ // selection's old value. Only follows when the dragged diamond WAS
1716
+ // the selected one (`ctx.selectedKeyframe` matches this gesture's
1717
+ // press-time itemId + fromT) — dragging some OTHER, unselected
1718
+ // diamond must not steal the selection, and a menu/Delete-driven
1719
+ // removal (not a retime) still goes through the host's own
1720
+ // clearing paths untouched by this branch. Gated on the same
1721
+ // "did anything actually change" check above, so a drag that
1722
+ // never crossed a real move never touches the selection either.
1723
+ if (state.gesture === 'keyframe-move' && state.keyframeT !== undefined) {
1724
+ const { itemId, kfT: fromT } = state.press.hit
1725
+ const sel = ctx.selectedKeyframe
1726
+ if (itemId !== undefined && fromT !== undefined && sel && sel.itemId === itemId && sel.t === fromT) {
1727
+ effects.push({ type: 'selectKeyframe', itemId, t: state.keyframeT })
1728
+ }
1729
+ }
1730
+ }
1731
+ return withCursor(
1732
+ { kind: 'idle', cursor: state.cursor },
1733
+ cursorForHit(hit),
1734
+ guideEffects(state.guide, null, effects),
1735
+ )
1736
+ }
1737
+
1738
+ return withCursor(state, cursorForHit(hit), [])
1739
+ }
1740
+
1741
+ case 'doubleClick': {
1742
+ // A diamond selects itself. Checked first: a 'keyframe' hit also carries an
1743
+ // itemId, so the generic branch below would otherwise swallow it.
1744
+ if (hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined) {
1745
+ return { state, effects: [{ type: 'selectKeyframe', itemId: hit.itemId, t: hit.kfT }] }
1746
+ }
1747
+ // Key the SELECTED element, and only when the click landed ON it. Checked
1748
+ // against `ctx.selectedIds` rather than "is there a selection" so a
1749
+ // double-click on a different clip never keys the selected one.
1750
+ if (
1751
+ (hit.kind === 'item-body' || hit.kind === 'item-edge') &&
1752
+ hit.item && hit.itemId !== undefined &&
1753
+ ctx.selectedIds.includes(hit.itemId) &&
1754
+ canKeyframe(hit.item)
1755
+ ) {
1756
+ const item = hit.item
1757
+ const localT = clamp(hit.t - item.start, 0, Math.max(0, item.end - item.start))
1758
+
1759
+ // WHICH props get keyed depends on the item, and this used to be a
1760
+ // hand-maintained five-prop constant here. `transformProps` is the
1761
+ // shared rule (see its doc comment for why a fixed list breaks in both
1762
+ // directions): keying `scaleX`/`scaleY` on a uniform overlay freezes its
1763
+ // zoom, and keying `scale` on a per-axis one does nothing at all,
1764
+ // because a per-axis value shadows the uniform one in the resolver.
1765
+ // OverlayInspector's header keyframe-all action reads the same function.
1766
+ //
1767
+ // Every value read off the ORIGINAL item BEFORE threading, so one prop's
1768
+ // write cannot perturb another's sample. Same rule OverlayInspector's
1769
+ // header diamond follows.
1770
+ const sampled = transformProps(item).map(prop => [prop, valueAt(item, prop, localT)] as const)
1771
+ let nextItem = item
1772
+ for (const [prop, value] of sampled) {
1773
+ nextItem = setKeyframe(enableKeyframing(nextItem, prop, localT), prop, localT, value)
1774
+ }
1775
+ if (nextItem === item) return { state, effects: [] }
1776
+
1777
+ // `ctx.project` is the ONLY project reference in scope here. The
1778
+ // `doubleClick` case is a top-level branch of the reducer's switch — it is
1779
+ // not nested under a `pressed`/`dragging` state, so there is no `press`
1780
+ // and no `baseProject`. (`baseProject` is a drag-only concept: recompute
1781
+ // from press-time so a back-and-forth drag cannot compound. A discrete
1782
+ // double-click never presses, so it is meaningless here.)
1783
+ const next = replaceVisualItem(ctx.project, item.id, { keyframes: nextItem.keyframes })
1784
+ return { state, effects: [
1785
+ { type: 'projectChange', project: next },
1786
+ { type: 'commit', project: next },
1787
+ ] }
1788
+ }
1789
+ // A clip or bar opens its inspector. Timeline background does nothing:
1790
+ // double-clicking it used to place the A/B range markers, which are gone.
1791
+ if (hit.itemId !== undefined) {
1792
+ // A caption has no inspector — it opens its text for editing instead.
1793
+ if (isCaptionHit(hit)) return { state, effects: [{ type: 'editCaption', id: hit.itemId }] }
1794
+ // 'keyframe' is visual too — a diamond only ever exists on an overlay
1795
+ // item (never an audio track). A well-formed diamond hit already
1796
+ // returned above; this only catches one missing its `kfT`, which still
1797
+ // belongs to that overlay rather than to any audio bar.
1798
+ const target = hit.kind === 'item-body' || hit.kind === 'item-edge' || hit.kind === 'keyframe' ? 'visual' : 'audio'
1799
+ return { state, effects: [{ type: 'inspect', target, id: hit.itemId }] }
1800
+ }
1801
+ return { state, effects: [] }
1802
+ }
1803
+ }
1804
+ }
1805
+
1806
+ // ── Imperative wrapper ───────────────────────────────────────────────────
1807
+
1808
+ export interface PointerMachine {
1809
+ readonly state: MachineState
1810
+ dispatch(event: PointerMachineEvent): PointerEffect[]
1811
+ reset(): void
1812
+ }
1813
+
1814
+ /** A reducer plus the one mutable slot the component needs. Kept trivial on
1815
+ * purpose: all the behaviour is in `pointerReducer`, which tests drive
1816
+ * directly. */
1817
+ export function createPointerMachine(initial: MachineState = initialMachineState()): PointerMachine {
1818
+ let state = initial
1819
+ return {
1820
+ get state() { return state },
1821
+ dispatch(event) {
1822
+ const transition = pointerReducer(state, event)
1823
+ state = transition.state
1824
+ return transition.effects
1825
+ },
1826
+ reset() { state = initialMachineState() },
1827
+ }
1828
+ }