@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,257 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — the one snapping model. At 100px/second the default radii convert to
4
+ * round numbers, which is what makes the hysteresis assertions readable:
5
+ *
6
+ * attract 20px → 0.20s release 44px → 0.44s
7
+ *
8
+ * The gap between them is the feature, not an implementation detail: a magnet
9
+ * you can leave as easily as you entered it reads as the timeline refusing to
10
+ * go where you put it. Several assertions below exist purely to pin that gap,
11
+ * so widening or narrowing it is always a deliberate edit.
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ DEFAULT_SNAP_CONFIG,
16
+ applySnap,
17
+ createSnapState,
18
+ snapPointsExcluding,
19
+ snapPointsForSpan,
20
+ type SnapPoint,
21
+ } from '../snap'
22
+ import type { Viewport } from '../viewport'
23
+
24
+ const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
25
+
26
+ /** Same-track boundaries — the full-strength magnet. */
27
+ const strong = (...times: number[]): SnapPoint[] => times.map(time => ({ time, strength: 'strong' }))
28
+ /** Boundaries on some other row: 6px in, 10px out. */
29
+ const weak = (...times: number[]): SnapPoint[] => times.map(time => ({ time, strength: 'weak' }))
30
+
31
+ const POINTS = strong(5)
32
+
33
+ describe('applySnap — attraction', () => {
34
+ it('captures a candidate inside the attract radius', () => {
35
+ const result = applySnap(5.1, POINTS, VIEWPORT, createSnapState())
36
+ expect(result.time).toBe(5)
37
+ expect(result.snappedTo).toBe(5)
38
+ })
39
+
40
+ it('leaves a candidate outside the attract radius alone', () => {
41
+ const result = applySnap(5.25, POINTS, VIEWPORT, createSnapState())
42
+ expect(result.time).toBe(5.25)
43
+ expect(result.snappedTo).toBeNull()
44
+ expect(result.state.snappedTo).toBeNull()
45
+ })
46
+
47
+ it('brackets the attract radius', () => {
48
+ expect(applySnap(5.201, POINTS, VIEWPORT, createSnapState()).snappedTo).toBeNull()
49
+ expect(applySnap(5.199, POINTS, VIEWPORT, createSnapState()).snappedTo).toBe(5)
50
+ })
51
+
52
+ it('attracts from either side', () => {
53
+ expect(applySnap(4.9, POINTS, VIEWPORT, createSnapState()).time).toBe(5)
54
+ })
55
+ })
56
+
57
+ describe('applySnap — hysteresis', () => {
58
+ it('holds a captured point until the release radius is cleared', () => {
59
+ let state = createSnapState()
60
+
61
+ // Approach and stick.
62
+ let result = applySnap(5.15, POINTS, VIEWPORT, state)
63
+ expect(result.time).toBe(5)
64
+ state = result.state
65
+
66
+ // Pull past the ATTRACT radius — still held, because release is wider.
67
+ result = applySnap(5.25, POINTS, VIEWPORT, state)
68
+ expect(result.time).toBe(5)
69
+ expect(result.snappedTo).toBe(5)
70
+ state = result.state
71
+
72
+ // Just short of release — still held.
73
+ result = applySnap(5.439, POINTS, VIEWPORT, state)
74
+ expect(result.time).toBe(5)
75
+ state = result.state
76
+
77
+ // Past release — free, and reporting the true candidate.
78
+ result = applySnap(5.44, POINTS, VIEWPORT, state)
79
+ expect(result.time).toBe(5.44)
80
+ expect(result.snappedTo).toBeNull()
81
+ state = result.state
82
+
83
+ // Coming back inside attract re-captures.
84
+ expect(applySnap(5.1, POINTS, VIEWPORT, state).time).toBe(5)
85
+ })
86
+
87
+ it('cannot immediately re-capture the point it just released', () => {
88
+ const held = applySnap(5.05, POINTS, VIEWPORT, createSnapState()).state
89
+ const released = applySnap(5.5, POINTS, VIEWPORT, held)
90
+ expect(released.time).toBe(5.5)
91
+ expect(released.snappedTo).toBeNull()
92
+ })
93
+
94
+ it('hands off to a different point when the escape lands near one', () => {
95
+ const points = strong(5, 5.6)
96
+ const held = applySnap(5.02, points, VIEWPORT, createSnapState()).state
97
+ const moved = applySnap(5.5, points, VIEWPORT, held)
98
+ // 0.50 from the held point clears release (0.44); 0.10 from the neighbour
99
+ // is well inside attract.
100
+ expect(moved.time).toBe(5.6)
101
+ expect(moved.snappedTo).toBe(5.6)
102
+ })
103
+ })
104
+
105
+ describe('applySnap — strength tiers', () => {
106
+ it('reports which tier caught the gesture', () => {
107
+ expect(applySnap(5.1, strong(5), VIEWPORT, createSnapState()).strength).toBe('strong')
108
+ expect(applySnap(5.03, weak(5), VIEWPORT, createSnapState()).strength).toBe('weak')
109
+ expect(applySnap(9, strong(5), VIEWPORT, createSnapState()).strength).toBeNull()
110
+ })
111
+
112
+ it('ignores a cross-track boundary that a same-track one would have caught', () => {
113
+ // 0.1s is 10px: inside the 20px strong radius, well outside the 6px weak
114
+ // one. This is the whole point of the split — an overlay row full of cuts
115
+ // must not grab a clip being dragged on the row below.
116
+ expect(applySnap(5.1, strong(5), VIEWPORT, createSnapState()).snappedTo).toBe(5)
117
+ expect(applySnap(5.1, weak(5), VIEWPORT, createSnapState()).snappedTo).toBeNull()
118
+ })
119
+
120
+ it('lets a strong point beat a NEARER weak one', () => {
121
+ // Nearest-wins holds within a tier, never across it: the same-track cut at
122
+ // 0.15s out takes it, not the overlay edge 0.02s away.
123
+ const points = [...weak(5.02), ...strong(5.15)]
124
+ const result = applySnap(5, points, VIEWPORT, createSnapState())
125
+ expect(result.snappedTo).toBe(5.15)
126
+ expect(result.strength).toBe('strong')
127
+ })
128
+
129
+ it('falls back to a weak point when no strong one is in range', () => {
130
+ const points = [...weak(5.02), ...strong(9)]
131
+ expect(applySnap(5, points, VIEWPORT, createSnapState()).snappedTo).toBe(5.02)
132
+ })
133
+
134
+ it('lets a weak capture go on weak terms — 10px, not 44', () => {
135
+ // A subtle magnet has to be subtle to LEAVE as well as to enter, or it is
136
+ // just a strong magnet that was hard to trigger.
137
+ const held = applySnap(5.02, weak(5), VIEWPORT, createSnapState()).state
138
+ expect(applySnap(5.09, weak(5), VIEWPORT, held).snappedTo).toBe(5)
139
+ expect(applySnap(5.11, weak(5), VIEWPORT, held).snappedTo).toBeNull()
140
+
141
+ // The same distance would not have shaken off a strong one.
142
+ const heldStrong = applySnap(5.02, strong(5), VIEWPORT, createSnapState()).state
143
+ expect(applySnap(5.11, strong(5), VIEWPORT, heldStrong).snappedTo).toBe(5)
144
+ })
145
+ })
146
+
147
+ describe('applySnap — multiple candidates', () => {
148
+ it('takes the nearest point, not the first in range', () => {
149
+ expect(applySnap(5, strong(4.9, 5.05), VIEWPORT, createSnapState()).time).toBe(5.05)
150
+ expect(applySnap(5, strong(5.05, 4.9), VIEWPORT, createSnapState()).time).toBe(5.05)
151
+ })
152
+
153
+ it('breaks an exact tie in favour of the earlier entry', () => {
154
+ expect(applySnap(5, strong(4.9, 5.1), VIEWPORT, createSnapState()).time).toBe(4.9)
155
+ expect(applySnap(5, strong(5.1, 4.9), VIEWPORT, createSnapState()).time).toBe(5.1)
156
+ })
157
+
158
+ it('ignores points outside the radius entirely', () => {
159
+ expect(applySnap(5, strong(1, 9), VIEWPORT, createSnapState()).snappedTo).toBeNull()
160
+ })
161
+ })
162
+
163
+ describe('applySnap — configuration and degenerate viewports', () => {
164
+ it('ships radii whose release is more than double its attract', () => {
165
+ expect(DEFAULT_SNAP_CONFIG).toEqual({ attractPx: 20, releasePx: 44, weakAttractPx: 6, weakReleasePx: 10 })
166
+ // The detent. Pinned as a ratio as well as absolutes so a future tweak to
167
+ // either number has to keep the asymmetry that makes the magnet hold.
168
+ expect(DEFAULT_SNAP_CONFIG.releasePx).toBeGreaterThan(DEFAULT_SNAP_CONFIG.attractPx * 2)
169
+ })
170
+
171
+ it('holds well past the distance that captured it', () => {
172
+ // The user-facing promise: sliding a clip a little way past a cut does not
173
+ // shake it off. 0.3s is 30px — half again the attract radius — and the
174
+ // gesture is still pinned to the boundary.
175
+ const held = applySnap(5.05, POINTS, VIEWPORT, createSnapState()).state
176
+ expect(applySnap(5.3, POINTS, VIEWPORT, held).snappedTo).toBe(5)
177
+ })
178
+
179
+ it('honours a config override', () => {
180
+ const tight = { attractPx: 4, releasePx: 6, weakAttractPx: 2, weakReleasePx: 3 }
181
+ // 0.03s is 3px — inside a 4px magnet, and well inside the default 20px one.
182
+ expect(applySnap(5.03, POINTS, VIEWPORT, createSnapState(), tight).time).toBe(5)
183
+ expect(applySnap(5.1, POINTS, VIEWPORT, createSnapState(), tight).snappedTo).toBeNull()
184
+ expect(applySnap(5.1, POINTS, VIEWPORT, createSnapState()).snappedTo).toBe(5)
185
+ // …and the tighter release lets go sooner.
186
+ const held = applySnap(5.03, POINTS, VIEWPORT, createSnapState(), tight).state
187
+ expect(applySnap(5.07, POINTS, VIEWPORT, held, tight).snappedTo).toBeNull()
188
+ })
189
+
190
+ it('scales the radii with zoom, so the magnet is the same size on screen', () => {
191
+ const zoomedIn: Viewport = { ...VIEWPORT, pxPerSecond: 1000 }
192
+ // 0.1s is 100px out at this scale — far outside the magnet.
193
+ expect(applySnap(5.1, POINTS, zoomedIn, createSnapState()).snappedTo).toBeNull()
194
+ // 0.01s is 10px — inside it.
195
+ expect(applySnap(5.01, POINTS, zoomedIn, createSnapState()).time).toBe(5)
196
+ })
197
+
198
+ it('does not snap before the surface has a scale', () => {
199
+ const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
200
+ const result = applySnap(5.0001, POINTS, unscaled, createSnapState())
201
+ expect(result.time).toBe(5.0001)
202
+ expect(result.snappedTo).toBeNull()
203
+ })
204
+
205
+ it('does not snap when there are no points', () => {
206
+ expect(applySnap(5, [], VIEWPORT, createSnapState()).time).toBe(5)
207
+ })
208
+
209
+ it('defaults to a free state when none is threaded', () => {
210
+ expect(applySnap(5.1, POINTS, VIEWPORT).time).toBe(5)
211
+ expect(createSnapState().snappedTo).toBeNull()
212
+ })
213
+ })
214
+
215
+ describe('snapPointsForSpan', () => {
216
+ it('offers each point to both edges of the span', () => {
217
+ expect(snapPointsForSpan(strong(2, 8), 3)).toEqual(strong(2, 8, -1, 5))
218
+ })
219
+
220
+ it('carries each point\'s strength onto its trailing-edge twin', () => {
221
+ // Otherwise a cross-track boundary would pull weakly on a clip's head and
222
+ // at full strength on its tail, which is indefensible either way round.
223
+ expect(snapPointsForSpan(weak(8), 3)).toEqual(weak(8, 5))
224
+ })
225
+
226
+ it('lets a moving item catch a boundary by its trailing edge', () => {
227
+ // A 3s item whose end should land on t=8 must start at t=5.
228
+ const points = snapPointsForSpan(strong(8), 3)
229
+ expect(applySnap(5.1, points, VIEWPORT, createSnapState()).time).toBe(5)
230
+ })
231
+ })
232
+
233
+ describe('snapPointsExcluding', () => {
234
+ it('drops the values the gesture is itself moving', () => {
235
+ expect(snapPointsExcluding(strong(0, 2, 5, 10), [2, 5])).toEqual(strong(0, 10))
236
+ })
237
+
238
+ it('de-duplicates and drops non-finite entries', () => {
239
+ expect(snapPointsExcluding(strong(3, 3, Infinity, NaN, 7), [])).toEqual(strong(3, 7))
240
+ })
241
+
242
+ it('upgrades a duplicate to strong, whichever order the rows arrived in', () => {
243
+ // A cut on this track that happens to line up with one two rows up is a
244
+ // same-track boundary. Losing that to a weak twin would silently punch a
245
+ // hole in the strong tier.
246
+ expect(snapPointsExcluding([...weak(4), ...strong(4)], [])).toEqual(strong(4))
247
+ expect(snapPointsExcluding([...strong(4), ...weak(4)], [])).toEqual(strong(4))
248
+ })
249
+
250
+ it('matches within a float epsilon, so recomputed times still cancel', () => {
251
+ expect(snapPointsExcluding(strong(0.1 + 0.2), [0.3])).toEqual([])
252
+ })
253
+
254
+ it('keeps everything when nothing is excluded', () => {
255
+ expect(snapPointsExcluding(strong(1, 2), [])).toEqual(strong(1, 2))
256
+ })
257
+ })
@@ -0,0 +1,399 @@
1
+ /**
2
+ * SP5 T4 — the canvas timeline's viewport math. Everything asserted here is
3
+ * pure: the surface plumbing (ResizeObserver, matchMedia) is exercised only
4
+ * through its size arithmetic, which is the part that can be wrong silently.
5
+ */
6
+ import { describe, it, expect, vi } from 'vitest'
7
+ import {
8
+ EDGE_SCROLL_MAX_PX_PER_SEC,
9
+ EDGE_SCROLL_RAMP_PX,
10
+ EDGE_SCROLL_ZONE_PX,
11
+ MAX_PX_PER_SECOND,
12
+ MIN_FIT_MULTIPLE,
13
+ MIN_PX_PER_SECOND,
14
+ OVERSCROLL_FRACTION,
15
+ applyWheelIntent,
16
+ backingStoreSize,
17
+ clampPxPerSecond,
18
+ clampScrollSeconds,
19
+ createViewportStore,
20
+ edgeScrollDelta,
21
+ fitPxPerSecond,
22
+ fitViewport,
23
+ formatZoomMultiple,
24
+ maxScrollSeconds,
25
+ panByPixels,
26
+ reclampForDuration,
27
+ scaleContextToDpr,
28
+ syncCanvasBackingStore,
29
+ timeToX,
30
+ visibleDuration,
31
+ visibleRange,
32
+ wheelIntent,
33
+ withSurfaceWidth,
34
+ xToTime,
35
+ zoomAtPivot,
36
+ zoomMultiple,
37
+ type Viewport,
38
+ } from '../viewport'
39
+
40
+ function vp(overrides: Partial<Viewport> = {}): Viewport {
41
+ return { pxPerSecond: 10, scrollSeconds: 0, widthPx: 1000, ...overrides }
42
+ }
43
+
44
+ describe('time ↔ pixel conversion', () => {
45
+ it('maps time to x through scroll and scale', () => {
46
+ const v = vp({ pxPerSecond: 20, scrollSeconds: 5 })
47
+ expect(timeToX(5, v)).toBe(0)
48
+ expect(timeToX(10, v)).toBe(100)
49
+ expect(timeToX(0, v)).toBe(-100)
50
+ })
51
+
52
+ it('round-trips x → time → x', () => {
53
+ const v = vp({ pxPerSecond: 37.5, scrollSeconds: 12.25 })
54
+ for (const x of [0, 1, 250, 999.5]) {
55
+ expect(xToTime(timeToX(xToTime(x, v), v), v)).toBeCloseTo(xToTime(x, v), 10)
56
+ }
57
+ })
58
+
59
+ it('reports the visible window from width and scale', () => {
60
+ const v = vp({ pxPerSecond: 50, scrollSeconds: 3, widthPx: 500 })
61
+ expect(visibleDuration(v)).toBe(10)
62
+ expect(visibleRange(v)).toEqual({ start: 3, end: 13 })
63
+ })
64
+
65
+ it('degrades safely before the first layout (no width, no scale)', () => {
66
+ const v = vp({ pxPerSecond: 0, widthPx: 0 })
67
+ expect(visibleDuration(v)).toBe(0)
68
+ expect(xToTime(123, v)).toBe(v.scrollSeconds)
69
+ })
70
+ })
71
+
72
+ describe('zoom clamping', () => {
73
+ it('fits the project across the surface', () => {
74
+ expect(fitPxPerSecond(1000, 50)).toBe(20)
75
+ expect(fitPxPerSecond(0, 50)).toBe(0)
76
+ expect(fitPxPerSecond(1000, 0)).toBe(0)
77
+ })
78
+
79
+ it('allows zooming out below fit — the DOM model could not', () => {
80
+ const widthPx = 1000
81
+ const totalDuration = 50
82
+ const fit = fitPxPerSecond(widthPx, totalDuration) // 20 px/s
83
+ expect(clampPxPerSecond(fit * 0.5, widthPx, totalDuration)).toBe(fit * 0.5)
84
+ // …but not indefinitely: the floor is a quarter of fit.
85
+ expect(clampPxPerSecond(0.001, widthPx, totalDuration)).toBe(fit * MIN_FIT_MULTIPLE)
86
+ })
87
+
88
+ it('never goes below the absolute floor on very long projects', () => {
89
+ // fit here is 0.1 px/s; a quarter of that is unusable, so the floor wins.
90
+ expect(clampPxPerSecond(0.0001, 1000, 10_000)).toBe(MIN_PX_PER_SECOND)
91
+ })
92
+
93
+ it('caps zoom-in', () => {
94
+ expect(clampPxPerSecond(1e6, 1000, 50)).toBe(MAX_PX_PER_SECOND)
95
+ })
96
+
97
+ it('reports zoom as a fit-relative multiple, for continuity with the DOM badge', () => {
98
+ expect(zoomMultiple(vp({ pxPerSecond: 20, widthPx: 1000 }), 50)).toBe(1)
99
+ expect(zoomMultiple(vp({ pxPerSecond: 40, widthPx: 1000 }), 50)).toBe(2)
100
+ expect(zoomMultiple(vp({ pxPerSecond: 5, widthPx: 1000 }), 50)).toBe(0.25)
101
+ expect(zoomMultiple(vp({ widthPx: 0 }), 50)).toBe(1)
102
+ })
103
+
104
+ it('formats the badge with one decimal below 10×', () => {
105
+ expect(formatZoomMultiple(1)).toBe('1.0')
106
+ expect(formatZoomMultiple(2.34)).toBe('2.3')
107
+ expect(formatZoomMultiple(12.4)).toBe('12')
108
+ expect(formatZoomMultiple(Number.NaN)).toBe('1')
109
+ })
110
+ })
111
+
112
+ describe('scroll clamping', () => {
113
+ it('stops at the content end plus a fraction of a screenful', () => {
114
+ const v = vp({ pxPerSecond: 10, widthPx: 1000 }) // 100s visible
115
+ const totalDuration = 300
116
+ expect(maxScrollSeconds(v, totalDuration)).toBe(300 - 100 + 100 * OVERSCROLL_FRACTION)
117
+ expect(clampScrollSeconds(1e6, v, totalDuration)).toBe(maxScrollSeconds(v, totalDuration))
118
+ })
119
+
120
+ it('pins to zero when the whole project already fits', () => {
121
+ const v = vp({ pxPerSecond: 10, widthPx: 1000 }) // 100s visible
122
+ expect(maxScrollSeconds(v, 30)).toBe(0)
123
+ expect(clampScrollSeconds(20, v, 30)).toBe(0)
124
+ })
125
+
126
+ it('never scrolls before t=0', () => {
127
+ expect(clampScrollSeconds(-12, vp(), 500)).toBe(0)
128
+ })
129
+
130
+ it('pans by pixels in the current scale', () => {
131
+ const v = vp({ pxPerSecond: 50, scrollSeconds: 10, widthPx: 500 })
132
+ expect(panByPixels(v, 250, 1000).scrollSeconds).toBe(15)
133
+ expect(panByPixels(v, -250, 1000).scrollSeconds).toBe(5)
134
+ expect(panByPixels(v, -1e6, 1000).scrollSeconds).toBe(0)
135
+ })
136
+ })
137
+
138
+ describe('edge auto-scroll delta', () => {
139
+ // A round scale (100 px/s) keeps the arithmetic legible: 1 content px is
140
+ // 0.01s, so the 1400 px/s cap is 14s of timeline per second of hold.
141
+ const pxPerSecond = 100
142
+ const canvasWidth = 1000
143
+ const zone = EDGE_SCROLL_ZONE_PX // 40
144
+ const maxSpeed = EDGE_SCROLL_MAX_PX_PER_SEC // 1400 px/s
145
+ const ramp = EDGE_SCROLL_RAMP_PX // 70 — the last 30px of it lie PAST the surface edge
146
+
147
+ /** The pan the curve owes for a given depth past the zone's outer boundary:
148
+ * `(depth / ramp) ^ 1.5` of the cap, converted to timeline seconds. Spelled
149
+ * out once here so the expectations below read as the curve's definition
150
+ * rather than as copied magic numbers. */
151
+ const expectedSpeed = (depthPx: number) =>
152
+ (maxSpeed * Math.pow(Math.min(1, depthPx / ramp), 1.5)) / pxPerSecond
153
+
154
+ /** `edgeScrollDelta` against the fixture above; `dt` defaults to a full
155
+ * second so the returned value reads directly as seconds/second. */
156
+ const at = (pointerX: number, dt = 1) =>
157
+ edgeScrollDelta(pointerX, canvasWidth, pxPerSecond, dt, zone, maxSpeed, ramp)
158
+
159
+ it('is zero for a pointer in the middle of the surface', () => {
160
+ expect(at(500)).toBe(0)
161
+ })
162
+
163
+ it('is zero exactly at the zone boundary, on either side', () => {
164
+ expect(at(zone)).toBe(0)
165
+ expect(at(canvasWidth - zone)).toBe(0)
166
+ })
167
+
168
+ it('pans left (negative) inside the left zone, right (positive) inside the right zone', () => {
169
+ expect(at(10)).toBeLessThan(0)
170
+ expect(at(canvasWidth - 10)).toBeGreaterThan(0)
171
+ })
172
+
173
+ it('ramps with how deep past the zone boundary the pointer sits, eased rather than linear', () => {
174
+ // Depths of 10px and 30px past the boundary, both still inside the surface.
175
+ const shallow = at(zone - 10)
176
+ const deep = at(zone - 30)
177
+ expect(Math.abs(shallow)).toBeCloseTo(expectedSpeed(10), 10)
178
+ expect(Math.abs(deep)).toBeCloseTo(expectedSpeed(30), 10)
179
+ // Eased, so tripling the depth MORE than triples the speed — a linear ramp
180
+ // would land exactly on 3x.
181
+ expect(Math.abs(deep)).toBeGreaterThan(Math.abs(shallow) * 3)
182
+
183
+ const shallowRight = at(canvasWidth - (zone - 10))
184
+ const deepRight = at(canvasWidth - (zone - 30))
185
+ expect(shallowRight).toBeCloseTo(-shallow, 10) // mirror image of the left side
186
+ expect(deepRight).toBeCloseTo(-deep, 10)
187
+ })
188
+
189
+ it('keeps accelerating PAST the surface edge, not only inside the zone', () => {
190
+ // This is the whole reason the ramp is wider than the zone. A drag reaches
191
+ // the surface edge in one flick, so a curve that maxed out there would make
192
+ // every drag feel like one flat speed no matter how hard it was pushed.
193
+ const atEdge = at(0)
194
+ const past15 = at(-15)
195
+ const past30 = at(-30)
196
+ expect(Math.abs(past15)).toBeGreaterThan(Math.abs(atEdge))
197
+ expect(Math.abs(past30)).toBeGreaterThan(Math.abs(past15))
198
+ // And the edge itself is well under the cap — a nudge, not a launch.
199
+ expect(Math.abs(atEdge)).toBeCloseTo(expectedSpeed(zone), 10)
200
+ expect(Math.abs(atEdge)).toBeLessThan(maxSpeed / pxPerSecond / 2)
201
+
202
+ // Mirrored on the right edge, same depths.
203
+ expect(at(canvasWidth)).toBeCloseTo(-atEdge, 10)
204
+ expect(at(canvasWidth + 15)).toBeCloseTo(-past15, 10)
205
+ expect(at(canvasWidth + 30)).toBeCloseTo(-past30, 10)
206
+ })
207
+
208
+ it('clamps at the max once the pointer is a full ramp past the boundary', () => {
209
+ // The document-level drag listeners can report an x arbitrarily far outside
210
+ // the surface once the real cursor leaves it — depth must clamp there, not
211
+ // keep growing. `zone - ramp` is exactly where the ramp is spent.
212
+ const rampSpent = at(zone - ramp)
213
+ expect(rampSpent).toBeCloseTo(-maxSpeed / pxPerSecond, 10)
214
+ expect(at(-500)).toBe(rampSpent)
215
+
216
+ const rightRampSpent = at(canvasWidth - zone + ramp)
217
+ expect(rightRampSpent).toBeCloseTo(maxSpeed / pxPerSecond, 10)
218
+ expect(at(canvasWidth + 500)).toBe(rightRampSpent)
219
+ })
220
+
221
+ it('scales with the frame delta — framerate independent', () => {
222
+ const oneFrame = at(10, 1 / 60)
223
+ const doubleFrame = at(10, 2 / 60)
224
+ expect(doubleFrame).toBeCloseTo(oneFrame * 2, 10)
225
+ })
226
+
227
+ it('is zero for a non-positive frame delta, px/second, zone, ramp, or surface width', () => {
228
+ expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 0, zone, maxSpeed, ramp)).toBe(0)
229
+ expect(edgeScrollDelta(10, canvasWidth, 0, 1, zone, maxSpeed, ramp)).toBe(0)
230
+ expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 1, 0, maxSpeed, ramp)).toBe(0)
231
+ expect(edgeScrollDelta(10, canvasWidth, pxPerSecond, 1, zone, maxSpeed, 0)).toBe(0)
232
+ expect(edgeScrollDelta(10, 0, pxPerSecond, 1, zone, maxSpeed, ramp)).toBe(0)
233
+ })
234
+
235
+ it('converts content px/second to timeline seconds through the current scale', () => {
236
+ // At the surface edge, a 200 px/s zoom halves the seconds moved compared to
237
+ // the 100 px/s fixture above for the same content speed.
238
+ const atZoomedIn = edgeScrollDelta(0, canvasWidth, 200, 1, zone, maxSpeed, ramp)
239
+ expect(atZoomedIn).toBeCloseTo(at(0) / 2, 10)
240
+ })
241
+ })
242
+
243
+ describe('pivot-preserving zoom', () => {
244
+ it('keeps the time under the cursor stationary', () => {
245
+ // Scrolled well into the timeline, so zooming out either way has room and
246
+ // the clamps (covered separately below) never enter the picture.
247
+ const v = vp({ pxPerSecond: 20, scrollSeconds: 100, widthPx: 1000 })
248
+ const totalDuration = 600
249
+ const pivotX = 320
250
+ const pivotTime = xToTime(pivotX, v)
251
+
252
+ for (const factor of [1.25, 0.8, 2, 0.5]) {
253
+ const next = zoomAtPivot(v, factor, pivotX, totalDuration)
254
+ expect(next.pxPerSecond).toBeCloseTo(v.pxPerSecond * factor, 10)
255
+ expect(xToTime(pivotX, next)).toBeCloseTo(pivotTime, 10)
256
+ }
257
+ })
258
+
259
+ it('holds the pivot across a chain of wheel ticks', () => {
260
+ let v = vp({ pxPerSecond: 20, scrollSeconds: 30, widthPx: 1000 })
261
+ const totalDuration = 600
262
+ const pivotX = 640
263
+ const pivotTime = xToTime(pivotX, v)
264
+ for (let i = 0; i < 12; i++) {
265
+ v = zoomAtPivot(v, Math.exp(-(-100) * 0.002), pivotX, totalDuration)
266
+ }
267
+ expect(xToTime(pivotX, v)).toBeCloseTo(pivotTime, 8)
268
+ })
269
+
270
+ it('gives up the pivot only where scroll would leave the legal range', () => {
271
+ // Pivot near the left edge while already scrolled to 0: holding it would
272
+ // require scrolling before t=0.
273
+ const v = vp({ pxPerSecond: 20, scrollSeconds: 0, widthPx: 1000 })
274
+ const next = zoomAtPivot(v, 0.5, 10, 600)
275
+ expect(next.scrollSeconds).toBe(0)
276
+ })
277
+
278
+ it('clamps the scale, and keeps scroll legal when it does', () => {
279
+ const v = vp({ pxPerSecond: 1900, scrollSeconds: 100, widthPx: 1000 })
280
+ const next = zoomAtPivot(v, 4, 500, 600)
281
+ expect(next.pxPerSecond).toBe(MAX_PX_PER_SECOND)
282
+ expect(next.scrollSeconds).toBeGreaterThanOrEqual(0)
283
+ expect(next.scrollSeconds).toBeLessThanOrEqual(maxScrollSeconds(next, 600))
284
+ })
285
+
286
+ it('fits to the surface and rewinds to the start', () => {
287
+ const fitted = fitViewport(vp({ pxPerSecond: 900, scrollSeconds: 42 }), 50)
288
+ expect(fitted.pxPerSecond).toBe(20)
289
+ expect(fitted.scrollSeconds).toBe(0)
290
+ expect(zoomMultiple(fitted, 50)).toBe(1)
291
+ })
292
+ })
293
+
294
+ describe('wheel semantics (ported from useTimelineZoom)', () => {
295
+ const at = (over: Partial<Parameters<typeof wheelIntent>[0]>) =>
296
+ wheelIntent({ deltaX: 0, deltaY: 0, ctrlKey: false, metaKey: false, altKey: false, ...over }, 400)
297
+
298
+ it('⌘/Ctrl-wheel zooms at the cursor with the DOM path exp() step', () => {
299
+ const ctrl = at({ ctrlKey: true, deltaY: -100 })
300
+ expect(ctrl).toEqual({ kind: 'zoom', factor: Math.exp(0.2), pivotX: 400 })
301
+ const meta = at({ metaKey: true, deltaY: 100 })
302
+ expect(meta).toEqual({ kind: 'zoom', factor: Math.exp(-0.2), pivotX: 400 })
303
+ })
304
+
305
+ it('Alt-wheel pans horizontally by deltaY', () => {
306
+ expect(at({ altKey: true, deltaY: 60 })).toEqual({ kind: 'pan', deltaPx: 60 })
307
+ })
308
+
309
+ it('leaves a plain vertical wheel to the page', () => {
310
+ expect(at({ deltaY: 120 })).toEqual({ kind: 'none' })
311
+ })
312
+
313
+ it('consumes a horizontally-dominant plain wheel (trackpad swipe)', () => {
314
+ expect(at({ deltaX: 80, deltaY: 5 })).toEqual({ kind: 'pan', deltaPx: 80 })
315
+ })
316
+
317
+ it('applies an intent to the viewport', () => {
318
+ const v = vp({ pxPerSecond: 20, scrollSeconds: 10, widthPx: 1000 })
319
+ const zoomed = applyWheelIntent(v, { kind: 'zoom', factor: 2, pivotX: 0 }, 600)
320
+ expect(zoomed.pxPerSecond).toBe(40)
321
+ expect(zoomed.scrollSeconds).toBe(10) // pivot at x=0 is the left edge
322
+ expect(applyWheelIntent(v, { kind: 'pan', deltaPx: 200 }, 600).scrollSeconds).toBe(20)
323
+ expect(applyWheelIntent(v, { kind: 'none' }, 600)).toBe(v)
324
+ })
325
+ })
326
+
327
+ describe('surface size changes', () => {
328
+ it('starts fitted on the first layout', () => {
329
+ const first = withSurfaceWidth({ pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }, 800, 40)
330
+ expect(first.widthPx).toBe(800)
331
+ expect(first.pxPerSecond).toBe(20)
332
+ })
333
+
334
+ it('preserves scale when the window is resized', () => {
335
+ const v = vp({ pxPerSecond: 60, scrollSeconds: 5, widthPx: 1000 })
336
+ const wider = withSurfaceWidth(v, 1400, 600)
337
+ expect(wider.pxPerSecond).toBe(60)
338
+ expect(wider.scrollSeconds).toBe(5)
339
+ })
340
+
341
+ it('re-clamps scroll when the project shrinks under the playhead', () => {
342
+ const v = vp({ pxPerSecond: 10, scrollSeconds: 400, widthPx: 1000 })
343
+ const shrunk = reclampForDuration(v, 60)
344
+ expect(shrunk.scrollSeconds).toBe(0)
345
+ })
346
+ })
347
+
348
+ describe('DPR-crisp sizing', () => {
349
+ it('sizes the backing store in device pixels', () => {
350
+ expect(backingStoreSize(800, 120, 2)).toEqual({ width: 1600, height: 240 })
351
+ expect(backingStoreSize(800.4, 120.6, 1)).toEqual({ width: 800, height: 121 })
352
+ expect(backingStoreSize(0, 0, 2)).toEqual({ width: 1, height: 1 })
353
+ expect(backingStoreSize(800, 120, 0)).toEqual({ width: 800, height: 120 })
354
+ })
355
+
356
+ it('only rewrites canvas dimensions when they actually change', () => {
357
+ const canvas = document.createElement('canvas')
358
+ expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 2 })).toBe(true)
359
+ expect(canvas.width).toBe(800)
360
+ expect(canvas.height).toBe(200)
361
+ expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 2 })).toBe(false)
362
+ // Same CSS box, new monitor: the backing store must follow the DPR.
363
+ expect(syncCanvasBackingStore(canvas, { cssWidth: 400, cssHeight: 100, dpr: 1 })).toBe(true)
364
+ expect(canvas.width).toBe(400)
365
+ })
366
+
367
+ it('puts the context in CSS-pixel space idempotently', () => {
368
+ const setTransform = vi.fn()
369
+ scaleContextToDpr({ setTransform } as unknown as CanvasRenderingContext2D, 3)
370
+ scaleContextToDpr({ setTransform } as unknown as CanvasRenderingContext2D, 3)
371
+ expect(setTransform).toHaveBeenNthCalledWith(1, 3, 0, 0, 3, 0, 0)
372
+ expect(setTransform).toHaveBeenNthCalledWith(2, 3, 0, 0, 3, 0, 0)
373
+ })
374
+ })
375
+
376
+ describe('viewport store', () => {
377
+ it('notifies subscribers on change and skips no-op writes', () => {
378
+ const store = createViewportStore(vp())
379
+ const seen = vi.fn()
380
+ const unsubscribe = store.subscribe(seen)
381
+
382
+ store.set(v => ({ ...v, scrollSeconds: 4 }))
383
+ expect(store.get().scrollSeconds).toBe(4)
384
+ expect(seen).toHaveBeenCalledTimes(1)
385
+
386
+ store.set(v => ({ ...v })) // same numbers, new object
387
+ expect(seen).toHaveBeenCalledTimes(1)
388
+
389
+ unsubscribe()
390
+ store.set(v => ({ ...v, scrollSeconds: 9 }))
391
+ expect(seen).toHaveBeenCalledTimes(1)
392
+ expect(store.get().scrollSeconds).toBe(9)
393
+ })
394
+
395
+ it('keeps snapshot identity stable so useSyncExternalStore does not loop', () => {
396
+ const store = createViewportStore(vp())
397
+ expect(store.get()).toBe(store.get())
398
+ })
399
+ })