@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,1447 @@
1
+ /**
2
+ * TimelineCanvas (SP5 T4) — the canvas track-row area. It is the ONLY track
3
+ * surface: it carries the visual tracks, the audio lanes and the caption rows
4
+ * alike, and the DOM rows it replaced (which for a while it was mounted in
5
+ * place of, behind a `timeline.canvas` prop) are gone.
6
+ *
7
+ * ── How this stays fast ──────────────────────────────────────────────────
8
+ * Three rules, all of them about NOT re-rendering React:
9
+ *
10
+ * 1. Two stacked canvases. Content (rows, clips, audio) on the lower
11
+ * one, the playhead alone on the upper one. Playback moves the playhead ~60
12
+ * times a second; on a single canvas each move would have to repaint every
13
+ * clip, which is precisely the cost this surface exists to avoid.
14
+ * 2. The playhead subscribes to the playback clock directly, the way the DOM
15
+ * path's `PlayheadLine` did — except the subscription drives an imperative
16
+ * redraw instead of a React render, so nothing above it re-renders.
17
+ * 3. Zoom/scroll live in an external store (viewport.ts), not React state, so
18
+ * a wheel-zoom gesture never re-renders Timeline. Only the zoom badge
19
+ * subscribes for display.
20
+ *
21
+ * All redraws funnel through `requestRedraw`, which coalesces to one rAF and
22
+ * repaints only the layers marked dirty.
23
+ *
24
+ * ── Pointer interaction (SP5 T5) ─────────────────────────────────────────
25
+ * Every gesture — seek, select, move, trim, roll/slip/slide — is
26
+ * decided by `pointer-machine.ts`, which is pure. This file does only the three
27
+ * things a pure reducer cannot: it turns mouse events into surface-space
28
+ * points, it hands the machine a fresh view of the world on each event, and it
29
+ * performs the effects the machine returns. Listeners follow the same pattern
30
+ * the wheel handler established (a ref to the latest closure, bound once on
31
+ * mount) so a drag never re-binds anything; the document-level move/up pair is
32
+ * attached for the duration of a gesture only, exactly as the DOM rows' drag
33
+ * hook does it.
34
+ */
35
+
36
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
37
+ import type { GetFilmstripArgs, GetWaveformPeaksArgs, FilmstripIndex, PeaksData, PendingDrop, Project, FootageDropPayload, ResolveFilePath, TimelineDropPlacement } from '../../../types'
38
+ import { FOOTAGE_DND_MIME } from '../../../types'
39
+ import type { KeyframeProp } from '../../../schema'
40
+ import type { PlaybackClock } from '../../playback-clock'
41
+ import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../timeline-model'
42
+ import { placeDroppedClip, resolveDropPoint } from '../placement'
43
+ import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
44
+ import { hitTest, isEdgeHit, type Point, type SurfaceRect } from './hit-test'
45
+ import { keyframeUnionTimes } from './keyframe-strip'
46
+ import {
47
+ createPointerMachine,
48
+ type KeyframeSelection,
49
+ type Modifiers,
50
+ type PointerContext,
51
+ type PointerEffect,
52
+ } from './pointer-machine'
53
+ import { DEFAULT_SNAP_CONFIG, type SnapStrength } from './snap'
54
+ import { WaveformPeaksStore, type WaveformSceneLookup } from './waveforms'
55
+ import { FilmstripStore, type FilmstripSceneLookup } from './filmstrips'
56
+ import {
57
+ EDGE_SCROLL_MAX_PX_PER_SEC,
58
+ EDGE_SCROLL_RAMP_PX,
59
+ EDGE_SCROLL_ZONE_PX,
60
+ ZOOM_BUTTON_FACTOR,
61
+ applyWheelIntent,
62
+ clampScrollSeconds,
63
+ edgeScrollDelta,
64
+ fitViewport,
65
+ formatZoomMultiple,
66
+ observeSurface,
67
+ reclampForDuration,
68
+ scaleContextToDpr,
69
+ syncCanvasBackingStore,
70
+ useViewportValue,
71
+ wheelIntent,
72
+ withSurfaceWidth,
73
+ xToTime,
74
+ zoomAtPivot,
75
+ zoomMultiple,
76
+ type SurfaceMetrics,
77
+ type ViewportStore,
78
+ } from './viewport'
79
+
80
+ export interface TimelineCanvasProps {
81
+ project: Project
82
+ clock: PlaybackClock
83
+ /** Shared with Timeline's zoom chrome — see `useCanvasZoomControls`. */
84
+ store: ViewportStore
85
+ /** Content duration plus the timeline's drag headroom (timeline-model). */
86
+ totalDuration: number
87
+ /** The project's frame rate. Used for exactly one thing — a click on a
88
+ * caption seeks half a frame into the segment rather than to its start, so
89
+ * the preview's frame-snapped clock lands inside it. See the seek in
90
+ * `pointer-machine`'s `pointerUp`. */
91
+ fps: number
92
+ /** Unified selection: visual items, audio tracks and caption segments alike.
93
+ * Captions share this ONE array rather than a channel of their own, which is
94
+ * what lets a mixed clip+caption drag commit as a single undo entry. */
95
+ selectedIds: string[]
96
+ /** Clip and audio boundaries the gestures snap to — Timeline's existing
97
+ * `computeDerivedTiming` memo, shared with the DOM rows. Absent means no
98
+ * magnetism, which is the right degradation rather than an error. */
99
+ snapBoundaries?: number[]
100
+ /** Trims close the gap they open, as they do on the DOM rows. */
101
+ rippleMode?: boolean
102
+ /** CapCut's "preview axis", off by default. On, a yellow cursor line tracks
103
+ * the pointer across this surface and `onHoverScrub` reports the time under
104
+ * it, so the host can show that frame in the preview while the playhead
105
+ * stays where it is. Off, this surface behaves exactly as it always has:
106
+ * no cursor line, and clicking seeks as usual. Pointer gestures are
107
+ * identical either way — the toggle adds a hover affordance, it does not
108
+ * change what a click does. */
109
+ previewAxis?: boolean
110
+ /** The time under the pointer while the axis is on, or null when the pointer
111
+ * leaves or a gesture starts. Fires per mousemove, so the host must route it
112
+ * to an external store rather than React state. */
113
+ onHoverScrub?: (time: number | null) => void
114
+ /** Timeline's `handleSelectItem` — additive rules and the item↔caption
115
+ * exclusivity stay owned there, so both surfaces select identically. */
116
+ onSelectItem?: (id: string | null, additive: boolean) => void
117
+ /** A marquee's whole catch, applied in one step. Falls back to replaying
118
+ * `onSelectItem` per id when the host does not implement it, so a host that
119
+ * predates the marquee still selects correctly. */
120
+ onSelectItems?: (ids: string[], additive: boolean) => void
121
+ /** The currently selected keyframe, drawn filled. Null when none. */
122
+ selectedKeyframe?: KeyframeSelection | null
123
+ onSelectKeyframe?: (selection: KeyframeSelection | null) => void
124
+ /** Live, uncommitted edit — fires once per pointer move during a gesture. */
125
+ onProjectChange?: (p: Project) => void
126
+ /** Gesture finished; persist. Same split the DOM rows use. */
127
+ onOverlayEdit?: (p: Project) => void
128
+ onInspectClip?: (id: string) => void
129
+ onInspectAudio?: (id: string) => void
130
+ /** Double-click on a caption block. A caption has no inspector dialog, so
131
+ * this is not `onInspectClip`'s sibling: it asks the host to focus that
132
+ * segment's row in the transcript sidebar, where caption text is edited. */
133
+ onEditCaption?: (id: string) => void
134
+ /** Right-click on an audio bar's fade GRIP — Vegas' own gesture for picking
135
+ * a fade's shape. `x`/`y` are CLIENT coordinates (not surface-relative,
136
+ * unlike every other callback here) because the host renders the picker as
137
+ * an absolutely-positioned DOM menu of its own, outside this canvas
138
+ * surface, and a screen position is what CSS `left`/`top` need. Absent →
139
+ * a right-click on a fade grip falls through to the browser's own context
140
+ * menu, same as anywhere else on the surface. */
141
+ onFadeCurveMenu?: (args: { trackId: string; side: 'in' | 'out'; x: number; y: number }) => void
142
+ /** Right-click on a keyframe-strip diamond (SP9b T3.3) — `onFadeCurveMenu`'s
143
+ * sibling for the keyframe-strip's own popup: the host is expected to
144
+ * offer the six `EASING_NAMES` (via `keyframeOps.setKeyframeEasing`) AND a
145
+ * way to remove the keyframe (via `keyframeOps.removeKeyframe`), applied
146
+ * once per entry in `props` — every keyframe track that has a point at
147
+ * `t`, which is every prop this ONE diamond represents (plan decision 2).
148
+ * `x`/`y` are CLIENT coordinates, same reason `onFadeCurveMenu`'s are.
149
+ * `isLast` is true when `t` is the item's LAST keyframe: its easing (which
150
+ * governs the segment INTO the next keyframe — see `Keyframe.easing`'s
151
+ * doc in schema.ts) has no next keyframe to reach, so a host offering the
152
+ * easing picker should grey it out or omit it there — removal still
153
+ * applies regardless. Absent → a right-click on a diamond falls through to
154
+ * the browser's own context menu. */
155
+ onKeyframeMenu?: (args: { itemId: string; t: number; props: KeyframeProp[]; isLast: boolean; x: number; y: number }) => void
156
+ /** T6 — the host adapter's peaks fetcher, threaded from
157
+ * `adapter.getWaveformPeaks` via Timeline. Absent → no waveforms anywhere
158
+ * (graceful; the surface just never asks). */
159
+ getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
160
+ /** T7 — the host adapter's filmstrip fetcher, threaded from
161
+ * `adapter.getFilmstrip` via Timeline. Absent → no filmstrips or
162
+ * hover-scrub thumbs anywhere (graceful). */
163
+ getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
164
+ /** T7 — resolves a filmstrip sheet's host path into a displayable URL, the
165
+ * SAME mechanism `WaveformChunk.path` uses for the DOM waveform PNGs
166
+ * (`adapter.fileUrl`). Threaded from Timeline's own `resolveFilePath`. */
167
+ resolveFilePath?: ResolveFilePath
168
+ /** Which ground the painter draws on, resolved from the host theme by
169
+ * `VideoEditor` (see `isLightTheme`) and threaded through Timeline. The
170
+ * canvas cannot read a CSS variable, so this is how a theme flip reaches
171
+ * the pixels. Defaults to `'dark'` — the only mode this surface had — so a
172
+ * host that never passes it is unchanged. */
173
+ mode?: TimelineMode
174
+ /** A drop of real OS FILES onto this surface, threaded from
175
+ * `VideoEditorProps.onImportFilesToTimeline` (see its doc for the contract).
176
+ * Its PRESENCE is what makes this surface accept an OS-file drag at all:
177
+ * absent, `dragover` never calls `preventDefault` for one, so the browser
178
+ * keeps its own handling and the whole gesture is inert — which is exactly
179
+ * what a host that predates this feature gets. */
180
+ onImportFilesToTimeline?: (files: File[], placement: TimelineDropPlacement) => void
181
+ /** Ghost bands for the host's in-flight imports, drawn on the overlay layer.
182
+ * Absent/empty → nothing extra is painted. */
183
+ pendingDrops?: readonly PendingDrop[]
184
+ }
185
+
186
+ /**
187
+ * Which video row a drop at surface-y `y` landed on, as an index into the
188
+ * NORMALIZED track order `placeDroppedClip` measures in — or `-1` for "no
189
+ * preference", which is what the ruler, a caption band, an audio lane and the
190
+ * gap between two rows all resolve to.
191
+ *
192
+ * Reads the layout's own row rectangles rather than re-deriving row geometry
193
+ * from heights and gaps: that is the rule stated at the top of `hit-test.ts`
194
+ * ("layout is read, never re-derived"), and it applies here for the same
195
+ * reason — a drop that computed its own rows would drift from the picture the
196
+ * moment a row height changed, and the drift would show as a clip landing on
197
+ * the row above the one you aimed at.
198
+ */
199
+ export function dropTrackIndexAt(y: number, layout: TimelineLayout): number {
200
+ for (const row of layout.rows) {
201
+ if (y >= row.y && y < row.y + row.height) return row.trackIdx
202
+ }
203
+ return -1
204
+ }
205
+
206
+ /**
207
+ * Resolve each host `PendingDrop` to the rectangle its ghost is drawn in.
208
+ *
209
+ * A `trackIndex` of -1 (or one naming a row that no longer exists — the host's
210
+ * list is asynchronous and the project can have changed under it) falls back to
211
+ * the BASE video row: the lowest-`trackIdx` row that holds video, else the
212
+ * first row in the layout. With no rows at all there is nowhere to draw, so the
213
+ * band is dropped rather than guessed at a y of 0, which would put it in the
214
+ * ruler.
215
+ *
216
+ * `drop.newTrack` is the one exception to "look up `trackIndex` in `layout`":
217
+ * there IS no row for it yet — `placeOnNewTrack` (placement.ts) hasn't run —
218
+ * so its rectangle is computed instead of looked up, by `newTrackRowRect`
219
+ * below.
220
+ */
221
+ export function pendingDropBands(
222
+ pendingDrops: readonly PendingDrop[],
223
+ layout: TimelineLayout,
224
+ ): PendingDropBand[] {
225
+ if (layout.rows.length === 0) return []
226
+ // `rows` is in DRAW order (highest trackIdx first), so the base video row is
227
+ // found by scanning for the lowest trackIdx that carries video, not by
228
+ // taking rows[0].
229
+ const videoRows = layout.rows.filter(r => r.items.some(it => it.type === 'video'))
230
+ const baseRow = videoRows.length > 0
231
+ ? videoRows.reduce((lowest, r) => (r.trackIdx < lowest.trackIdx ? r : lowest))
232
+ : layout.rows[layout.rows.length - 1]
233
+
234
+ // Computed lazily, and only once: no drop in the list needs it unless at
235
+ // least one carries `newTrack`, and every `newTrack` drop shares the exact
236
+ // same rectangle (there is only ever one "next new track" position for a
237
+ // given layout).
238
+ let newTrackRow: { y: number; height: number } | null = null
239
+ const resolveNewTrackRow = (): { y: number; height: number } => {
240
+ if (!newTrackRow) newTrackRow = newTrackRowRect(layout, videoRows, baseRow)
241
+ return newTrackRow
242
+ }
243
+
244
+ const bands: PendingDropBand[] = []
245
+ for (const drop of pendingDrops) {
246
+ const row = drop.newTrack
247
+ ? resolveNewTrackRow()
248
+ : layout.rows.find(r => r.trackIdx === drop.trackIndex) ?? baseRow
249
+ bands.push({
250
+ start: drop.atTime,
251
+ end: drop.atTime + Math.max(0, drop.durationSec),
252
+ y: row.y,
253
+ height: row.height,
254
+ label: drop.label,
255
+ })
256
+ }
257
+ return bands
258
+ }
259
+
260
+ /**
261
+ * Where a BRAND NEW video track renders, computed against the CURRENT layout
262
+ * (before that track exists) — the rectangle `pendingDropBands` draws a
263
+ * `newTrack` ghost's band in.
264
+ *
265
+ * `placeOnNewTrack` (placement.ts) always lands a freshly-minted video track
266
+ * at the TOP of the video block — the highest trackIdx among video-kind
267
+ * tracks (its own doc comment walks through why: `orderedTrackArray`'s stable
268
+ * partition keeps the new track last among video-kind tracks, and "last in
269
+ * the video group" is the highest index in a block that is contiguous from
270
+ * 0). In DRAW order that is the row directly ABOVE whichever row currently
271
+ * holds that top-of-video-block spot — above the highest-`trackIdx` entry in
272
+ * `videoRows`, i.e. one row closer to the ruler than the current top video
273
+ * row (or than `baseRow`, when the project's only video row IS the base row).
274
+ * A freshly-created track always carries a video item the moment ingest
275
+ * finishes, so it takes the SAME height every video-kind row gets
276
+ * (`BASE_VISUAL_ROW_RENDER_HEIGHT_PX` — see `computeTimelineLayout`'s own
277
+ * height rule, "any video-kind track", not only trackIdx 0), not the shorter
278
+ * overlay-row height.
279
+ *
280
+ * Clamped so the band can never intrude into the ruler strip: on a project
281
+ * with little or no room between the ruler and the top video row (few or no
282
+ * overlay/caption rows above it) there isn't a full row's worth of space to
283
+ * insert a phantom into without the surface actually growing by one row — the
284
+ * canvas only grows once the real track lands. That is an inherent limit of
285
+ * ghosting a row that does not exist yet, not a bug: the clamp keeps the
286
+ * result directly adjacent to the top of the video block (never past the
287
+ * ruler) rather than off the top of the surface entirely.
288
+ */
289
+ function newTrackRowRect(
290
+ layout: TimelineLayout,
291
+ videoRows: readonly VisualRowLayout[],
292
+ baseRow: VisualRowLayout,
293
+ ): { y: number; height: number } {
294
+ const topVideoRow = videoRows.length > 0
295
+ ? videoRows.reduce((highest, r) => (r.trackIdx > highest.trackIdx ? r : highest))
296
+ : baseRow
297
+ const height = BASE_VISUAL_ROW_RENDER_HEIGHT_PX
298
+ const rulerBottom = layout.ruler.y + layout.ruler.height
299
+ const y = Math.max(rulerBottom, topVideoRow.y - ROW_GAP_PX - height)
300
+ return { y, height }
301
+ }
302
+
303
+ const requestFrame: (cb: () => void) => number =
304
+ typeof requestAnimationFrame === 'function'
305
+ ? cb => requestAnimationFrame(() => cb())
306
+ : cb => setTimeout(cb, 16) as unknown as number
307
+
308
+ const cancelFrame: (handle: number) => void =
309
+ typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout
310
+
311
+ /** A monotonic clock for timing the edge auto-scroll loop's frame deltas.
312
+ * `performance.now()` where available (real browsers, and jsdom under
313
+ * Vitest's fake timers, which fake it in lockstep with `requestAnimationFrame`);
314
+ * `Date.now()` as the only fallback. */
315
+ function perfNow(): number {
316
+ return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()
317
+ }
318
+
319
+ /** Stable empty default so an omitted `snapBoundaries` doesn't churn the
320
+ * pointer layer's latest-props ref with a fresh array every render. */
321
+ const NO_SNAP_BOUNDARIES: number[] = []
322
+
323
+ /** `NO_SNAP_BOUNDARIES`' sibling, for the same reason: an omitted
324
+ * `pendingDrops` must not hand the ghost memo a fresh array each render. */
325
+ const NO_PENDING_DROPS: readonly PendingDrop[] = []
326
+
327
+ /**
328
+ * How tall the surface must be to reach the bottom of the pane's visible area.
329
+ *
330
+ * `surfaceOffsetTop` is the surface's top measured from the scroll CONTENT's
331
+ * origin (everything laid out above it: the zoom chrome, the scrubber, the
332
+ * paddings) — NOT its position on screen. That distinction is what makes the
333
+ * measurement scroll-invariant: scrolling a tall timeline moves the surface's
334
+ * screen position but not its offset within the content, so the fill it asks
335
+ * for never grows as you scroll. It is also independent of the surface's OWN
336
+ * height, so setting the result can never change the next measurement.
337
+ *
338
+ * `paddingBelow` is the padding under the surface in the flow (only the
339
+ * Timeline root's `py-3` contributes today). Pure, so it unit-tests without a
340
+ * layout. Clamped at 0 so an overflowing timeline never asks for a negative
341
+ * height (the caller's Math.max keeps the layout height there instead).
342
+ */
343
+ export function paneFillHeight(viewportHeight: number, surfaceOffsetTop: number, paddingBelow: number): number {
344
+ return Math.max(0, viewportHeight - surfaceOffsetTop - paddingBelow)
345
+ }
346
+
347
+ /** Sum the `padding-bottom` of every element from `from` up to and including
348
+ * `to` — the padding that sits under the surface within the scroll viewport,
349
+ * which the fill must leave clear or it would overflow the pane by that much
350
+ * and show a scrollbar. Generic over the DOM chain rather than hardcoding the
351
+ * root's `py-3`, so a class change to the timeline column can't silently
352
+ * reintroduce the overflow. */
353
+ function paddingBelowSurface(from: HTMLElement | null, to: HTMLElement): number {
354
+ let total = 0
355
+ for (let el: HTMLElement | null = from; el; el = el.parentElement) {
356
+ total += parseFloat(getComputedStyle(el).paddingBottom) || 0
357
+ if (el === to) break
358
+ }
359
+ return total
360
+ }
361
+
362
+ export default function TimelineCanvas({
363
+ project,
364
+ clock,
365
+ store,
366
+ totalDuration,
367
+ fps,
368
+ selectedIds,
369
+ snapBoundaries = NO_SNAP_BOUNDARIES,
370
+ rippleMode = false,
371
+ previewAxis = false,
372
+ onHoverScrub,
373
+ onSelectItem,
374
+ onSelectItems,
375
+ selectedKeyframe = null,
376
+ onSelectKeyframe,
377
+ onProjectChange,
378
+ onOverlayEdit,
379
+ onInspectClip,
380
+ onInspectAudio,
381
+ onEditCaption,
382
+ onFadeCurveMenu,
383
+ onKeyframeMenu,
384
+ getWaveformPeaks,
385
+ getFilmstrip,
386
+ resolveFilePath,
387
+ mode = 'dark',
388
+ onImportFilesToTimeline,
389
+ pendingDrops = NO_PENDING_DROPS,
390
+ }: TimelineCanvasProps) {
391
+ const containerRef = useRef<HTMLDivElement>(null)
392
+ const contentCanvasRef = useRef<HTMLCanvasElement>(null)
393
+ const overlayCanvasRef = useRef<HTMLCanvasElement>(null)
394
+ const metricsRef = useRef<SurfaceMetrics>({ cssWidth: 0, cssHeight: 0, dpr: 1 })
395
+
396
+ // T6 — one fetch-state store for the lifetime of this mounted surface, so
397
+ // in-flight/resolved peaks survive across redraws (pan, zoom, selection).
398
+ const waveformStoreRef = useRef<WaveformPeaksStore | null>(null)
399
+ if (waveformStoreRef.current === null) waveformStoreRef.current = new WaveformPeaksStore()
400
+
401
+ // T7 — same lifetime rule as the waveform store, for filmstrip indexes and
402
+ // decoded sheet images.
403
+ const filmstripStoreRef = useRef<FilmstripStore | null>(null)
404
+ if (filmstripStoreRef.current === null) filmstripStoreRef.current = new FilmstripStore()
405
+
406
+
407
+ // `project.captions` is in here because the caption band is part of the
408
+ // layout: without it a caption move or trim would change the project and
409
+ // repaint the OLD band, so the block would spring back under the cursor.
410
+ const layout = useMemo(() => computeTimelineLayout(project), [project.tracks, project.audio, project.captions])
411
+
412
+ // How far the surface grows PAST the drawn tracks to fill the empty area at
413
+ // the bottom of the resizable timeline pane. The tracks stay top-anchored;
414
+ // this only adds background below them, so a click there hits the canvas
415
+ // (deselect / seek / marquee) instead of dead page space. 0 until measured,
416
+ // and stays 0 in any host that doesn't mark a scroll viewport (see the effect
417
+ // below), which degrades to the pre-fill behaviour.
418
+ const [paneFill, setPaneFill] = useState(0)
419
+ const surfaceHeight = Math.max(layout.height, VISUAL_ROW_RENDER_HEIGHT_PX, paneFill)
420
+
421
+ // Latest draw inputs, readable from the imperative paint without making the
422
+ // paint a dependency of every effect (the ref-to-latest pattern
423
+ // `useTimelineZoom` uses for its wheel handler).
424
+ // The host's in-flight imports, resolved to rectangles against the CURRENT
425
+ // layout. Memoized on the layout too, not just the list: a row added or
426
+ // removed by an unrelated edit moves every row's y, and a ghost still drawn
427
+ // at the old one would float over the wrong track.
428
+ const pendingDropBandList = useMemo(() => pendingDropBands(pendingDrops, layout), [pendingDrops, layout])
429
+
430
+ const sceneRef = useRef({ project, layout, selectedIds, selectedKeyframe, totalDuration, mode, pendingDropBandList })
431
+ sceneRef.current = { project, layout, selectedIds, selectedKeyframe, totalDuration, mode, pendingDropBandList }
432
+
433
+ // The preview-axis cursor, tracked imperatively for the same reason the
434
+ // filmstrip hover thumb is: it moves with every mousemove, and a React state
435
+ // write per mouse position would re-render the caption row's hundreds of DOM
436
+ // nodes to move a 2px line. Read fresh at overlay-paint time.
437
+ const cursorTimeRef = useRef<number | null>(null)
438
+
439
+ // The snap guide, tracked the same imperative way and for the same reason:
440
+ // it moves during a drag, and a React state write per pointer move would
441
+ // re-render the whole timeline to place a 2px line. The machine emits a
442
+ // `snapGuide` effect only when the guide MOVES, so this is written a couple
443
+ // of times per gesture rather than per event.
444
+ const snapGuideRef = useRef<{ time: number; strength: SnapStrength } | null>(null)
445
+
446
+ // The rubber-band box, same imperative treatment: it follows the pointer, so
447
+ // a React state write per move would re-render the timeline to move a
448
+ // rectangle.
449
+ const marqueeRef = useRef<SurfaceRect | null>(null)
450
+
451
+ // The trim handle under the resting pointer, so the painter can light it up.
452
+ // Held as a ref for the usual reason, but redrawn through `requestRedraw`
453
+ // only when the identity changes — crossing into or out of a handle, which
454
+ // happens a handful of times a session, not per mousemove.
455
+ const hoveredHandleRef = useRef<{ itemId: string; edge: 'in' | 'out' } | null>(null)
456
+
457
+ // Hover-scrub emissions, coalesced to one per animation frame.
458
+ //
459
+ // The LINE is cheap — `requestRedraw` already folds it into one rAF. The
460
+ // EMISSION is not: each one asks the preview to show a different frame,
461
+ // which means seeking the source. A trackpad sweep delivers 60-120
462
+ // mousemoves a second, and on long-GOP media each seek cancels the decode
463
+ // still in flight, so the picture can end up never landing a frame at all
464
+ // (worst on un-proxied 4K HEVC, where a seek decodes up to a second of
465
+ // frames). One emission per frame is all a display can show anyway; the
466
+ // intermediate positions are dropped rather than queued, so the seek target
467
+ // is always the pointer's CURRENT position and never a stale backlog.
468
+ const hoverEmitRef = useRef<{ frame: number | null; pending: number | null }>({ frame: null, pending: null })
469
+
470
+ const dirtyRef = useRef({ content: false, overlay: false })
471
+ const frameRef = useRef<number | null>(null)
472
+
473
+ const paintRef = useRef<() => void>(() => {})
474
+ paintRef.current = () => {
475
+ const { cssWidth, cssHeight, dpr } = metricsRef.current
476
+ if (cssWidth <= 0 || cssHeight <= 0) return
477
+ const dirty = dirtyRef.current
478
+ const viewport = store.get()
479
+ const scene = sceneRef.current
480
+
481
+ if (dirty.content) {
482
+ dirty.content = false
483
+ const ctx = contentCanvasRef.current?.getContext('2d')
484
+ if (ctx) {
485
+ scaleContextToDpr(ctx, dpr)
486
+ // T6 — one query context per paint (px/second and the project id can
487
+ // both have moved since the last one); the store itself is what
488
+ // persists across paints. `onReady` re-marks content dirty and
489
+ // schedules a redraw once new data lands, the same way a project
490
+ // edit does.
491
+ const waveforms: WaveformSceneLookup | undefined = getWaveformPeaks
492
+ ? {
493
+ clipColumns: (item, rect) => waveformStoreRef.current!.clipColumns(item, rect, {
494
+ projectId: scene.project.id,
495
+ getWaveformPeaks,
496
+ pxPerSecond: viewport.pxPerSecond,
497
+ // Lets `clipColumns` recover the clip's full on-screen span
498
+ // and slice its peaks to whatever sub-range `rect` actually
499
+ // shows — see `WaveformQueryContext.viewport`.
500
+ viewport,
501
+ onReady: () => requestRedraw('content'),
502
+ }),
503
+ audioColumns: (track, rect) => waveformStoreRef.current!.audioColumns(track, rect, {
504
+ projectId: scene.project.id,
505
+ getWaveformPeaks,
506
+ pxPerSecond: viewport.pxPerSecond,
507
+ viewport,
508
+ onReady: () => requestRedraw('content'),
509
+ }),
510
+ }
511
+ : undefined
512
+ // T7 — filmstrip data is shared with the overlay layer's hover thumb
513
+ // (see filmstrips.ts's `FilmstripQueryContext.onReady` doc), so
514
+ // whichever call resolves a fetch invalidates BOTH layers.
515
+ const filmstrips: FilmstripSceneLookup | undefined = (getFilmstrip && resolveFilePath)
516
+ ? {
517
+ clipTiles: (item, rect) => filmstripStoreRef.current!.clipTiles(item, rect, {
518
+ projectId: scene.project.id,
519
+ getFilmstrip,
520
+ fileUrl: resolveFilePath,
521
+ viewport,
522
+ onReady: () => requestRedraw('all'),
523
+ }),
524
+ }
525
+ : undefined
526
+ drawTimelineContent(ctx, {
527
+ project: scene.project,
528
+ viewport,
529
+ layout: scene.layout,
530
+ selectedIds: scene.selectedIds,
531
+ selectedKeyframe: scene.selectedKeyframe,
532
+ hoveredHandle: hoveredHandleRef.current,
533
+ surfaceWidth: cssWidth,
534
+ surfaceHeight: cssHeight,
535
+ waveforms,
536
+ filmstrips,
537
+ mode: scene.mode,
538
+ })
539
+ }
540
+ }
541
+
542
+ if (dirty.overlay) {
543
+ dirty.overlay = false
544
+ const ctx = overlayCanvasRef.current?.getContext('2d')
545
+ if (ctx) {
546
+ scaleContextToDpr(ctx, dpr)
547
+ drawTimelineOverlay(ctx, {
548
+ viewport,
549
+ currentTime: clock.get(),
550
+ cursorTime: cursorTimeRef.current,
551
+ snapTime: snapGuideRef.current?.time ?? null,
552
+ snapStrength: snapGuideRef.current?.strength ?? null,
553
+ marquee: marqueeRef.current,
554
+ pendingDrops: scene.pendingDropBandList,
555
+ surfaceWidth: cssWidth,
556
+ surfaceHeight: cssHeight,
557
+ mode: scene.mode,
558
+ })
559
+ }
560
+ }
561
+ }
562
+
563
+ const requestRedraw = useCallback((layer: 'content' | 'overlay' | 'all') => {
564
+ const dirty = dirtyRef.current
565
+ if (layer !== 'overlay') dirty.content = true
566
+ if (layer !== 'content') dirty.overlay = true
567
+ if (frameRef.current !== null) return
568
+ frameRef.current = requestFrame(() => {
569
+ frameRef.current = null
570
+ paintRef.current()
571
+ })
572
+ }, [])
573
+
574
+ // The latch must be reset here too, or a StrictMode remount (cleanup runs
575
+ // on the same instance before the effect re-fires) deadlocks every future
576
+ // `requestRedraw` behind a stale non-null handle.
577
+ useEffect(() => () => {
578
+ if (frameRef.current !== null) {
579
+ cancelFrame(frameRef.current)
580
+ frameRef.current = null
581
+ }
582
+ const emit = hoverEmitRef.current
583
+ if (emit.frame !== null) cancelFrame(emit.frame)
584
+ emit.frame = null
585
+ emit.pending = null
586
+ // Same StrictMode concern as `frameRef` above, for the edge auto-scroll
587
+ // loop's own rAF handle.
588
+ stopEdgeAutoScroll()
589
+ }, [])
590
+
591
+ // ── Surface: CSS size, DPR, backing stores ──
592
+ useEffect(() => {
593
+ const el = containerRef.current
594
+ if (!el) return
595
+ return observeSurface(el, metrics => {
596
+ metricsRef.current = metrics
597
+ const content = contentCanvasRef.current
598
+ const overlay = overlayCanvasRef.current
599
+ if (content) syncCanvasBackingStore(content, metrics)
600
+ if (overlay) syncCanvasBackingStore(overlay, metrics)
601
+ store.set(vp => withSurfaceWidth(vp, metrics.cssWidth, sceneRef.current.totalDuration))
602
+ // Unconditional: writing `canvas.width` clears the canvas, and a DPR
603
+ // change invalidates the transform even when the CSS size is identical.
604
+ requestRedraw('all')
605
+ })
606
+ }, [store, requestRedraw])
607
+
608
+ // ── Fill the pane: grow the surface to reach the bottom of the timeline
609
+ // pane, so the empty area under the last track is live canvas ──
610
+ //
611
+ // Measured against the pane's scroll viewport (the `data-timeline-scroll`
612
+ // marker VideoEditor puts on it), whose height is set by the resizable pane
613
+ // and is INDEPENDENT of the surface's own height — so setting `paneFill` can
614
+ // never change what the next measurement reads, and there is no feedback loop
615
+ // even while the surface (and the flow below it) grows and the pane scrolls.
616
+ // A host that marks no viewport (the pending layout, tests) is left at 0 and
617
+ // keeps the pre-fill height. Re-measures on pane resize (the viewport's
618
+ // ResizeObserver) and whenever the layout changes (a row added above the
619
+ // surface shifts its top; more rows change how much space is left).
620
+ useEffect(() => {
621
+ const el = containerRef.current
622
+ if (!el) return
623
+ const scroll = el.closest('[data-timeline-scroll]') as HTMLElement | null
624
+ if (!scroll) return
625
+ const measure = () => {
626
+ const scrollRect = scroll.getBoundingClientRect()
627
+ const surfaceRect = el.getBoundingClientRect()
628
+ // Offset within the scroll CONTENT, not on screen: the `scrollTop` term
629
+ // cancels the shift `surfaceRect.top` takes on when the pane is scrolled,
630
+ // so a scrolled-down measurement asks for the same fill as an unscrolled
631
+ // one (see `paneFillHeight`).
632
+ const surfaceOffsetTop = surfaceRect.top - scrollRect.top + scroll.scrollTop
633
+ const padding = paddingBelowSurface(el.parentElement, scroll)
634
+ const next = Math.round(paneFillHeight(scrollRect.height, surfaceOffsetTop, padding))
635
+ setPaneFill(prev => (prev === next ? prev : next))
636
+ }
637
+ measure()
638
+ let ro: ResizeObserver | undefined
639
+ if (typeof ResizeObserver !== 'undefined') {
640
+ ro = new ResizeObserver(() => measure())
641
+ ro.observe(scroll)
642
+ }
643
+ const onResize = () => measure()
644
+ window.addEventListener('resize', onResize)
645
+ return () => { ro?.disconnect(); window.removeEventListener('resize', onResize) }
646
+ }, [layout])
647
+
648
+ // ── Turning the axis off with the pointer still over the surface takes the
649
+ // line and the host's override down with it: no further mousemove is
650
+ // coming to do it, and the preview would stay frozen on a hovered frame. ──
651
+ useEffect(() => {
652
+ if (previewAxis) return
653
+ const emit = hoverEmitRef.current
654
+ if (emit.frame !== null) cancelFrame(emit.frame)
655
+ emit.frame = null
656
+ emit.pending = null
657
+ if (cursorTimeRef.current === null) return
658
+ cursorTimeRef.current = null
659
+ onHoverScrub?.(null)
660
+ requestRedraw('overlay')
661
+ }, [previewAxis, onHoverScrub, requestRedraw])
662
+
663
+ // ── Viewport: pan/zoom moves everything, including the playhead ──
664
+ useEffect(() => store.subscribe(() => requestRedraw('all')), [store, requestRedraw])
665
+
666
+ // ── Playhead: the only per-tick subscriber, and it repaints one layer ──
667
+ useEffect(() => clock.subscribe(() => requestRedraw('overlay')), [clock, requestRedraw])
668
+
669
+ // ── Theme: a light/dark flip repaints BOTH layers ──
670
+ //
671
+ // `mode` is not part of the content effect below on purpose. It changes the
672
+ // colour of marks on the OVERLAY too — the playhead, the axis cursor, the
673
+ // marquee — and that layer is repainted only by the clock, the viewport or a
674
+ // gesture. Without this the surface would keep showing stale pixels of the
675
+ // previous theme until something unrelated happened to touch each layer,
676
+ // which for the overlay of a paused, untouched timeline is "never". The
677
+ // paint reads `sceneRef.current.mode`, written during the same render that
678
+ // schedules this effect, so the repaint always sees the NEW mode.
679
+ useEffect(() => { requestRedraw('all') }, [mode, requestRedraw])
680
+
681
+ // ── Content: project/selection edits ──
682
+ const selectionKey = selectedIds.join('\0')
683
+ // The selected keyframe is content too — the strip draws it filled — so a
684
+ // change of diamond has to repaint that layer, same as a change of item.
685
+ useEffect(() => { requestRedraw('content') }, [project, layout, selectionKey, selectedKeyframe, requestRedraw])
686
+
687
+ // ── Overlay: the pending-import ghosts ──
688
+ //
689
+ // The ghosts live on the overlay layer, which is repainted only by the clock,
690
+ // the viewport or a gesture — so without this a ghost would appear (or fail
691
+ // to disappear) only on the next unrelated repaint, i.e. "never" on a paused,
692
+ // untouched timeline. Keyed by CONTENT rather than by the array's identity,
693
+ // exactly like `selectionKey` above: a host that rebuilds the list each
694
+ // render (or passes an inline `[]`) must not schedule a repaint per render
695
+ // for a picture that hasn't moved.
696
+ const pendingDropsKey = pendingDropBandList
697
+ .map(b => [b.start, b.end, b.y, b.height, b.label ?? ''].join('|'))
698
+ .join('\0')
699
+ useEffect(() => { requestRedraw('overlay') }, [pendingDropsKey, requestRedraw])
700
+
701
+ // ── Duration changes re-clamp scale and scroll ──
702
+ useEffect(() => {
703
+ store.set(vp => reclampForDuration(vp, totalDuration))
704
+ requestRedraw('all')
705
+ }, [totalDuration, store, requestRedraw])
706
+
707
+ // ── Wheel: ⌘/Ctrl zoom at cursor, Alt (or horizontal) pan, plain wheel
708
+ // left alone so the page keeps scrolling. preventDefault only fires for
709
+ // intents we consume, which is why the listener must be non-passive —
710
+ // React's onWheel is registered passive at the root (same reason
711
+ // useTimelineZoom binds natively). ──
712
+ const wheelRef = useRef<(e: WheelEvent) => void>(() => {})
713
+ wheelRef.current = (e: WheelEvent) => {
714
+ const el = containerRef.current
715
+ if (!el) return
716
+ const intent = wheelIntent(e, e.clientX - el.getBoundingClientRect().left)
717
+ if (intent.kind === 'none') return
718
+ e.preventDefault()
719
+ // Don't let a wheel gesture we've already consumed bubble to the scroll
720
+ // container we sit inside, or to the page. (This originally existed for a
721
+ // sharper reason: the DOM timeline bound its own non-passive wheel
722
+ // listener to that container, and without this it would zoom its separate
723
+ // multiplier off the same gesture and widen the page under the canvas.
724
+ // Those rows and that listener are gone — see `useTimelineZoom` — so this
725
+ // is now just ordinary "we handled it" containment.)
726
+ e.stopPropagation()
727
+ store.set(vp => applyWheelIntent(vp, intent, sceneRef.current.totalDuration))
728
+ }
729
+
730
+ useEffect(() => {
731
+ const el = containerRef.current
732
+ if (!el) return
733
+ const onWheel = (e: WheelEvent) => wheelRef.current(e)
734
+ el.addEventListener('wheel', onWheel, { passive: false })
735
+ return () => el.removeEventListener('wheel', onWheel)
736
+ }, [])
737
+
738
+ // ── Pointer: the gesture machine's DOM shell ──
739
+
740
+ const machineRef = useRef<ReturnType<typeof createPointerMachine> | null>(null)
741
+ if (machineRef.current === null) machineRef.current = createPointerMachine()
742
+ const machine = machineRef.current
743
+
744
+ // Everything the machine's context and effects need, refreshed each render so
745
+ // handlers bound once on mount never read a stale project or callback.
746
+ const pointerRef = useRef({
747
+ project, layout, selectedIds, selectedKeyframe, snapBoundaries, totalDuration, fps, rippleMode, previewAxis,
748
+ onSelectItem, onSelectItems, onSelectKeyframe, onProjectChange, onOverlayEdit, onInspectClip, onInspectAudio, onEditCaption, onHoverScrub, onFadeCurveMenu, onKeyframeMenu,
749
+ onImportFilesToTimeline,
750
+ })
751
+ pointerRef.current = {
752
+ project, layout, selectedIds, selectedKeyframe, snapBoundaries, totalDuration, fps, rippleMode, previewAxis,
753
+ onSelectItem, onSelectItems, onSelectKeyframe, onProjectChange, onOverlayEdit, onInspectClip, onInspectAudio, onEditCaption, onHoverScrub, onFadeCurveMenu, onKeyframeMenu,
754
+ // Read by the drag handlers below, which are bound ONCE on mount — a
755
+ // file-drop hook read from the closure instead of from here would be the
756
+ // one the host passed on the very first render, forever.
757
+ onImportFilesToTimeline,
758
+ }
759
+
760
+ const buildContext = useCallback((): PointerContext => {
761
+ const p = pointerRef.current
762
+ return {
763
+ project: p.project,
764
+ layout: p.layout,
765
+ viewport: store.get(),
766
+ selectedIds: p.selectedIds,
767
+ selectedKeyframe: p.selectedKeyframe,
768
+ snapBoundaries: p.snapBoundaries,
769
+ totalDuration: p.totalDuration,
770
+ fps: p.fps,
771
+ rippleMode: p.rippleMode,
772
+ playheadTime: clock.get(),
773
+ }
774
+ }, [store, clock])
775
+
776
+ const runEffects = useCallback((effects: PointerEffect[]) => {
777
+ const p = pointerRef.current
778
+ let edited = false
779
+ for (const effect of effects) {
780
+ switch (effect.type) {
781
+ case 'seek': clock.set(effect.time); break
782
+ case 'select': p.onSelectItem?.(effect.id, effect.additive); break
783
+ case 'selectKeyframe': p.onSelectKeyframe?.({ itemId: effect.itemId, t: effect.t }); break
784
+ case 'projectChange': p.onProjectChange?.(effect.project); edited = true; break
785
+ case 'commit': p.onOverlayEdit?.(effect.project); break
786
+ case 'inspect': (effect.target === 'visual' ? p.onInspectClip : p.onInspectAudio)?.(effect.id); break
787
+ case 'editCaption': p.onEditCaption?.(effect.id); break
788
+ // Cursor is written straight to the node: an affordance that changes on
789
+ // every hover must not cost a React render.
790
+ case 'cursor': if (containerRef.current) containerRef.current.style.cursor = effect.cursor; break
791
+ // Overlay-only: the guide is gesture feedback, not content, so putting
792
+ // it up or taking it down never costs a filmstrip repaint.
793
+ case 'snapGuide':
794
+ snapGuideRef.current = effect.time === null || effect.strength === null
795
+ ? null
796
+ : { time: effect.time, strength: effect.strength }
797
+ requestRedraw('overlay')
798
+ break
799
+ // Overlay-only for the same reason as the guide: the box follows the
800
+ // pointer, and repainting clips and filmstrips behind it sixty times a
801
+ // second to move a rectangle would be absurd.
802
+ case 'marquee':
803
+ marqueeRef.current = effect.rect
804
+ requestRedraw('overlay')
805
+ break
806
+ case 'selectMany':
807
+ if (p.onSelectItems) {
808
+ p.onSelectItems(effect.ids, effect.additive)
809
+ } else {
810
+ // No bulk handler: replay as singles. The first is non-additive
811
+ // unless the marquee itself was additive (so it replaces the old
812
+ // selection), and every one after it extends what the first set.
813
+ if (!effect.additive && effect.ids.length === 0) p.onSelectItem?.(null, false)
814
+ effect.ids.forEach((id, i) => p.onSelectItem?.(id, effect.additive || i > 0))
815
+ }
816
+ break
817
+ }
818
+ }
819
+ // The edit reaches the surface as a new `project` prop, which schedules a
820
+ // redraw on its own — but only once the host echoes it back. Marking the
821
+ // content dirty here keeps a drag responsive under a host that defers.
822
+ if (edited) requestRedraw('content')
823
+ }, [clock, requestRedraw])
824
+
825
+ // Document-level move/up, live only for the duration of a gesture (the DOM
826
+ // drag hook's pattern) so ordinary mouse movement over the page costs nothing.
827
+ const releaseGestureRef = useRef<(() => void) | null>(null)
828
+
829
+ // The surface rect, frozen for the duration of a gesture — see
830
+ // `surfacePoint`'s comment for why. Null while idle.
831
+ const gestureRectRef = useRef<DOMRect | null>(null)
832
+
833
+ // ── Edge auto-scroll ──
834
+ //
835
+ // The latest surface-space point and modifiers a real pointermove reported
836
+ // during a drag. The rAF loop below re-feeds THIS SAME point back into the
837
+ // machine after each pan, rather than reading a fresh one — the pointer
838
+ // itself hasn't moved, only the view under it has, so re-dispatching the
839
+ // unchanged point against the panned viewport is exactly what advances the
840
+ // dragged item's time (see `dispatchPointerMove`). Null outside a gesture.
841
+ const lastDragPointRef = useRef<{ point: Point; modifiers: Modifiers } | null>(null)
842
+ // The loop's own rAF handle, and the timestamp its last tick ran at (for a
843
+ // framerate-independent pan). Both null while the loop isn't running.
844
+ const edgeScrollFrameRef = useRef<number | null>(null)
845
+ const edgeScrollLastTimeRef = useRef<number | null>(null)
846
+
847
+ const handlersRef = useRef({
848
+ down: (_e: MouseEvent) => {},
849
+ hover: (_e: MouseEvent) => {},
850
+ move: (_e: MouseEvent) => {},
851
+ up: (_e: MouseEvent) => {},
852
+ doubleClick: (_e: MouseEvent) => {},
853
+ leave: (_e: MouseEvent) => {},
854
+ contextMenu: (_e: MouseEvent) => {},
855
+ })
856
+
857
+ function surfacePoint(e: MouseEvent): Point | null {
858
+ const el = containerRef.current
859
+ if (!el) return null
860
+ // A gesture's own live edit can resize the surface and shift its rect mid-
861
+ // drag (a cross-track move adds/removes a row under the moving cursor); the
862
+ // press-time rect is that gesture's fixed frame of reference, matching the
863
+ // DOM drag hooks' raw-delta math. Idle reads (hover, no gesture) stay live.
864
+ const rect = gestureRectRef.current ?? el.getBoundingClientRect()
865
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top }
866
+ }
867
+
868
+ function modifiersOf(e: MouseEvent): Modifiers {
869
+ return { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey, ctrl: e.ctrlKey }
870
+ }
871
+
872
+ // ── Trim-handle hover ──
873
+ //
874
+ // A second hit-test per hover event, on top of the one the machine does
875
+ // internally. It is gated on there BEING a selection, because handles are
876
+ // only drawn on selected items: with nothing selected — the common state
877
+ // while just moving the pointer around — this costs a length check and
878
+ // returns.
879
+ //
880
+ // The alternative was another remembered value in the machine and an effect
881
+ // per hover; a hit-test over the visible rows is cheaper than that, and it
882
+ // keeps the machine about gestures rather than about paint.
883
+
884
+ function hoveredHandleAt(point: Point): { itemId: string; edge: 'in' | 'out' } | null {
885
+ const p = pointerRef.current
886
+ if (p.selectedIds.length === 0) return null
887
+ // `selectedIds` also lets a keyframe diamond's own small zone take
888
+ // precedence here, same as it does in the machine's own hit-test — a
889
+ // trim handle must not light up underneath a diamond that would win the
890
+ // actual press.
891
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
892
+ if (!isEdgeHit(hit) || hit.itemId === undefined || hit.edge === undefined) return null
893
+ return p.selectedIds.includes(hit.itemId) ? { itemId: hit.itemId, edge: hit.edge } : null
894
+ }
895
+
896
+ function updateHoveredHandle(point: Point) {
897
+ const next = hoveredHandleAt(point)
898
+ const prev = hoveredHandleRef.current
899
+ if (prev?.itemId === next?.itemId && prev?.edge === next?.edge) return
900
+ hoveredHandleRef.current = next
901
+ requestRedraw('content')
902
+ }
903
+
904
+ function clearHoveredHandle() {
905
+ if (hoveredHandleRef.current === null) return
906
+ hoveredHandleRef.current = null
907
+ requestRedraw('content')
908
+ }
909
+
910
+ // ── Preview axis: the cursor line, and the frame it asks the host to show ──
911
+ //
912
+ // Both halves are driven from the same place so the line and the previewed
913
+ // frame can never disagree. Time is taken raw from the x position — no
914
+ // snapping, because a hover affordance that jumped to clip boundaries would
915
+ // preview a frame other than the one the line is drawn at.
916
+
917
+ function updateAxisCursor(point: Point) {
918
+ const p = pointerRef.current
919
+ if (!p.previewAxis) return
920
+ const t = Math.max(0, Math.min(p.totalDuration, xToTime(point.x, store.get())))
921
+ if (cursorTimeRef.current === t) return
922
+ cursorTimeRef.current = t
923
+ requestRedraw('overlay')
924
+ // The line tracks the pointer exactly; the frame request is rate-limited.
925
+ const emit = hoverEmitRef.current
926
+ emit.pending = t
927
+ if (emit.frame !== null) return
928
+ emit.frame = requestFrame(() => {
929
+ emit.frame = null
930
+ const next = emit.pending
931
+ emit.pending = null
932
+ if (next !== null) pointerRef.current.onHoverScrub?.(next)
933
+ })
934
+ }
935
+
936
+ /** Drop any frame request that hasn't fired yet, so a release can't be
937
+ * overtaken by a stale position arriving one frame later. */
938
+ function cancelPendingHoverEmit() {
939
+ const emit = hoverEmitRef.current
940
+ if (emit.frame !== null) cancelFrame(emit.frame)
941
+ emit.frame = null
942
+ emit.pending = null
943
+ }
944
+
945
+ // Guarded on the ref, so the paths that call this speculatively (every
946
+ // pointer-leave, every press, mount with the axis already off) stay silent
947
+ // when there was no cursor up to take down.
948
+ function clearAxisCursor() {
949
+ cancelPendingHoverEmit()
950
+ if (cursorTimeRef.current === null) return
951
+ cursorTimeRef.current = null
952
+ // Released synchronously, never deferred: the preview must be handed back
953
+ // to the playhead the instant the pointer leaves or a gesture starts.
954
+ pointerRef.current.onHoverScrub?.(null)
955
+ requestRedraw('overlay')
956
+ }
957
+
958
+ // ── Edge auto-scroll: pan the view while a drag holds near either edge ──
959
+ //
960
+ // Standard NLE behaviour: drag an item/handle past the visible edge and the
961
+ // view pans to follow, rather than trapping the gesture at whatever was on
962
+ // screen when the drag started. Only gestures where "the pointer is
963
+ // captured and following makes sense" qualify — every `dragging` state
964
+ // EXCEPT `scrub` (the ruler already owns the playhead directly; panning
965
+ // underneath it while it drags would fight the seek instead of extending
966
+ // it). Marquee selection is included: dragging the box out past the edge to
967
+ // catch items further along the timeline is the same affordance.
968
+
969
+ function dispatchPointerMove(point: Point, modifiers: Modifiers) {
970
+ runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers, ctx: buildContext() }))
971
+ }
972
+
973
+ function stopEdgeAutoScroll() {
974
+ if (edgeScrollFrameRef.current !== null) {
975
+ cancelFrame(edgeScrollFrameRef.current)
976
+ edgeScrollFrameRef.current = null
977
+ }
978
+ edgeScrollLastTimeRef.current = null
979
+ }
980
+
981
+ function inEdgeZone(pointerX: number, surfaceWidth: number): boolean {
982
+ return pointerX < EDGE_SCROLL_ZONE_PX || pointerX > surfaceWidth - EDGE_SCROLL_ZONE_PX
983
+ }
984
+
985
+ function edgeAutoScrollTick() {
986
+ edgeScrollFrameRef.current = null
987
+
988
+ const state = machine.state
989
+ if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
990
+ const drag = lastDragPointRef.current
991
+ const rect = gestureRectRef.current
992
+ if (!drag || !rect || rect.width <= 0) { stopEdgeAutoScroll(); return }
993
+ if (!inEdgeZone(drag.point.x, rect.width)) { stopEdgeAutoScroll(); return }
994
+
995
+ const now = perfNow()
996
+ const last = edgeScrollLastTimeRef.current
997
+ edgeScrollLastTimeRef.current = now
998
+ // The first tick has no prior timestamp to diff against; skip panning
999
+ // this frame (a 0-length delta would pan nothing anyway) and let the next
1000
+ // one carry a real elapsed time, so the very first frame after entering
1001
+ // the zone doesn't jump by a guessed duration.
1002
+ if (last !== null) {
1003
+ const dt = Math.min(0.1, (now - last) / 1000)
1004
+ const viewport = store.get()
1005
+ const delta = edgeScrollDelta(
1006
+ drag.point.x, rect.width, viewport.pxPerSecond, dt,
1007
+ EDGE_SCROLL_ZONE_PX, EDGE_SCROLL_MAX_PX_PER_SEC, EDGE_SCROLL_RAMP_PX,
1008
+ )
1009
+ if (delta !== 0) {
1010
+ let hitClamp = false
1011
+ store.set(vp => {
1012
+ const nextScroll = clampScrollSeconds(vp.scrollSeconds + delta, vp, sceneRef.current.totalDuration)
1013
+ if (nextScroll === vp.scrollSeconds) { hitClamp = true; return vp }
1014
+ return { ...vp, scrollSeconds: nextScroll }
1015
+ })
1016
+ if (hitClamp) { stopEdgeAutoScroll(); return }
1017
+ // Re-feed the SAME screen point now that scrollSeconds has moved
1018
+ // under it — the store's own `subscribe` (wired above) already
1019
+ // requests the repaint this pan needs.
1020
+ dispatchPointerMove(drag.point, drag.modifiers)
1021
+ }
1022
+ }
1023
+
1024
+ edgeScrollFrameRef.current = requestFrame(edgeAutoScrollTick)
1025
+ }
1026
+
1027
+ /** Called on every real pointermove during a gesture. Starts the loop the
1028
+ * first time the pointer enters an edge zone; leaves it running otherwise
1029
+ * (the loop re-reads `lastDragPointRef` itself each tick, so a pointer that
1030
+ * keeps moving within the zone doesn't need to restart anything, and one
1031
+ * that leaves the zone is caught on the loop's own next tick). */
1032
+ function updateEdgeAutoScroll() {
1033
+ const state = machine.state
1034
+ if (state.kind !== 'dragging' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
1035
+ const drag = lastDragPointRef.current
1036
+ const rect = gestureRectRef.current
1037
+ if (!drag || !rect || rect.width <= 0 || !inEdgeZone(drag.point.x, rect.width)) return
1038
+ if (edgeScrollFrameRef.current !== null) return // already running
1039
+ edgeScrollLastTimeRef.current = null
1040
+ edgeScrollFrameRef.current = requestFrame(edgeAutoScrollTick)
1041
+ }
1042
+
1043
+ handlersRef.current = {
1044
+ down(e) {
1045
+ if (e.button !== 0) return
1046
+ // A stale gesture whose mouseup never fired (focus loss, etc.) must not
1047
+ // leave its frozen rect and listeners behind for this new one.
1048
+ releaseGestureRef.current?.()
1049
+ gestureRectRef.current = containerRef.current?.getBoundingClientRect() ?? null
1050
+ const point = surfacePoint(e)
1051
+ if (!point) return
1052
+ // A gesture is starting — no axis cursor during a drag/trim. The gesture
1053
+ // itself owns the playhead from here, and a click seeks, so leaving the
1054
+ // cursor up would draw a second line the drag never moves.
1055
+ clearAxisCursor()
1056
+ // Suppress the native text-selection drag; the surface is `select-none`
1057
+ // but a press-and-drag still starts one in some browsers. That
1058
+ // preventDefault also suppresses the focus a plain click would give this
1059
+ // container, which Timeline's own Delete/Enter bindings depend on
1060
+ // (they only fire with focus inside Timeline's root — see Timeline.tsx)
1061
+ // — so focus it explicitly.
1062
+ containerRef.current?.focus({ preventScroll: true })
1063
+ e.preventDefault()
1064
+ runEffects(machine.dispatch({ type: 'pointerDown', point, modifiers: modifiersOf(e), ctx: buildContext() }))
1065
+
1066
+ const onMove = (ev: MouseEvent) => handlersRef.current.move(ev)
1067
+ const onUp = (ev: MouseEvent) => handlersRef.current.up(ev)
1068
+ document.addEventListener('mousemove', onMove)
1069
+ document.addEventListener('mouseup', onUp)
1070
+ releaseGestureRef.current = () => {
1071
+ document.removeEventListener('mousemove', onMove)
1072
+ document.removeEventListener('mouseup', onUp)
1073
+ gestureRectRef.current = null
1074
+ releaseGestureRef.current = null
1075
+ // Every path that ends a gesture — release, a stale gesture's next
1076
+ // press, or unmount — runs through here, so this is the one place
1077
+ // edge auto-scroll needs to be torn down.
1078
+ stopEdgeAutoScroll()
1079
+ lastDragPointRef.current = null
1080
+ }
1081
+ },
1082
+ // Hover only updates the cursor, and only while no gesture is running — the
1083
+ // document listener owns movement once a press is down, so without this
1084
+ // guard every mid-drag move would be dispatched twice.
1085
+ hover(e) {
1086
+ if (machine.state.kind !== 'idle') return
1087
+ const point = surfacePoint(e)
1088
+ if (!point) return
1089
+ runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers: modifiersOf(e), ctx: buildContext() }))
1090
+ updateHoveredHandle(point)
1091
+ updateAxisCursor(point)
1092
+ },
1093
+ move(e) {
1094
+ const point = surfacePoint(e)
1095
+ if (!point) return
1096
+ const modifiers = modifiersOf(e)
1097
+ // Latched for edge auto-scroll: its rAF loop re-dispatches THIS point
1098
+ // once scrollSeconds pans under it, rather than reading a fresh one —
1099
+ // see `lastDragPointRef`'s doc.
1100
+ lastDragPointRef.current = { point, modifiers }
1101
+ dispatchPointerMove(point, modifiers)
1102
+ updateEdgeAutoScroll()
1103
+ },
1104
+ up(e) {
1105
+ // Point first, then release — the point must still see this gesture's
1106
+ // frozen rect, not the live one the teardown below reverts to.
1107
+ const point = surfacePoint(e)
1108
+ releaseGestureRef.current?.()
1109
+ if (!point) { runEffects(machine.dispatch({ type: 'cancel' })); return }
1110
+ runEffects(machine.dispatch({ type: 'pointerUp', point, modifiers: modifiersOf(e), ctx: buildContext() }))
1111
+ },
1112
+ doubleClick(e) {
1113
+ const point = surfacePoint(e)
1114
+ if (!point) return
1115
+ e.preventDefault()
1116
+ runEffects(machine.dispatch({ type: 'doubleClick', point, modifiers: modifiersOf(e), ctx: buildContext() }))
1117
+ },
1118
+ // The pointer left the surface entirely; no more `mousemove` events will
1119
+ // arrive to naturally age the cursor out, so drop it explicitly.
1120
+ leave() {
1121
+ clearAxisCursor()
1122
+ // A gesture owns the highlight until it ends: during a trim the pointer
1123
+ // routinely leaves the surface, and dropping the lit handle then would
1124
+ // un-light the very edge being dragged.
1125
+ if (machine.state.kind === 'idle') clearHoveredHandle()
1126
+ },
1127
+ // Right-click a fade grip → the fade-shape picker (Vegas' own gesture), OR
1128
+ // a keyframe diamond → its own easing/remove picker (SP9b T3.3, the same
1129
+ // shape of popup — see `onKeyframeMenu`'s doc). Anywhere else on the
1130
+ // surface, this is a no-op and the browser's normal context menu shows —
1131
+ // only one of those two hits calls `preventDefault`. Runs its OWN
1132
+ // hit-test rather than going through the pointer machine: a right-click
1133
+ // is not a gesture (no drag, no press/release pair), and the machine's
1134
+ // vocabulary has nothing for it.
1135
+ contextMenu(e) {
1136
+ const p = pointerRef.current
1137
+ if (!p.onFadeCurveMenu && !p.onKeyframeMenu) return
1138
+ const point = surfacePoint(e)
1139
+ if (!point) return
1140
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
1141
+ if (p.onFadeCurveMenu && hit.kind === 'audio-fade' && hit.itemId !== undefined && hit.side) {
1142
+ e.preventDefault()
1143
+ p.onFadeCurveMenu({ trackId: hit.itemId, side: hit.side, x: e.clientX, y: e.clientY })
1144
+ return
1145
+ }
1146
+ if (p.onKeyframeMenu && hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined && hit.item) {
1147
+ e.preventDefault()
1148
+ const times = keyframeUnionTimes(hit.item)
1149
+ const isLast = times.length > 0 && hit.kfT === times[times.length - 1]
1150
+ const props = (hit.item.keyframes ?? [])
1151
+ .filter(track => track.points.some(pt => pt.t === hit.kfT))
1152
+ .map(track => track.prop)
1153
+ p.onKeyframeMenu({ itemId: hit.itemId, t: hit.kfT, props, isLast, x: e.clientX, y: e.clientY })
1154
+ }
1155
+ },
1156
+ }
1157
+
1158
+ useEffect(() => {
1159
+ const el = containerRef.current
1160
+ if (!el) return
1161
+ const onDown = (e: MouseEvent) => handlersRef.current.down(e)
1162
+ const onHover = (e: MouseEvent) => handlersRef.current.hover(e)
1163
+ const onDoubleClick = (e: MouseEvent) => handlersRef.current.doubleClick(e)
1164
+ const onLeave = (e: MouseEvent) => handlersRef.current.leave(e)
1165
+ const onContextMenu = (e: MouseEvent) => handlersRef.current.contextMenu(e)
1166
+ el.addEventListener('mousedown', onDown)
1167
+ el.addEventListener('mousemove', onHover)
1168
+ el.addEventListener('dblclick', onDoubleClick)
1169
+ el.addEventListener('contextmenu', onContextMenu)
1170
+ el.addEventListener('mouseleave', onLeave)
1171
+ return () => {
1172
+ el.removeEventListener('mousedown', onDown)
1173
+ el.removeEventListener('mousemove', onHover)
1174
+ el.removeEventListener('dblclick', onDoubleClick)
1175
+ el.removeEventListener('contextmenu', onContextMenu)
1176
+ el.removeEventListener('mouseleave', onLeave)
1177
+ releaseGestureRef.current?.()
1178
+ }
1179
+ }, [])
1180
+
1181
+ // ── Drops onto the surface: the footage bin, and OS files ──
1182
+ //
1183
+ // Two drags land here and neither is a pointer gesture — the browser owns
1184
+ // the drag, there is no mousedown/up pair on this surface — so both stay out
1185
+ // of the pointer machine.
1186
+ //
1187
+ // A FOOTAGE-BIN drag (our own `FOOTAGE_DND_MIME`) carries everything needed
1188
+ // to place a clip, so it commits here, straight through the SAME
1189
+ // discrete-edit pair every other discrete timeline edit uses:
1190
+ // `onProjectChange` applies it live, `onOverlayEdit` persists it as one undo
1191
+ // step (mirroring the machine's own `projectChange`+`commit` effects in
1192
+ // `runEffects`, and Timeline's ripple-delete keymap). WHERE it lands is not
1193
+ // decided here: both drop paths hand the drop time and the row released over
1194
+ // to `placeDroppedClip` (placement.ts), which owns the one rule — "where you
1195
+ // dropped it, without stomping existing footage". (It used to pin every bin
1196
+ // drop to the main video track regardless of the row under the cursor; a
1197
+ // drop onto an occupied span then silently overlapped whatever was there.)
1198
+ //
1199
+ // An OS-FILE drag cannot be placed here at all: a `File` has no duration, no
1200
+ // proxy and no host-resolvable path until the host has probed and ingested
1201
+ // it. So that path only REPORTS the drop — the files, the time, the row —
1202
+ // through `onImportFilesToTimeline`, and the host commits the clip when its
1203
+ // import lands. The hook's PRESENCE is also the feature detection: without
1204
+ // it we never `preventDefault` an OS-file drag, so the browser keeps its own
1205
+ // handling and the gesture is completely inert (`dataTransfer.getData()`
1206
+ // returns "" during `dragover` for security, which is why the accept test
1207
+ // reads `types` rather than the data itself — both paths depend on that).
1208
+ //
1209
+ // The insertion indicator is the overlay's own cursor line
1210
+ // (`drawTimelineOverlay`'s `cursorTime`) reused via `cursorTimeRef` — no new
1211
+ // draw plumbing — and is taken down on `dragleave`/`drop`. It is written
1212
+ // straight to the ref (never through `clearAxisCursor`) so a drag never fires
1213
+ // the host's `onHoverScrub` preview seek.
1214
+ const dragHandlersRef = useRef({
1215
+ over: (_e: DragEvent) => {},
1216
+ leave: (_e: DragEvent) => {},
1217
+ drop: (_e: DragEvent) => {},
1218
+ })
1219
+
1220
+ /** Where in the timeline a drop at client-x `clientX` landed, clamped to the
1221
+ * project's own span. Shared by both drop paths so a bin clip and a
1222
+ * filesystem file dropped at the same pixel land at the same second. */
1223
+ function dropTimeAt(clientX: number, rect: DOMRect): number {
1224
+ return Math.max(0, Math.min(
1225
+ pointerRef.current.totalDuration,
1226
+ xToTime(clientX - rect.left, store.get()),
1227
+ ))
1228
+ }
1229
+
1230
+ /** The snap inputs `placeDroppedClip` takes: every boundary a gesture would
1231
+ * magnetize to, plus the playhead, and the magnet's radius expressed in
1232
+ * SECONDS.
1233
+ *
1234
+ * Pixels are the unit of feel (see snap.ts's own module comment): a magnet
1235
+ * has to cover the same distance on screen whether the timeline shows ten
1236
+ * seconds or ten minutes, so the radius is a pixel count divided by the
1237
+ * current scale on every call rather than a fixed number of seconds. A
1238
+ * non-positive `pxPerSecond` (a viewport not yet measured) would divide to
1239
+ * Infinity and magnetize the drop to the nearest boundary anywhere on the
1240
+ * timeline, so it disables snapping instead. */
1241
+ function dropSnapInputs(): { snapTimes: number[]; snapToleranceSec: number } {
1242
+ const p = pointerRef.current
1243
+ const pxPerSecond = store.get().pxPerSecond
1244
+ return {
1245
+ snapTimes: [...(p.snapBoundaries ?? []), clock.get()],
1246
+ snapToleranceSec: pxPerSecond > 0 ? DEFAULT_SNAP_CONFIG.attractPx / pxPerSecond : 0,
1247
+ }
1248
+ }
1249
+
1250
+ dragHandlersRef.current = {
1251
+ over(e) {
1252
+ const dt = e.dataTransfer
1253
+ if (!dt) return
1254
+ // EITHER our own bin MIME, or — only when the host gave us somewhere to
1255
+ // send them — an OS-file drag. `getData()` is unreadable during
1256
+ // `dragover`, so this can only test `types`.
1257
+ const isFootage = dt.types?.includes(FOOTAGE_DND_MIME)
1258
+ const isFiles = !!pointerRef.current.onImportFilesToTimeline && dt.types?.includes('Files')
1259
+ if (!isFootage && !isFiles) return
1260
+ // Without preventDefault the browser never fires `drop`; `copy` shows the
1261
+ // right affordance (a drop adds a placement, it doesn't move the source).
1262
+ e.preventDefault()
1263
+ dt.dropEffect = 'copy'
1264
+ const el = containerRef.current
1265
+ if (!el) return
1266
+ const t = xToTime(e.clientX - el.getBoundingClientRect().left, store.get())
1267
+ if (cursorTimeRef.current === t) return
1268
+ cursorTimeRef.current = t
1269
+ requestRedraw('overlay')
1270
+ },
1271
+ leave() {
1272
+ if (cursorTimeRef.current === null) return
1273
+ cursorTimeRef.current = null
1274
+ requestRedraw('overlay')
1275
+ },
1276
+ drop(e) {
1277
+ const p = pointerRef.current
1278
+ const el = containerRef.current
1279
+ // Which drag this is — decided by what the DataTransfer actually
1280
+ // CARRIES, not by what `dragover` accepted a moment ago: a drop can
1281
+ // arrive from a drag that started outside this surface entirely.
1282
+ const raw = e.dataTransfer?.getData(FOOTAGE_DND_MIME)
1283
+ const files = e.dataTransfer?.files
1284
+
1285
+ if (raw) {
1286
+ e.preventDefault()
1287
+ // Take the indicator down whatever happens below.
1288
+ if (cursorTimeRef.current !== null) { cursorTimeRef.current = null; requestRedraw('overlay') }
1289
+
1290
+ let payload: FootageDropPayload
1291
+ try {
1292
+ payload = JSON.parse(raw) as FootageDropPayload
1293
+ } catch {
1294
+ return
1295
+ }
1296
+ if (
1297
+ !payload ||
1298
+ typeof payload.src !== 'string' ||
1299
+ typeof payload.sourceDuration !== 'number' ||
1300
+ !Number.isFinite(payload.sourceDuration) ||
1301
+ payload.sourceDuration <= 0
1302
+ ) return
1303
+
1304
+ if (!p.onProjectChange && !p.onOverlayEdit) return
1305
+ if (!el) return
1306
+
1307
+ const rect = el.getBoundingClientRect()
1308
+ const placed = placeDroppedClip(p.project, {
1309
+ atTime: dropTimeAt(e.clientX, rect),
1310
+ preferredTrackIndex: dropTrackIndexAt(e.clientY - rect.top, p.layout),
1311
+ clip: payload,
1312
+ ripple: p.rippleMode,
1313
+ ...dropSnapInputs(),
1314
+ })
1315
+ // `placeDroppedClip` returns the input project BY REFERENCE when it
1316
+ // placed nothing (an unplaceable duration). Committing that would push
1317
+ // an undo entry and a save for an edit that never happened.
1318
+ if (placed.project === p.project) return
1319
+ p.onProjectChange?.(placed.project)
1320
+ p.onOverlayEdit?.(placed.project)
1321
+ return
1322
+ }
1323
+
1324
+ if (files && files.length > 0 && p.onImportFilesToTimeline) {
1325
+ e.preventDefault()
1326
+ if (cursorTimeRef.current !== null) { cursorTimeRef.current = null; requestRedraw('overlay') }
1327
+ if (!el) return
1328
+ const rect = el.getBoundingClientRect()
1329
+ // Snapped HERE, unlike the bin path (which snaps inside its own
1330
+ // synchronous `placeDroppedClip` call): a file drop has no placement
1331
+ // call to snap inside of, since the host places the clip later, once
1332
+ // its background import resolves, at exactly the `atTime` reported
1333
+ // now. Resolving the magnet at drop time — same rule, same inputs
1334
+ // (`dropSnapInputs`) the bin path uses — is therefore the only chance
1335
+ // to apply it at all, and it makes the ghost band land on the same
1336
+ // second the real clip eventually will.
1337
+ p.onImportFilesToTimeline(Array.from(files), {
1338
+ atTime: resolveDropPoint({ atTime: dropTimeAt(e.clientX, rect), ...dropSnapInputs() }),
1339
+ preferredTrackIndex: dropTrackIndexAt(e.clientY - rect.top, p.layout),
1340
+ // Captured HERE, at drop time, not read by the host later: the
1341
+ // magnet is editor state the host cannot see, and by the time its
1342
+ // import resolves the operator may have toggled it. The mode during
1343
+ // the gesture is the one the drop meant. (The bin path above needs
1344
+ // no such capture — it places synchronously, so reading
1345
+ // `p.rippleMode` directly is already the drop-time value.)
1346
+ ripple: p.rippleMode ?? false,
1347
+ })
1348
+ return
1349
+ }
1350
+
1351
+ // Neither — leave it for the browser (no preventDefault).
1352
+ },
1353
+ }
1354
+
1355
+ useEffect(() => {
1356
+ const el = containerRef.current
1357
+ if (!el) return
1358
+ const onOver = (e: DragEvent) => dragHandlersRef.current.over(e)
1359
+ const onLeave = (e: DragEvent) => dragHandlersRef.current.leave(e)
1360
+ const onDrop = (e: DragEvent) => dragHandlersRef.current.drop(e)
1361
+ el.addEventListener('dragover', onOver)
1362
+ el.addEventListener('dragleave', onLeave)
1363
+ el.addEventListener('drop', onDrop)
1364
+ return () => {
1365
+ el.removeEventListener('dragover', onOver)
1366
+ el.removeEventListener('dragleave', onLeave)
1367
+ el.removeEventListener('drop', onDrop)
1368
+ }
1369
+ }, [])
1370
+
1371
+ return (
1372
+ <div
1373
+ ref={containerRef}
1374
+ data-timeline-canvas
1375
+ // Focusable (not tab-stoppable) so a pointer-down can focus it
1376
+ // programmatically — see the `down` handler above — satisfying
1377
+ // Timeline's root-focus guard for Delete/Enter without adding this
1378
+ // surface to the tab order.
1379
+ tabIndex={-1}
1380
+ // `outline-none` because this surface is focused by MOUSE and then
1381
+ // driven by KEYBOARD, which is exactly the sequence that turns
1382
+ // `:focus-visible` on. Pressing space to pause put a focus ring around
1383
+ // the entire timeline — pointer-down focuses the surface silently, then
1384
+ // the first keypress makes the browser decide the focus is now worth
1385
+ // showing, and it draws a box round every track at once.
1386
+ //
1387
+ // Suppressing it costs nothing here: `tabIndex={-1}` keeps this out of
1388
+ // the tab order, so there is no keyboard route to it and no keyboard
1389
+ // user who needs the ring to know where they are. Timeline's own
1390
+ // `tabIndex={0}` root — the one a keyboard user CAN reach — keeps its
1391
+ // own affordance decision separately.
1392
+ className="relative w-full select-none outline-none"
1393
+ style={{ height: surfaceHeight, cursor: 'pointer' }}
1394
+ // Timeline's container click also seeks, via `xToTime` against this
1395
+ // same canvas viewport. The pointer machine has already seeked (on
1396
+ // mousedown) by the time this fires, so swallow it rather than let it
1397
+ // re-seek to the wrong second.
1398
+ onClick={e => e.stopPropagation()}
1399
+ >
1400
+ <canvas ref={contentCanvasRef} className="absolute inset-0 w-full h-full" />
1401
+ <canvas ref={overlayCanvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />
1402
+ </div>
1403
+ )
1404
+ }
1405
+
1406
+ // ── Zoom chrome adapter ──────────────────────────────────────────────────
1407
+ // Timeline renders the zoom buttons; the canvas owns what they do. It hands
1408
+ // Timeline this adapter so the chrome stays presentational and the viewport
1409
+ // mutation lives with the surface that draws it. (It was once a seam between
1410
+ // two zoom models — the DOM path's integer multiplier and the canvas'
1411
+ // viewport — but the DOM timeline is gone, so there is only one implementer.)
1412
+
1413
+ export interface ZoomControls {
1414
+ badge: ReactNode
1415
+ zoomIn: () => void
1416
+ zoomOut: () => void
1417
+ fit: () => void
1418
+ showFit: boolean
1419
+ }
1420
+
1421
+ export function useCanvasZoomControls(store: ViewportStore, totalDuration: number): ZoomControls {
1422
+ return useMemo(() => ({
1423
+ badge: <CanvasZoomBadge store={store} totalDuration={totalDuration} />,
1424
+ // Buttons zoom around the middle of the view, which is where the eye is
1425
+ // when there's no cursor to pivot on (the DOM path's `zoomTo` with no
1426
+ // pivot does the same).
1427
+ zoomIn: () => store.set(vp => zoomAtPivot(vp, ZOOM_BUTTON_FACTOR, vp.widthPx / 2, totalDuration)),
1428
+ zoomOut: () => store.set(vp => zoomAtPivot(vp, 1 / ZOOM_BUTTON_FACTOR, vp.widthPx / 2, totalDuration)),
1429
+ fit: () => store.set(vp => fitViewport(vp, totalDuration)),
1430
+ // Always offered: unlike the DOM model, canvas zoom can sit below 1×, so
1431
+ // "am I off fit?" is not simply "is zoom > 1". Keeping it unconditional
1432
+ // also keeps the badge the only part of the chrome that re-renders on zoom.
1433
+ showFit: true,
1434
+ }), [store, totalDuration])
1435
+ }
1436
+
1437
+ /** The zoom readout, isolated so wheel-zoom re-renders this span and nothing
1438
+ * else. Reports a fit-relative multiple for continuity with the DOM badge,
1439
+ * where 1× also meant "the whole project fits". */
1440
+ export function CanvasZoomBadge({ store, totalDuration }: { store: ViewportStore; totalDuration: number }) {
1441
+ const viewport = useViewportValue(store)
1442
+ return (
1443
+ <span className="text-[10px] font-mono text-gray-500 w-7 text-center tabular-nums select-none">
1444
+ {formatZoomMultiple(zoomMultiple(viewport, totalDuration))}×
1445
+ </span>
1446
+ )
1447
+ }