@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,372 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The left rail that names each track. Its whole job is alignment: a label that
4
+ * sits at a different y than the clips it describes is worse than no label, so
5
+ * these assert it positions off the SAME layout the canvas paints from.
6
+ */
7
+ import { describe, it, expect, vi, afterEach } from 'vitest'
8
+ import { render, screen, cleanup, fireEvent } from '@testing-library/react'
9
+ import TrackGutter, { visualTrackKind } from '../TrackGutter'
10
+ import { computeTimelineLayout } from '../canvas/draw'
11
+ import type { Project } from '../../../types'
12
+ import type { Captions, VisualItem } from '../../../schema'
13
+
14
+ afterEach(() => cleanup())
15
+
16
+ const video = (id: string, over: Partial<VisualItem> = {}): VisualItem =>
17
+ ({ id, type: 'video', src: 'a.mp4', start: 0, end: 2, ...over }) as VisualItem
18
+ const overlay = (id: string, over: Partial<VisualItem> = {}): VisualItem =>
19
+ ({ id, type: 'overlay', src: '/p/overlays/text_line.jsx', start: 0, end: 2, ...over }) as VisualItem
20
+ const captions = (over: Partial<Captions> = {}): Captions =>
21
+ ({ style: 'clean', segments: [{ text: 'hello', start: 0, end: 1 }], ...over })
22
+
23
+ function project(over: Partial<Project> = {}): Project {
24
+ return {
25
+ id: 'p',
26
+ tracks: [[video('c0')], [overlay('o0')]],
27
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 }] },
28
+ ...over,
29
+ } as unknown as Project
30
+ }
31
+
32
+ describe('visualTrackKind', () => {
33
+ it('reads the kind off the items, since tracks are untyped in the schema', () => {
34
+ expect(visualTrackKind([video('a'), video('b')])).toBe('video')
35
+ expect(visualTrackKind([overlay('a')])).toBe('overlay')
36
+ })
37
+
38
+ it('has no answer for an empty track rather than guessing "video"', () => {
39
+ expect(visualTrackKind([])).toBeNull()
40
+ })
41
+
42
+ it('falls back to the first item on a mixed track', () => {
43
+ expect(visualTrackKind([overlay('a'), video('b')])).toBe('overlay')
44
+ })
45
+ })
46
+
47
+ describe('TrackGutter', () => {
48
+ it('names each row by what it holds, plus the audio lane and the caption row', () => {
49
+ // Icon-only cells: the name is the accessible name (and the hover tooltip),
50
+ // not on-screen text. The caption cell only renders when the layout has a
51
+ // band to align with (see 'TrackGutter — caption cell alignment' below),
52
+ // so this project needs actual caption segments.
53
+ render(<TrackGutter project={project({ captions: captions() })} />)
54
+ expect(screen.getByLabelText('Video')).toBeTruthy()
55
+ expect(screen.getByLabelText('Overlay')).toBeTruthy()
56
+ expect(screen.getByLabelText('Audio')).toBeTruthy()
57
+ expect(screen.getByLabelText('Captions')).toBeTruthy()
58
+ })
59
+
60
+ it('places every label at the y its own row occupies in the canvas layout', () => {
61
+ // The alignment invariant. Both surfaces read one `computeTimelineLayout`,
62
+ // so this fails the moment the rail starts deriving geometry of its own.
63
+ const p = project()
64
+ const layout = computeTimelineLayout(p)
65
+ const { container } = render(<TrackGutter project={p} layout={layout} />)
66
+
67
+ const positioned = [...container.querySelectorAll<HTMLElement>('.absolute')]
68
+ const seen = positioned.map(el => ({ top: el.style.top, height: el.style.height }))
69
+
70
+ for (const row of layout.rows) {
71
+ expect(seen).toContainEqual({ top: `${row.y}px`, height: `${row.height}px` })
72
+ }
73
+ for (const lane of layout.lanes) {
74
+ expect(seen).toContainEqual({ top: `${lane.y}px`, height: `${lane.height}px` })
75
+ }
76
+ })
77
+
78
+ it('gives the base track its taller cell, not a uniform row height', () => {
79
+ const p = project()
80
+ const layout = computeTimelineLayout(p)
81
+ render(<TrackGutter project={p} layout={layout} />)
82
+
83
+ const base = layout.rows.find(r => r.trackIdx === 0)!
84
+ const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
85
+ expect(base.height).toBeGreaterThan(overlayRow.height)
86
+ })
87
+
88
+ it('omits the caption cell when showCaptionRow is false, even though the layout carries a caption band', () => {
89
+ const p = project({ captions: captions() })
90
+ expect(computeTimelineLayout(p).captions).toBeTruthy()
91
+ render(<TrackGutter project={p} showCaptionRow={false} />)
92
+ expect(screen.queryByLabelText('Captions')).toBeNull()
93
+ })
94
+
95
+ it('labels an empty track rather than rendering a blank cell', () => {
96
+ render(<TrackGutter project={project({ tracks: [{ id: 'trk-0', items: [] }] } as Partial<Project>)} />)
97
+ expect(screen.getByLabelText('Track')).toBeTruthy()
98
+ })
99
+
100
+ it('renders without an audio lane or captions on a bare project', () => {
101
+ const bare = { id: 'p', tracks: [[video('c0')]] } as unknown as Project
102
+ render(<TrackGutter project={bare} showCaptionRow={false} />)
103
+ expect(screen.getByLabelText('Video')).toBeTruthy()
104
+ expect(screen.queryByLabelText('Audio')).toBeNull()
105
+ })
106
+ })
107
+
108
+ describe('TrackGutter — caption cell alignment', () => {
109
+ // The caption row moved from a DOM strip below the canvas to a band INSIDE
110
+ // the canvas layout, so its rail cell has to be positioned like every other
111
+ // row rather than trail the gutter as its own flex cell.
112
+
113
+ it('positions the caption cell at layout.captions[0].y/height, inside the same block the track rows live in', () => {
114
+ const p = project({ captions: captions() })
115
+ const layout = computeTimelineLayout(p)
116
+ expect(layout.captions).toHaveLength(1)
117
+
118
+ const { container } = render(<TrackGutter project={p} layout={layout} showCaptionRow />)
119
+
120
+ const cell = screen.getByLabelText('Captions')
121
+ // `.absolute` is the wrapper VisualTrackRailRow/AudioLaneRailRow both use —
122
+ // finding it via `closest` rather than asserting on RailCell itself proves
123
+ // the cell is wrapped the SAME way the other rows are, not styled directly.
124
+ const wrapper = cell.closest('.absolute') as HTMLElement
125
+ expect(wrapper).toBeTruthy()
126
+ expect(wrapper.style.top).toBe(`${layout.captions![0].y}px`)
127
+ expect(wrapper.style.height).toBe(`${layout.captions![0].height}px`)
128
+
129
+ // Structural, not a snapshot: the wrapper is a DESCENDANT of the `.relative`
130
+ // block the track rows and audio lanes are absolutely positioned inside —
131
+ // not a trailing sibling after it.
132
+ const relativeBlock = container.querySelector('.relative')!
133
+ expect(relativeBlock.contains(wrapper)).toBe(true)
134
+ })
135
+
136
+ it('renders no caption cell on a project with no captions, even though showCaptionRow defaults true, and nothing else shifts', () => {
137
+ const p = project() // no `captions` field — the layout carries no bands at all
138
+ const layout = computeTimelineLayout(p)
139
+ expect(layout.captions).toBeUndefined()
140
+
141
+ const { container } = render(<TrackGutter project={p} layout={layout} />)
142
+ expect(screen.queryByLabelText('Captions')).toBeNull()
143
+
144
+ // The row/lane cells still land exactly where the (caption-less) layout
145
+ // says they do — missing bands don't leave a gap or shift anything.
146
+ const positioned = [...container.querySelectorAll<HTMLElement>('.absolute')]
147
+ const seen = positioned.map(el => ({ top: el.style.top, height: el.style.height }))
148
+ for (const row of layout.rows) {
149
+ expect(seen).toContainEqual({ top: `${row.y}px`, height: `${row.height}px` })
150
+ }
151
+ for (const lane of layout.lanes) {
152
+ expect(seen).toContainEqual({ top: `${lane.y}px`, height: `${lane.height}px` })
153
+ }
154
+ })
155
+
156
+ it('renders N cells, one per band, each at its own band y — "Captions 1"…"Captions N" once there is more than one', () => {
157
+ const p = project({
158
+ captions: captions({
159
+ segments: [
160
+ { text: 'a', start: 0, end: 1, lane: 0 },
161
+ { text: 'b', start: 0, end: 1, lane: 1 },
162
+ { text: 'c', start: 0, end: 1, lane: 2 },
163
+ ],
164
+ }),
165
+ })
166
+ const layout = computeTimelineLayout(p)
167
+ expect(layout.captions).toHaveLength(3)
168
+
169
+ render(<TrackGutter project={p} layout={layout} showCaptionRow />)
170
+
171
+ // The bare "Captions" label is gone once there is more than one band.
172
+ expect(screen.queryByLabelText('Captions')).toBeNull()
173
+ for (const band of layout.captions!) {
174
+ const cell = screen.getByLabelText(`Captions ${band.lane + 1}`)
175
+ const wrapper = cell.closest('.absolute') as HTMLElement
176
+ expect(wrapper.style.top).toBe(`${band.y}px`)
177
+ expect(wrapper.style.height).toBe(`${band.height}px`)
178
+ }
179
+ })
180
+ })
181
+
182
+ describe('TrackGutter — skip', () => {
183
+ it('shows no skip control when the host has not wired the edit channel', () => {
184
+ // A button that silently does nothing is worse than no button.
185
+ render(<TrackGutter project={project()} />)
186
+ expect(screen.queryByLabelText(/^Skip /)).toBeNull()
187
+ })
188
+
189
+ it('offers a skip toggle per visual track when wired', () => {
190
+ render(<TrackGutter project={project()} onToggleTrackEnabled={vi.fn()} />)
191
+ expect(screen.getByLabelText('Skip video track')).toBeTruthy()
192
+ expect(screen.getByLabelText('Skip overlay track')).toBeTruthy()
193
+ })
194
+
195
+ it('reports the track index and the NEW enabled value', () => {
196
+ const onToggle = vi.fn()
197
+ render(<TrackGutter project={project()} onToggleTrackEnabled={onToggle} />)
198
+ fireEvent.click(screen.getByLabelText('Skip video track'))
199
+ expect(onToggle).toHaveBeenCalledWith(0, false)
200
+ })
201
+
202
+ it('re-enables a skipped track', () => {
203
+ const onToggle = vi.fn()
204
+ const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], enabled: false }] } as unknown as Project
205
+ render(<TrackGutter project={p} showCaptionRow={false} onToggleTrackEnabled={onToggle} />)
206
+ fireEvent.click(screen.getByLabelText('Skip video track'))
207
+ expect(onToggle).toHaveBeenCalledWith(0, true)
208
+ })
209
+
210
+ it('marks a skipped track pressed, so the state is readable without colour', () => {
211
+ const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], enabled: false }] } as unknown as Project
212
+ render(<TrackGutter project={p} showCaptionRow={false} onToggleTrackEnabled={vi.fn()} />)
213
+ expect(screen.getByLabelText('Skip video track').getAttribute('aria-pressed')).toBe('true')
214
+ })
215
+
216
+ it('treats a legacy-shaped project as all-enabled', () => {
217
+ const legacy = { id: 'p', tracks: [[video('c0')]] } as unknown as Project
218
+ render(<TrackGutter project={legacy} showCaptionRow={false} onToggleTrackEnabled={vi.fn()} />)
219
+ expect(screen.getByLabelText('Skip video track').getAttribute('aria-pressed')).toBe('false')
220
+ })
221
+ })
222
+
223
+ describe('TrackGutter — rail controls and settings', () => {
224
+ const wired = {
225
+ onToggleTrackEnabled: vi.fn(),
226
+ onSetTrackVolume: vi.fn(),
227
+ onSetTrackMuted: vi.fn(),
228
+ }
229
+
230
+ it('puts mute and skip in the RAIL, and volume behind the gear', () => {
231
+ // Mute and skip are flipped constantly while cutting and need to be seen at
232
+ // a glance, so they are inline; the gear is for what you set once.
233
+ render(<TrackGutter project={project()} {...wired} />)
234
+ expect(screen.getByRole('button', { name: 'Mute video track' })).toBeTruthy()
235
+ expect(screen.getByRole('button', { name: 'Skip video track' })).toBeTruthy()
236
+ expect(screen.queryByRole('dialog')).toBeNull()
237
+
238
+ fireEvent.click(screen.getByRole('button', { name: 'Video track settings' }))
239
+ expect(screen.getByLabelText('Video volume')).toBeTruthy()
240
+ })
241
+
242
+ it('offers NO audio controls on an overlay-only track', () => {
243
+ // Overlay and image items carry no audio — `VisualItem.volume`/`muted` are
244
+ // video-only — so a mute button or volume slider there could do nothing.
245
+ render(<TrackGutter project={project()} {...wired} />)
246
+ expect(screen.queryByRole('button', { name: 'Mute overlay track' })).toBeNull()
247
+ expect(screen.queryByRole('button', { name: 'Overlay track settings' })).toBeNull()
248
+ // Skip still applies — you can hide an overlay track.
249
+ expect(screen.getByRole('button', { name: 'Skip overlay track' })).toBeTruthy()
250
+ })
251
+
252
+ it('gives a MIXED track audio controls, reading the items not the track kind', () => {
253
+ const mixed = {
254
+ id: 'p',
255
+ tracks: [{ id: 't0', items: [overlay('o0'), video('c0')] }],
256
+ } as unknown as Project
257
+ render(<TrackGutter project={mixed} showCaptionRow={false} {...wired} />)
258
+ expect(screen.getByRole('button', { name: 'Mute overlay track' })).toBeTruthy()
259
+ })
260
+
261
+ it('the type icon is a plain label, never the settings trigger', () => {
262
+ // It used to double as the trigger, which made it impossible to tell what
263
+ // was clickable.
264
+ render(<TrackGutter project={project()} {...wired} />)
265
+ expect(screen.getByLabelText('Video').tagName).toBe('SPAN')
266
+ })
267
+
268
+ it('renders no gear when volume is not wired', () => {
269
+ render(<TrackGutter project={project()} onToggleTrackEnabled={vi.fn()} />)
270
+ expect(screen.queryByRole('button', { name: 'Video track settings' })).toBeNull()
271
+ })
272
+
273
+ it('toggles mute from the rail with the new value', () => {
274
+ const onSetTrackMuted = vi.fn()
275
+ render(<TrackGutter project={project()} {...wired} onSetTrackMuted={onSetTrackMuted} />)
276
+ fireEvent.click(screen.getByRole('button', { name: 'Mute video track' }))
277
+ expect(onSetTrackMuted).toHaveBeenCalledWith(0, true)
278
+ })
279
+
280
+ it('shows a muted track as pressed, so the state reads without colour', () => {
281
+ const p = { id: 'p', tracks: [{ id: 't0', items: [video('c0')], muted: true }] } as unknown as Project
282
+ render(<TrackGutter project={p} showCaptionRow={false} {...wired} />)
283
+ expect(screen.getByRole('button', { name: 'Mute video track' }).getAttribute('aria-pressed')).toBe('true')
284
+ })
285
+
286
+ it('gives an audio lane mute inline and volume behind the gear, but no skip', () => {
287
+ render(<TrackGutter project={project()} onSetLaneVolume={vi.fn()} onSetLaneMuted={vi.fn()} />)
288
+ expect(screen.getByRole('button', { name: 'Mute audio lane' })).toBeTruthy()
289
+ expect(screen.queryByRole('button', { name: /^Skip audio/ })).toBeNull()
290
+
291
+ fireEvent.click(screen.getByRole('button', { name: 'Audio lane settings' }))
292
+ expect(screen.getByLabelText('Audio lane volume')).toBeTruthy()
293
+ })
294
+
295
+ it('fans a lane mute out over every track id in the lane', () => {
296
+ const onSetLaneMuted = vi.fn()
297
+ const twoTrackLane = {
298
+ id: 'p',
299
+ tracks: [{ id: 't0', items: [video('c0')] }],
300
+ audio: { tracks: [
301
+ { id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 },
302
+ { id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
303
+ ] },
304
+ } as unknown as Project
305
+ render(<TrackGutter project={twoTrackLane} showCaptionRow={false} onSetLaneMuted={onSetLaneMuted} />)
306
+ fireEvent.click(screen.getByRole('button', { name: 'Mute audio lane' }))
307
+ expect(onSetLaneMuted).toHaveBeenCalledWith(['a0', 'a1'], true)
308
+ })
309
+
310
+ it('shows no magnet control when the host has not wired onSetLaneMagnet', () => {
311
+ render(<TrackGutter project={project()} onSetLaneMuted={vi.fn()} />)
312
+ expect(screen.queryByRole('button', { name: /^Magnetic /i })).toBeNull()
313
+ })
314
+
315
+ it('offers a magnet toggle per audio lane when wired, reflecting the OFF default', () => {
316
+ render(<TrackGutter project={project()} onSetLaneMagnet={vi.fn()} />)
317
+ const toggle = screen.getByRole('button', { name: 'Magnetic audio lane' })
318
+ expect(toggle).toBeTruthy()
319
+ expect(toggle.getAttribute('aria-pressed')).toBe('false')
320
+ })
321
+
322
+ it('reflects an ON magnetic lane', () => {
323
+ const p = {
324
+ id: 'p',
325
+ tracks: [{ id: 't0', items: [video('c0')] }],
326
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true }] },
327
+ } as unknown as Project
328
+ render(<TrackGutter project={p} showCaptionRow={false} onSetLaneMagnet={vi.fn()} />)
329
+ expect(screen.getByRole('button', { name: 'Magnetic audio lane' }).getAttribute('aria-pressed')).toBe('true')
330
+ })
331
+
332
+ it('fans a lane magnet toggle out over every track id in the lane, with the NEW value', () => {
333
+ const onSetLaneMagnet = vi.fn()
334
+ const twoTrackLane = {
335
+ id: 'p',
336
+ tracks: [{ id: 't0', items: [video('c0')] }],
337
+ audio: { tracks: [
338
+ { id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0 },
339
+ { id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
340
+ ] },
341
+ } as unknown as Project
342
+ render(<TrackGutter project={twoTrackLane} showCaptionRow={false} onSetLaneMagnet={onSetLaneMagnet} />)
343
+ fireEvent.click(screen.getByRole('button', { name: 'Magnetic audio lane' }))
344
+ expect(onSetLaneMagnet).toHaveBeenCalledWith(['a0', 'a1'], true)
345
+ })
346
+
347
+ it('reads a lane as magnetic only when EVERY track in it is', () => {
348
+ const mixedLane = {
349
+ id: 'p',
350
+ tracks: [{ id: 't0', items: [video('c0')] }],
351
+ audio: { tracks: [
352
+ { id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true },
353
+ { id: 'a1', src: 'm.mp3', start: 0, end: 2, lane: 0 },
354
+ ] },
355
+ } as unknown as Project
356
+ render(<TrackGutter project={mixedLane} showCaptionRow={false} onSetLaneMagnet={vi.fn()} />)
357
+ expect(screen.getByRole('button', { name: 'Magnetic audio lane' }).getAttribute('aria-pressed')).toBe('false')
358
+ })
359
+
360
+ it('previews a volume drag and commits once on release', () => {
361
+ const onSetTrackVolume = vi.fn()
362
+ render(<TrackGutter project={project()} {...wired} onSetTrackVolume={onSetTrackVolume} />)
363
+ fireEvent.click(screen.getByRole('button', { name: 'Video track settings' }))
364
+ const slider = screen.getByLabelText('Video volume')
365
+
366
+ fireEvent.change(slider, { target: { value: '0.5' } })
367
+ expect(onSetTrackVolume).toHaveBeenLastCalledWith(0, 0.5, false)
368
+
369
+ fireEvent.pointerUp(slider)
370
+ expect(onSetTrackVolume).toHaveBeenLastCalledWith(0, 0.5, true)
371
+ })
372
+ })
@@ -0,0 +1,273 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Coverage for the canvas test helpers themselves (`_canvasSelect.ts`).
4
+ *
5
+ * These helpers are about to become the way every migrated DOM-era test
6
+ * addresses a clip, so a silent mis-aim in here would show up downstream as
7
+ * "the assertion is wrong" rather than "the click missed". Each thing the
8
+ * helpers promise is therefore checked against the REAL mounted timeline, not
9
+ * against a restatement of the same arithmetic:
10
+ *
11
+ * - the pinned surface rect is what the mounted surface actually reports;
12
+ * - the fit viewport the helpers compute is the one the surface settled on
13
+ * (proved by seeking: a press at `timeToClientX(t)` lands the clock on `t`);
14
+ * - a press selects the item the caller named, by kind and by id;
15
+ * - `metaKey` extends rather than replaces;
16
+ * - a drag is a drag (many live edits, ONE commit) and a click is a click.
17
+ */
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
19
+ import { render, cleanup, fireEvent, act } from '@testing-library/react'
20
+ import type { Project } from '../../../types'
21
+ import { createPlaybackClock } from '../../playback-clock'
22
+ import Timeline from '../Timeline'
23
+ import { computeDerivedTiming, trackItems } from '../timeline-model'
24
+ import { PLAYHEAD_GRAB_PX } from '../canvas/hit-test'
25
+ import { fitPxPerSecond } from '../canvas/viewport'
26
+ import {
27
+ SURFACE_LEFT,
28
+ SURFACE_WIDTH,
29
+ canvasItemPoint,
30
+ canvasSurface,
31
+ canvasViewport,
32
+ dragCanvasItem,
33
+ focusCanvasRoot,
34
+ installCanvasHarness,
35
+ resolveCanvasTarget,
36
+ selectCanvasItem,
37
+ timeToClientX,
38
+ } from './_canvasSelect'
39
+
40
+ let uninstall: () => void
41
+
42
+ beforeEach(() => { uninstall = installCanvasHarness() })
43
+ afterEach(() => { cleanup(); uninstall() })
44
+
45
+ /** The same two-track shape `VideoEditor.keymap.test.tsx` uses, so what is
46
+ * proved here is what the migrated tests rely on. Content runs 0–4s, so
47
+ * `totalDuration` is 4 + max(5, 0.8) = 9s — the number the fit is against. */
48
+ function makeProject(): Project {
49
+ return {
50
+ id: 'p1',
51
+ status: 'draft',
52
+ settings: { resolution: [1080, 1920], fps: 30 },
53
+ tracks: [
54
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
55
+ [{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' } }],
56
+ ],
57
+ } as unknown as Project
58
+ }
59
+
60
+ function mount(project: Project, selectedIds: string[] = [], clockAt = 0) {
61
+ const clock = createPlaybackClock(clockAt)
62
+ const onSelectIds = vi.fn()
63
+ const onProjectChange = vi.fn()
64
+ const onOverlayEdit = vi.fn()
65
+ const utils = render(
66
+ <Timeline
67
+ project={project}
68
+ clock={clock}
69
+ selectedIds={selectedIds}
70
+ onSelectIds={onSelectIds}
71
+ onProjectChange={onProjectChange}
72
+ onOverlayEdit={onOverlayEdit}
73
+ />,
74
+ )
75
+ return { clock, onSelectIds, onProjectChange, onOverlayEdit, ...utils }
76
+ }
77
+
78
+ /** A bare press/release at a page point, bypassing the selector plumbing —
79
+ * used only to prove the coordinate math against the surface's own viewport. */
80
+ function pressAndRelease(surface: HTMLElement, clientX: number, clientY: number) {
81
+ fireEvent.mouseDown(surface, { clientX, clientY, button: 0, bubbles: true })
82
+ act(() => { document.dispatchEvent(new MouseEvent('mouseup', { clientX, clientY, bubbles: true })) })
83
+ }
84
+
85
+ describe('_canvasSelect — harness', () => {
86
+ it('pins the canvas surface to a real rect and leaves everything else spanning the column', () => {
87
+ const { container } = mount(makeProject())
88
+ const surface = canvasSurface(container)
89
+
90
+ expect(surface.getBoundingClientRect().left).toBe(SURFACE_LEFT)
91
+ expect(surface.getBoundingClientRect().width).toBe(SURFACE_WIDTH)
92
+ // Anything that isn't the surface starts at the left of the column.
93
+ expect((container.firstElementChild as HTMLElement).getBoundingClientRect().left).toBe(0)
94
+ })
95
+
96
+ it('restores the real getContext / getBoundingClientRect on teardown', () => {
97
+ const beforeCtx = HTMLCanvasElement.prototype.getContext
98
+ const beforeRect = Element.prototype.getBoundingClientRect
99
+
100
+ const teardown = installCanvasHarness()
101
+ expect(HTMLCanvasElement.prototype.getContext).not.toBe(beforeCtx)
102
+ expect(Element.prototype.getBoundingClientRect).not.toBe(beforeRect)
103
+
104
+ teardown()
105
+ expect(HTMLCanvasElement.prototype.getContext).toBe(beforeCtx)
106
+ expect(Element.prototype.getBoundingClientRect).toBe(beforeRect)
107
+ })
108
+
109
+ // The DOM timeline is retired, so a mounted `<Timeline>` ALWAYS has a canvas
110
+ // surface — the only way to miss it now is aiming the helper at a container
111
+ // that holds no timeline, which is what this guard is left to catch.
112
+ it('canvasSurface says what is wrong when the container holds no timeline', () => {
113
+ const { container } = render(<div />)
114
+ expect(() => canvasSurface(container)).toThrow(/data-timeline-canvas/)
115
+ })
116
+
117
+ it('focusCanvasRoot focuses the tabIndex=0 root the Delete/Enter bindings are scoped to', () => {
118
+ const { container } = mount(makeProject())
119
+ focusCanvasRoot(container)
120
+ expect(document.activeElement).toBe(container.querySelector('[tabindex="0"]'))
121
+ })
122
+ })
123
+
124
+ describe('_canvasSelect — coordinates', () => {
125
+ it('fits the whole timeline INCLUDING drag headroom, not just the content', () => {
126
+ const project = makeProject()
127
+ const { contentDuration, totalDuration } = computeDerivedTiming(project)
128
+
129
+ expect(contentDuration).toBe(4)
130
+ expect(totalDuration).toBe(9)
131
+ // The scale is 1000px / 9s, NOT 1000px / 4s — the fit is against the
132
+ // padded duration. Anyone hardcoding a round px/second is wrong.
133
+ expect(canvasViewport(project).pxPerSecond).toBe(fitPxPerSecond(SURFACE_WIDTH, totalDuration))
134
+ expect(canvasViewport(project).pxPerSecond).toBeCloseTo(1000 / 9, 10)
135
+ })
136
+
137
+ it('maps t=0 to the surface origin and the full duration to its right edge', () => {
138
+ const project = makeProject()
139
+ const { totalDuration } = computeDerivedTiming(project)
140
+ expect(timeToClientX(project, 0)).toBe(SURFACE_LEFT)
141
+ expect(timeToClientX(project, totalDuration)).toBeCloseTo(SURFACE_LEFT + SURFACE_WIDTH, 10)
142
+ })
143
+
144
+ it('agrees with the viewport the MOUNTED surface actually settled on', () => {
145
+ // The real proof: press on empty track area at the x the helper computes
146
+ // for t=6 and the surface's own `xToTime` must land the playhead there. If
147
+ // the helper's fit differed from the mounted one by so much as a percent,
148
+ // this seek would miss.
149
+ const project = makeProject()
150
+ const { container, clock } = mount(project)
151
+ const surface = canvasSurface(container)
152
+ const videoRow = resolveCanvasTarget(project, { type: 'video' })
153
+
154
+ pressAndRelease(surface, timeToClientX(project, 6), videoRow.y + videoRow.height / 2)
155
+ expect(clock.get()).toBeCloseTo(6, 6)
156
+ })
157
+
158
+ it('resolves a target by kind and by id, and names the ids it knows when it cannot', () => {
159
+ const project = makeProject()
160
+ expect(resolveCanvasTarget(project, { type: 'video' }).id).toBe('clip-0')
161
+ expect(resolveCanvasTarget(project, { type: 'overlay' }).id).toBe('overlay-1')
162
+ expect(resolveCanvasTarget(project, { id: 'overlay-1' }).start).toBe(0)
163
+ expect(() => resolveCanvasTarget(project, { id: 'nope' })).toThrow(/clip-0/)
164
+ })
165
+
166
+ it('dodges the playhead grab band so a press on a clip selects instead of scrubbing', () => {
167
+ const project = makeProject()
168
+ const centred = canvasItemPoint(project, { type: 'video' })
169
+ // Park the playhead exactly on the clip's middle — where the press would
170
+ // otherwise land, and where `grabsPlayhead` would turn it into a scrub.
171
+ const dodged = canvasItemPoint(project, { type: 'video' }, { playheadTime: 2 })
172
+ expect(centred.clientX).toBeCloseTo(timeToClientX(project, 2), 6)
173
+ expect(dodged.clientX - centred.clientX).toBeCloseTo(PLAYHEAD_GRAB_PX + 1, 6)
174
+ })
175
+
176
+ it('honours an explicit `at` time instead of the item middle', () => {
177
+ const project = makeProject()
178
+ const point = canvasItemPoint(project, { type: 'video' }, { at: 1 })
179
+ expect(point.clientX).toBeCloseTo(timeToClientX(project, 1), 6)
180
+ })
181
+ })
182
+
183
+ describe('_canvasSelect — selectCanvasItem', () => {
184
+ it('selects the first item of a kind — the canvas equivalent of clicking "▪ video"', () => {
185
+ const project = makeProject()
186
+ const { container, onSelectIds } = mount(project)
187
+ selectCanvasItem(container, project, { type: 'video' })
188
+ expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
189
+ })
190
+
191
+ it('selects by id', () => {
192
+ const project = makeProject()
193
+ const { container, onSelectIds } = mount(project)
194
+ selectCanvasItem(container, project, { id: 'overlay-1' })
195
+ expect(onSelectIds).toHaveBeenCalledWith(['overlay-1'])
196
+ })
197
+
198
+ it('replaces the selection on a plain click', () => {
199
+ const project = makeProject()
200
+ const { container, onSelectIds } = mount(project, ['overlay-1'])
201
+ selectCanvasItem(container, project, { type: 'video' })
202
+ expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
203
+ })
204
+
205
+ it('EXTENDS the selection with metaKey — the modifier the DOM helper used', () => {
206
+ const project = makeProject()
207
+ const { container, onSelectIds } = mount(project, ['clip-0'])
208
+ selectCanvasItem(container, project, { type: 'overlay' }, { metaKey: true })
209
+ expect(onSelectIds).toHaveBeenCalledWith(['clip-0', 'overlay-1'])
210
+ })
211
+
212
+ it('still selects when the playhead is parked on the clip it is aiming at', () => {
213
+ const project = makeProject()
214
+ const { container, onSelectIds } = mount(project, [], 2)
215
+ selectCanvasItem(container, project, { type: 'video' }, { playheadTime: 2 })
216
+ expect(onSelectIds).toHaveBeenCalledWith(['clip-0'])
217
+ })
218
+
219
+ it('is a CLICK, not a drag: it never commits an edit', () => {
220
+ const project = makeProject()
221
+ const { container, onProjectChange, onOverlayEdit } = mount(project)
222
+ selectCanvasItem(container, project, { type: 'video' })
223
+ expect(onProjectChange).not.toHaveBeenCalled()
224
+ expect(onOverlayEdit).not.toHaveBeenCalled()
225
+ })
226
+ })
227
+
228
+ describe('_canvasSelect — dragCanvasItem', () => {
229
+ it('is a DRAG: many live edits, exactly ONE commit', () => {
230
+ const project = makeProject()
231
+ const { container, onProjectChange, onOverlayEdit } = mount(project)
232
+
233
+ dragCanvasItem(container, project, { type: 'video' }, { dxPx: 200, steps: 5 })
234
+
235
+ // One live edit per intermediate move — the property the "a whole drag
236
+ // undoes in one step" regression test depends on.
237
+ expect(onProjectChange.mock.calls.length).toBeGreaterThan(1)
238
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
239
+
240
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
241
+ const moved = trackItems(committed).flat().find((i) => i.id === 'clip-0')
242
+ expect(moved?.start).toBeGreaterThan(0)
243
+ })
244
+
245
+ it('lands the item near the requested time', () => {
246
+ const project = makeProject()
247
+ const { container, onOverlayEdit } = mount(project)
248
+
249
+ // The clip's body point sits at its middle (t=2); asking to release two
250
+ // seconds later moves the whole clip two seconds later.
251
+ dragCanvasItem(container, project, { type: 'video' }, { toTime: 4 })
252
+
253
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
254
+ const moved = trackItems(committed).flat().find((i) => i.id === 'clip-0')
255
+ expect(moved?.start).toBeCloseTo(2, 1)
256
+ })
257
+
258
+ it('refuses travel that would never cross the drag threshold', () => {
259
+ const project = makeProject()
260
+ const { container, onOverlayEdit } = mount(project)
261
+ expect(() => dragCanvasItem(container, project, { type: 'video' }, { dxPx: 2 }))
262
+ .toThrow(/DRAG_THRESHOLD_PX/)
263
+ // Nothing was dispatched — it bailed before the press.
264
+ expect(onOverlayEdit).not.toHaveBeenCalled()
265
+ })
266
+
267
+ it('needs a destination', () => {
268
+ const project = makeProject()
269
+ const { container } = mount(project)
270
+ expect(() => dragCanvasItem(container, project, { type: 'video' }, {}))
271
+ .toThrow(/toTime.*dxPx/)
272
+ })
273
+ })