@bycrux/editor 0.12.0 → 1.0.2

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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +161 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,346 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Edge auto-scroll — while an item/trim/audio/caption drag holds the pointer
4
+ * near the surface's left or right edge, the view pans to follow it (standard
5
+ * NLE behaviour), rather than trapping the gesture at whatever was on screen
6
+ * when the drag started.
7
+ *
8
+ * The ramp/clamp MATH is `edgeScrollDelta` in viewport.ts, covered exhaustively
9
+ * as pure data in viewport.test.ts. What can only be shown with a mounted
10
+ * component is here: that a real drag actually starts the loop, that panning
11
+ * re-feeds the pointer machine so the dragged item keeps tracking, that it
12
+ * clamps and stops at the legal scroll range, and that it stands down when the
13
+ * pointer leaves the zone, the drag ends, or the gesture is a ruler scrub.
14
+ *
15
+ * jsdom's `performance.now()` is NOT tied to Vitest's fake timers (verified:
16
+ * advancing the fake clock by 16ms moves it by a fraction of a millisecond of
17
+ * REAL wall time), so the rAF loop's own frame-delta math would see near-zero
18
+ * `dt` if left alone — dozens of ticks to see any pan at all, and a flaky
19
+ * amount at that. `performance.now` is stubbed directly in these tests so each
20
+ * flushed tick reports a controlled elapsed time instead.
21
+ */
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
23
+ import { render, act, cleanup } from '@testing-library/react'
24
+ import { createPlaybackClock } from '../../../playback-clock'
25
+ import type { Project } from '../../../../types'
26
+ import TimelineCanvas from '../TimelineCanvas'
27
+ import { createViewportStore, maxScrollSeconds, type Viewport } from '../viewport'
28
+ import { computeTimelineLayout } from '../draw'
29
+
30
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
31
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
32
+
33
+ function stubContext() {
34
+ return new Proxy({}, {
35
+ get(_t, prop: string) {
36
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
37
+ return () => {}
38
+ },
39
+ set() { return true },
40
+ })
41
+ }
42
+
43
+ beforeEach(() => {
44
+ realGetContext = HTMLCanvasElement.prototype.getContext
45
+ realGetRect = Element.prototype.getBoundingClientRect
46
+ HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
47
+ // Fixed 1000×100 surface at the page origin, matching the pointer suite's
48
+ // harness — the edge-zone math below (x<40 / x>960) is pinned against it.
49
+ Element.prototype.getBoundingClientRect = function (this: Element) {
50
+ return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
51
+ }
52
+ vi.useFakeTimers()
53
+ })
54
+
55
+ afterEach(() => {
56
+ cleanup()
57
+ vi.useRealTimers()
58
+ HTMLCanvasElement.prototype.getContext = realGetContext
59
+ Element.prototype.getBoundingClientRect = realGetRect
60
+ })
61
+
62
+ // A wide project (well past what 1000px×100px/s shows at once) so there is
63
+ // real room to auto-scroll into.
64
+ const project = {
65
+ id: 'p',
66
+ tracks: [{
67
+ id: 'trk-0',
68
+ items: [
69
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
70
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 20, inPoint: 0, outPoint: 16, sourceDuration: 20 },
71
+ ],
72
+ }],
73
+ } as unknown as Project
74
+
75
+ const ROW_Y = (() => {
76
+ const row = computeTimelineLayout(project).rows[0]
77
+ return Math.round(row.y + row.height / 2)
78
+ })()
79
+
80
+ const RULER_Y = (() => {
81
+ const ruler = computeTimelineLayout(project).ruler
82
+ return Math.round(ruler.y + ruler.height / 2)
83
+ })()
84
+
85
+ const TOTAL_DURATION = 60
86
+ const FPS = 30
87
+
88
+ // Pinned scale: pxPerSecond=100, widthPx=1000 → 10s visible. Rightmost legal
89
+ // scrollSeconds is then maxScrollSeconds(vp, 60) = 60 - 10 + 10×0.25 = 52.5.
90
+ const PINNED_VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
91
+ const RIGHTMOST_SCROLL = maxScrollSeconds(PINNED_VIEWPORT, TOTAL_DURATION)
92
+
93
+ function mount() {
94
+ const store = createViewportStore()
95
+ const clock = createPlaybackClock()
96
+ const onProjectChange = vi.fn()
97
+ const onOverlayEdit = vi.fn()
98
+
99
+ const utils = render(
100
+ <TimelineCanvas
101
+ project={project}
102
+ clock={clock}
103
+ store={store}
104
+ totalDuration={TOTAL_DURATION}
105
+ fps={FPS}
106
+ selectedIds={[]}
107
+ onProjectChange={onProjectChange}
108
+ onOverlayEdit={onOverlayEdit}
109
+ />,
110
+ )
111
+ act(() => { vi.advanceTimersByTime(32) })
112
+ act(() => { store.set(PINNED_VIEWPORT) })
113
+ act(() => { vi.advanceTimersByTime(32) })
114
+
115
+ return {
116
+ ...utils,
117
+ store,
118
+ clock,
119
+ onProjectChange,
120
+ onOverlayEdit,
121
+ surface: utils.container.querySelector('[data-timeline-canvas]') as HTMLElement,
122
+ }
123
+ }
124
+
125
+ function mouse(type: string, x: number, y: number, init: MouseEventInit = {}) {
126
+ return new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true, button: 0, ...init })
127
+ }
128
+
129
+ /** Installs a `performance.now()` stub the test fully controls: each call
130
+ * returns `clock.value`, which the test advances explicitly between ticks —
131
+ * decoupled from Vitest's fake timers (see file header). */
132
+ function stubPerfNow() {
133
+ const state = { value: 0 }
134
+ const spy = vi.spyOn(performance, 'now').mockImplementation(() => state.value)
135
+ return {
136
+ advance(ms: number) { state.value += ms },
137
+ restore() { spy.mockRestore() },
138
+ }
139
+ }
140
+
141
+ /** c0's start time in the most recent `projectChange` the gesture emitted.
142
+ * The `project` prop here is a fixed constant (nothing echoes edits back), so
143
+ * every emitted project is recomputed from the press-time one — the latest
144
+ * call is always the gesture's current position, never an accumulation. */
145
+ function lastC0Start(calls: unknown[][]): number {
146
+ expect(calls.length).toBeGreaterThan(0)
147
+ const emitted = calls[calls.length - 1][0] as unknown as {
148
+ tracks: { items: { id: string; start: number }[] }[]
149
+ }
150
+ // Searched across every track: dragging c0 out over c1 makes the mover lift
151
+ // it to a track of its own rather than overlap, so it is not on trk-0 by the
152
+ // time the pan starts.
153
+ const c0 = emitted.tracks.flatMap(t => t.items).find(i => i.id === 'c0')
154
+ expect(c0).toBeDefined()
155
+ return c0!.start
156
+ }
157
+
158
+ describe('TimelineCanvas — edge auto-scroll', () => {
159
+ it('pans the view toward the right edge during a drag, re-feeding the gesture, and clamps at the rightmost legal scroll', () => {
160
+ const perf = stubPerfNow()
161
+ try {
162
+ const { surface, store, onProjectChange } = mount()
163
+
164
+ // Press c0's body and cross the drag threshold well inside the surface.
165
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
166
+ act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
167
+ const callsAfterRealMove = onProjectChange.mock.calls.length
168
+ expect(callsAfterRealMove).toBeGreaterThan(0)
169
+ expect(store.get().scrollSeconds).toBe(0)
170
+
171
+ // Drag to x=990 — inside the 40px right edge zone of a 1000px surface —
172
+ // and hold the pointer there without moving it further.
173
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
174
+
175
+ // First tick only seeds the clock (no prior timestamp to diff against);
176
+ // no pan yet.
177
+ act(() => { vi.advanceTimersByTime(20) })
178
+ expect(store.get().scrollSeconds).toBe(0)
179
+
180
+ // Each further tick pans right at a large, clock-controlled dt (capped
181
+ // internally at 0.1s/tick). x=990 sits 30px past the zone's boundary, so
182
+ // the eased ramp gives (30/70)^1.5 ≈ 0.28 of the 1400px/s cap — about
183
+ // 393px/s, i.e. ≈ 0.393s of scroll per tick at this scale — enough ticks
184
+ // to clear the RIGHTMOST_SCROLL clamp (52.5s) and then some, to prove it
185
+ // holds there rather than merely arriving at it.
186
+ for (let i = 0; i < 140; i++) {
187
+ perf.advance(1000)
188
+ act(() => { vi.advanceTimersByTime(20) })
189
+ }
190
+
191
+ expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
192
+
193
+ // The pointer's screen position never moved past x=990, yet the machine
194
+ // was re-fed that same point on every panning tick — proof the drag
195
+ // kept tracking as the view slid underneath it, not just on the two
196
+ // real mousemoves above.
197
+ expect(onProjectChange.mock.calls.length).toBeGreaterThan(callsAfterRealMove + 1)
198
+
199
+ // Stays clamped — no overshoot, no runaway past the legal range even
200
+ // with more frames available.
201
+ act(() => { vi.advanceTimersByTime(200) })
202
+ expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
203
+
204
+ act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
205
+ } finally {
206
+ perf.restore()
207
+ }
208
+ })
209
+
210
+ it('carries the dragged clip along with the pan, so it stays under the held cursor', () => {
211
+ // The pan is only half the job. If the gesture measured its travel in raw
212
+ // screen pixels it would report NO movement for these frames — the pointer
213
+ // is parked — and the clip would sit at a fixed time while the timeline
214
+ // slid out from under it, drifting away from the hand holding it. What is
215
+ // asserted here is the other half: the clip advances by exactly the pan.
216
+ const perf = stubPerfNow()
217
+ try {
218
+ const { surface, store, onProjectChange } = mount()
219
+
220
+ // Grab c0 (0s–4s) 2.0s in, and drag to x=990 — inside the right edge
221
+ // zone — in one move. 9.9s under the cursor, less the 2.0s grab offset.
222
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
223
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
224
+ const beforePan = lastC0Start(onProjectChange.mock.calls)
225
+ expect(beforePan).toBeCloseTo(7.9, 5)
226
+
227
+ // Ten panning ticks with the pointer held exactly where it was. (The
228
+ // first tick only seeds the frame clock — see the test above.)
229
+ act(() => { vi.advanceTimersByTime(20) })
230
+ for (let i = 0; i < 10; i++) {
231
+ perf.advance(1000)
232
+ act(() => { vi.advanceTimersByTime(20) })
233
+ }
234
+
235
+ const scrolled = store.get().scrollSeconds
236
+ expect(scrolled).toBeGreaterThan(0)
237
+ // Far from the clamp, so this is the tracking behaviour and not the
238
+ // timeline's end holding the clip in place.
239
+ expect(scrolled).toBeLessThan(RIGHTMOST_SCROLL)
240
+ expect(lastC0Start(onProjectChange.mock.calls)).toBeCloseTo(beforePan + scrolled, 5)
241
+
242
+ act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
243
+ } finally {
244
+ perf.restore()
245
+ }
246
+ })
247
+
248
+ it('stops panning as soon as the pointer moves back out of the edge zone', () => {
249
+ const perf = stubPerfNow()
250
+ try {
251
+ const { surface, store } = mount()
252
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
253
+ act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
254
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
255
+
256
+ act(() => { vi.advanceTimersByTime(20) }) // seed
257
+ perf.advance(1000)
258
+ act(() => { vi.advanceTimersByTime(20) }) // one real pan
259
+ const pannedTo = store.get().scrollSeconds
260
+ expect(pannedTo).toBeGreaterThan(0)
261
+ expect(pannedTo).toBeLessThan(RIGHTMOST_SCROLL)
262
+
263
+ // Pull back to the middle of the surface — well outside either zone.
264
+ act(() => { document.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
265
+ // The already-scheduled tick fires once more, sees the pointer is no
266
+ // longer in a zone, and stands the loop down without panning further.
267
+ perf.advance(1000)
268
+ act(() => { vi.advanceTimersByTime(20) })
269
+ expect(store.get().scrollSeconds).toBe(pannedTo)
270
+
271
+ // No further frames are pending — continuing to advance time changes
272
+ // nothing.
273
+ perf.advance(5000)
274
+ act(() => { vi.advanceTimersByTime(200) })
275
+ expect(store.get().scrollSeconds).toBe(pannedTo)
276
+
277
+ act(() => { document.dispatchEvent(mouse('mouseup', 500, ROW_Y)) })
278
+ } finally {
279
+ perf.restore()
280
+ }
281
+ })
282
+
283
+ it('stops panning the instant the drag ends', () => {
284
+ const perf = stubPerfNow()
285
+ try {
286
+ const { surface, store } = mount()
287
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
288
+ act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
289
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
290
+
291
+ act(() => { vi.advanceTimersByTime(20) }) // seed
292
+ perf.advance(1000)
293
+ act(() => { vi.advanceTimersByTime(20) }) // one real pan
294
+ const pannedTo = store.get().scrollSeconds
295
+ expect(pannedTo).toBeGreaterThan(0)
296
+
297
+ act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
298
+
299
+ // The release tears the loop down synchronously (via the same teardown
300
+ // that removes the document listeners) — no leaked frame keeps panning.
301
+ perf.advance(5000)
302
+ act(() => { vi.advanceTimersByTime(200) })
303
+ expect(store.get().scrollSeconds).toBe(pannedTo)
304
+ } finally {
305
+ perf.restore()
306
+ }
307
+ })
308
+
309
+ it('does not auto-scroll for a ruler scrub, even with the pointer held at the edge', () => {
310
+ const perf = stubPerfNow()
311
+ try {
312
+ const { surface, store, clock } = mount()
313
+ // A press on the ruler scrubs immediately (dragging/scrub from the
314
+ // first pointerdown) — held right at the edge zone from the start.
315
+ act(() => { surface.dispatchEvent(mouse('mousedown', 990, RULER_Y)) })
316
+ expect(clock.get()).toBeCloseTo(9.9)
317
+ act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
318
+
319
+ perf.advance(1000)
320
+ act(() => { vi.advanceTimersByTime(200) })
321
+ perf.advance(5000)
322
+ act(() => { vi.advanceTimersByTime(200) })
323
+
324
+ // The playhead moved (that's what a scrub does); the VIEWPORT did not.
325
+ expect(store.get().scrollSeconds).toBe(0)
326
+
327
+ act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
328
+ } finally {
329
+ perf.restore()
330
+ }
331
+ })
332
+
333
+ it('never auto-scrolls with no active gesture', () => {
334
+ const perf = stubPerfNow()
335
+ try {
336
+ const { surface, store } = mount()
337
+ // Plain hover at the edge, no press.
338
+ act(() => { surface.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
339
+ perf.advance(1000)
340
+ act(() => { vi.advanceTimersByTime(200) })
341
+ expect(store.get().scrollSeconds).toBe(0)
342
+ } finally {
343
+ perf.restore()
344
+ }
345
+ })
346
+ })
@@ -0,0 +1,122 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Pane-fill — the surface grows past its tracks to occupy the empty area at the
4
+ * bottom of the resizable timeline pane, so a click/drag there hits the canvas
5
+ * (deselect, seek, marquee) instead of dead page space.
6
+ *
7
+ * The arithmetic is proven as a pure unit (`paneFillHeight`); the wiring —
8
+ * measuring the marked scroll viewport, growing the surface, and hit-testing
9
+ * the extended region as `background` so a click deselects — needs a mounted
10
+ * surface. jsdom lays everything out at 0×0, so `getBoundingClientRect` is
11
+ * stubbed per-element: the scroll viewport is 600px tall, the surface starts
12
+ * 100px down, leaving 500px of fill below the ~186px of tracks.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
15
+ import { render, act, cleanup } from '@testing-library/react'
16
+ import { createPlaybackClock } from '../../../playback-clock'
17
+ import type { Project } from '../../../../types'
18
+ import TimelineCanvas, { paneFillHeight } from '../TimelineCanvas'
19
+ import { createViewportStore } from '../viewport'
20
+
21
+ describe('paneFillHeight', () => {
22
+ it('is the viewport height less the surface offset and the padding below it', () => {
23
+ expect(paneFillHeight(600, 100, 12)).toBe(488)
24
+ expect(paneFillHeight(600, 100, 0)).toBe(500)
25
+ })
26
+
27
+ it('never goes negative when the surface offset already exceeds the viewport', () => {
28
+ // The tracks alone already overflow the pane: the fill floors at 0 and the
29
+ // caller's Math.max keeps the layout height instead.
30
+ expect(paneFillHeight(200, 260, 0)).toBe(0)
31
+ })
32
+ })
33
+
34
+ // One base video track (120px row) + one audio lane (40px): layout height ~186,
35
+ // comfortably under the 500px of pane fill the stubs produce.
36
+ const project = {
37
+ id: 'p',
38
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8, inPoint: 0, outPoint: 8, sourceDuration: 8 }] }],
39
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5 }] },
40
+ } as unknown as Project
41
+
42
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
43
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
44
+
45
+ function stubContext() {
46
+ return new Proxy({}, {
47
+ get(_t, prop: string) {
48
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
49
+ return () => {}
50
+ },
51
+ set() { return true },
52
+ })
53
+ }
54
+
55
+ function rect(top: number, bottom: number): DOMRect {
56
+ return { x: 0, y: top, top, left: 0, right: 1000, bottom, width: 1000, height: bottom - top, toJSON: () => ({}) } as DOMRect
57
+ }
58
+
59
+ beforeEach(() => {
60
+ realGetContext = HTMLCanvasElement.prototype.getContext
61
+ realGetRect = Element.prototype.getBoundingClientRect
62
+ HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
63
+ // The scroll viewport is 600px tall; the surface begins 100px down it, so the
64
+ // fill below the tracks is 600 − 100 = 500px. Everything else keeps the 0×0
65
+ // that would otherwise apply.
66
+ Element.prototype.getBoundingClientRect = function (this: Element) {
67
+ if (this instanceof HTMLElement && this.hasAttribute('data-timeline-scroll')) return rect(0, 600)
68
+ if (this instanceof HTMLElement && this.hasAttribute('data-timeline-canvas')) return rect(100, 600)
69
+ return rect(0, 120)
70
+ }
71
+ vi.useFakeTimers()
72
+ })
73
+
74
+ afterEach(() => {
75
+ cleanup()
76
+ vi.useRealTimers()
77
+ HTMLCanvasElement.prototype.getContext = realGetContext
78
+ Element.prototype.getBoundingClientRect = realGetRect
79
+ })
80
+
81
+ function mount(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
82
+ const store = createViewportStore()
83
+ const clock = createPlaybackClock()
84
+ const onSelectItem = vi.fn()
85
+ const utils = render(
86
+ <div data-timeline-scroll>
87
+ <TimelineCanvas
88
+ project={project}
89
+ clock={clock}
90
+ store={store}
91
+ totalDuration={20}
92
+ fps={30}
93
+ selectedIds={[]}
94
+ onSelectItem={onSelectItem}
95
+ {...overrides}
96
+ />
97
+ </div>,
98
+ )
99
+ act(() => { vi.advanceTimersByTime(32) })
100
+ const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
101
+ return { ...utils, store, clock, onSelectItem, surface }
102
+ }
103
+
104
+ describe('TimelineCanvas — pane fill', () => {
105
+ it('grows the surface to fill the pane below the tracks', () => {
106
+ const { surface } = mount()
107
+ // 500px of fill dominates the ~186px of tracks.
108
+ expect(surface.style.height).toBe('500px')
109
+ })
110
+
111
+ it('deselects on a click in the extended area below the tracks', () => {
112
+ // y=400 is well past the last lane (~186) but inside the 500px surface, so
113
+ // it hit-tests as background — a press+release there clears the selection,
114
+ // exactly as clicking bare timeline does.
115
+ const { surface, onSelectItem } = mount({ selectedIds: ['c0'] })
116
+ act(() => {
117
+ surface.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 400, clientY: 400, bubbles: true }))
118
+ surface.dispatchEvent(new MouseEvent('mouseup', { button: 0, clientX: 400, clientY: 400, bubbles: true }))
119
+ })
120
+ expect(onSelectItem).toHaveBeenCalledWith(null, false)
121
+ })
122
+ })