@bycrux/editor 0.12.0 → 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 (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,606 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — the pointer wiring, and only the wiring.
4
+ *
5
+ * Every gesture DECISION is covered as pure data in `pointer-machine.test.ts`.
6
+ * What can only be shown with a mounted component is here: that browser events
7
+ * reach the machine in surface coordinates, that its effects land on the right
8
+ * callbacks, that the document-level listeners live exactly as long as the
9
+ * gesture, and that the cursor is written without a React render.
10
+ *
11
+ * jsdom has no 2D canvas and lays everything out at 0×0, so both are stubbed —
12
+ * the same harness T4's suite uses.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
15
+ import { render, act, cleanup } from '@testing-library/react'
16
+ import { createPlaybackClock } from '../../../playback-clock'
17
+ import type { Project } from '../../../../types'
18
+ import TimelineCanvas from '../TimelineCanvas'
19
+ import { createViewportStore } from '../viewport'
20
+ import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../draw'
21
+ import { trackItems } from '../../timeline-model'
22
+
23
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
24
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
25
+
26
+ function stubContext() {
27
+ return new Proxy({}, {
28
+ get(_t, prop: string) {
29
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
30
+ return () => {}
31
+ },
32
+ set() { return true },
33
+ })
34
+ }
35
+
36
+ beforeEach(() => {
37
+ realGetContext = HTMLCanvasElement.prototype.getContext
38
+ realGetRect = Element.prototype.getBoundingClientRect
39
+ HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
40
+ Element.prototype.getBoundingClientRect = function (this: Element) {
41
+ return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
42
+ }
43
+ vi.useFakeTimers()
44
+ })
45
+
46
+ afterEach(() => {
47
+ cleanup()
48
+ vi.useRealTimers()
49
+ HTMLCanvasElement.prototype.getContext = realGetContext
50
+ Element.prototype.getBoundingClientRect = realGetRect
51
+ })
52
+
53
+ // One base track (the tall row, starting at y 0) and one audio lane below it.
54
+ // Probes into the track row use small Y's, which stay inside it at any row
55
+ // height; the lane's own Y is derived below, since it moves when rows resize.
56
+ const project = {
57
+ id: 'p',
58
+ tracks: [{
59
+ id: 'trk-0',
60
+ items: [
61
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
62
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 8, inPoint: 0, outPoint: 4, sourceDuration: 20 },
63
+ ],
64
+ }],
65
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5, lane: 0 }] },
66
+ } as unknown as Project
67
+
68
+ /** Vertical centre of the audio lane, taken from the painter's own layout. */
69
+ const LANE_Y = (() => {
70
+ const lane = computeTimelineLayout(project).lanes[0]
71
+ return Math.round(lane.y + lane.height / 2)
72
+ })()
73
+
74
+ /** Vertical centre of the single visual row, likewise from the layout — the
75
+ * ruler strip offsets everything below it, so a hardcoded y aims at the gap. */
76
+ const ROW_Y = (() => {
77
+ const row = computeTimelineLayout(project).rows[0]
78
+ return Math.round(row.y + row.height / 2)
79
+ })()
80
+
81
+ /** Inside the ruler strip, where scrubbing lives. */
82
+ const RULER_Y = (() => {
83
+ const ruler = computeTimelineLayout(project).ruler
84
+ return Math.round(ruler.y + ruler.height / 2)
85
+ })()
86
+
87
+ /** Just inside the TOP of the audio lane's bar — where a fade grip's small
88
+ * hit zone lives (see hit-test.ts's `FADE_GRIP_ZONE_HEIGHT_PX`), a few px
89
+ * below the lane's own top-of-bar y so it's inside that zone rather than
90
+ * sitting exactly on its boundary. */
91
+ const LANE_TOP = (() => {
92
+ const lane = computeTimelineLayout(project).lanes[0]
93
+ return Math.round(lane.y + AUDIO_ITEM_INSET_PX) + 2
94
+ })()
95
+
96
+ const TOTAL_DURATION = 13
97
+ const SNAP_BOUNDARIES = [0, 4, 8, 1, 5]
98
+ const FPS = 30
99
+
100
+ /** The same fixture with a caption band, for the caption-only wiring below.
101
+ * Separate so the row/lane probes above keep the geometry they assume. */
102
+ const captionedProject = {
103
+ ...project,
104
+ captions: { style: 'pop', segments: [{ id: 's0', text: 'hello', start: 1, end: 3 }] },
105
+ } as unknown as Project
106
+
107
+ /** Vertical centre of the caption band, from the painter's own layout. */
108
+ const CAPTION_Y = (() => {
109
+ const band = computeTimelineLayout(captionedProject).captions![0]
110
+ return Math.round(band.y + band.height / 2)
111
+ })()
112
+
113
+ function mount(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
114
+ const store = createViewportStore()
115
+ const clock = createPlaybackClock()
116
+ const handlers = {
117
+ onSelectItem: vi.fn(),
118
+ onSelectItems: vi.fn(),
119
+ onProjectChange: vi.fn(),
120
+ onOverlayEdit: vi.fn(),
121
+ onInspectClip: vi.fn(),
122
+ onInspectAudio: vi.fn(),
123
+ onHoverScrub: vi.fn(),
124
+ }
125
+ const renders = vi.fn()
126
+ function Sibling() { renders(); return null }
127
+
128
+ // Wrapped in a component so a test can flip `previewAxis` after mounting
129
+ // without rebuilding the store, the clock or the spies.
130
+ function Surface({ axis }: { axis?: boolean }) {
131
+ return (
132
+ <>
133
+ <TimelineCanvas
134
+ project={project}
135
+ clock={clock}
136
+ store={store}
137
+ totalDuration={TOTAL_DURATION}
138
+ fps={FPS}
139
+ selectedIds={[]}
140
+ snapBoundaries={SNAP_BOUNDARIES}
141
+ {...handlers}
142
+ {...overrides}
143
+ {...(axis === undefined ? {} : { previewAxis: axis })}
144
+ />
145
+ <Sibling />
146
+ </>
147
+ )
148
+ }
149
+
150
+ const utils = render(<Surface />)
151
+ act(() => { vi.advanceTimersByTime(32) })
152
+ // Pin the scale so the assertions can talk in whole seconds: x = t × 100.
153
+ act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
154
+ act(() => { vi.advanceTimersByTime(32) })
155
+
156
+ return {
157
+ ...utils,
158
+ store,
159
+ clock,
160
+ renders,
161
+ ...handlers,
162
+ rerenderWithAxis: (axis: boolean) => utils.rerender(<Surface axis={axis} />),
163
+ surface: utils.container.querySelector('[data-timeline-canvas]') as HTMLElement,
164
+ }
165
+ }
166
+
167
+ function mouse(type: string, x: number, y: number, init: MouseEventInit = {}) {
168
+ return new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true, button: 0, ...init })
169
+ }
170
+
171
+ describe('TimelineCanvas — pointer wiring', () => {
172
+ it('seeks the clock from a click on empty timeline', () => {
173
+ // On release, not on press: the same press may turn out to be a marquee.
174
+ const { surface, clock } = mount()
175
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
176
+ expect(clock.get()).toBe(0)
177
+ act(() => { document.dispatchEvent(mouse('mouseup', 900, ROW_Y)) })
178
+ expect(clock.get()).toBeCloseTo(9)
179
+ })
180
+
181
+ it('seeks on PRESS from the ruler, and keeps scrubbing', () => {
182
+ const { surface, clock } = mount()
183
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, RULER_Y)) })
184
+ expect(clock.get()).toBeCloseTo(9)
185
+ act(() => { document.dispatchEvent(mouse('mousemove', 600, RULER_Y)) })
186
+ expect(clock.get()).toBeCloseTo(6)
187
+ act(() => { document.dispatchEvent(mouse('mouseup', 600, RULER_Y)) })
188
+ })
189
+
190
+ it('drags a marquee across empty track area and selects what it caught', () => {
191
+ const { surface, onSelectItems } = mount()
192
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
193
+ act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
194
+ act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
195
+ expect(onSelectItems).toHaveBeenCalledTimes(1)
196
+ const [ids, additive] = onSelectItems.mock.calls[0]
197
+ expect(ids).toEqual(expect.arrayContaining(['c1']))
198
+ expect(additive).toBe(false)
199
+ })
200
+
201
+ it('converts client coordinates into surface coordinates', () => {
202
+ // The stubbed rect is at the origin, so shift it and check the seek moves.
203
+ Element.prototype.getBoundingClientRect = function (this: Element) {
204
+ return { x: 200, y: 50, top: 50, left: 200, right: 1200, bottom: 150, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
205
+ }
206
+ const { surface, clock } = mount()
207
+ // Client x=1100 is surface x=900 — empty track area at t=9. The y is the
208
+ // row's own centre pushed down by the stubbed rect's top edge.
209
+ act(() => { surface.dispatchEvent(mouse('mousedown', 1100, 50 + ROW_Y)) })
210
+ act(() => { document.dispatchEvent(mouse('mouseup', 1100, 50 + ROW_Y)) })
211
+ expect(clock.get()).toBeCloseTo(9)
212
+ })
213
+
214
+ it('selects a clip on a click, through the host\'s own handler', () => {
215
+ const { surface, onSelectItem, onProjectChange } = mount()
216
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
217
+ act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y)) })
218
+ expect(onSelectItem).toHaveBeenCalledWith('c0', false)
219
+ expect(onProjectChange).not.toHaveBeenCalled()
220
+ })
221
+
222
+ it('passes the additive modifier through', () => {
223
+ const { surface, onSelectItem } = mount()
224
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y, { shiftKey: true })) })
225
+ act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y, { shiftKey: true })) })
226
+ expect(onSelectItem).toHaveBeenCalledWith('c0', true)
227
+ })
228
+
229
+ it('drives a drag through onProjectChange per move and onOverlayEdit once', () => {
230
+ const { surface, onProjectChange, onOverlayEdit, onSelectItem } = mount()
231
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
232
+ act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
233
+ act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
234
+ act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
235
+
236
+ expect(onProjectChange).toHaveBeenCalledTimes(2)
237
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
238
+ expect(onSelectItem).not.toHaveBeenCalled()
239
+
240
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
241
+ expect(trackItems(committed).flat().find(i => i.id === 'c0')).toMatchObject({ start: 1, end: 5 })
242
+ })
243
+
244
+ it('trims from a press on the out handle', () => {
245
+ const { surface, onProjectChange } = mount()
246
+ // c0 ends at t=4 → x=400; its out handle is 390–400.
247
+ act(() => { surface.dispatchEvent(mouse('mousedown', 396, ROW_Y)) })
248
+ act(() => { document.dispatchEvent(mouse('mousemove', 296, ROW_Y)) })
249
+ act(() => { document.dispatchEvent(mouse('mouseup', 296, ROW_Y)) })
250
+
251
+ const edited = onProjectChange.mock.calls[0][0] as Project
252
+ expect(trackItems(edited).flat().find(i => i.id === 'c0')).toMatchObject({ end: 3, outPoint: 3 })
253
+ })
254
+
255
+ it('does nothing on a double-click over empty timeline', () => {
256
+ const { surface, onProjectChange, onSelectItem, onInspectClip } = mount()
257
+ act(() => { surface.dispatchEvent(mouse('dblclick', 900, ROW_Y)) })
258
+ expect(onProjectChange).not.toHaveBeenCalled()
259
+ expect(onSelectItem).not.toHaveBeenCalled()
260
+ expect(onInspectClip).not.toHaveBeenCalled()
261
+ })
262
+
263
+ it('opens the inspectors on a double-click over an item', () => {
264
+ const { surface, onInspectClip, onInspectAudio } = mount()
265
+ act(() => { surface.dispatchEvent(mouse('dblclick', 200, ROW_Y)) })
266
+ expect(onInspectClip).toHaveBeenCalledWith('c0')
267
+ act(() => { surface.dispatchEvent(mouse('dblclick', 300, LANE_Y)) })
268
+ expect(onInspectAudio).toHaveBeenCalledWith('a0')
269
+ })
270
+
271
+ it('writes the cursor straight to the node, without a React render', () => {
272
+ const { surface, renders } = mount()
273
+ const before = renders.mock.calls.length
274
+
275
+ act(() => { surface.dispatchEvent(mouse('mousemove', 200, ROW_Y)) })
276
+ expect(surface.style.cursor).toBe('grab')
277
+
278
+ act(() => { surface.dispatchEvent(mouse('mousemove', 396, ROW_Y)) })
279
+ expect(surface.style.cursor).toBe('ew-resize')
280
+
281
+ act(() => { surface.dispatchEvent(mouse('mousemove', 900, ROW_Y)) })
282
+ // Empty track area is the plain arrow: nothing to click, only a marquee
283
+ // to drag out.
284
+ expect(surface.style.cursor).toBe('default')
285
+
286
+ act(() => { surface.dispatchEvent(mouse('mousemove', 900, RULER_Y)) })
287
+ expect(surface.style.cursor).toBe('ew-resize')
288
+
289
+ expect(renders.mock.calls.length).toBe(before)
290
+ })
291
+
292
+ it('keeps the document listeners only for the length of a gesture', () => {
293
+ const { surface, clock, onProjectChange } = mount()
294
+
295
+ // No press: page-wide movement is ignored.
296
+ act(() => { document.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
297
+ expect(onProjectChange).not.toHaveBeenCalled()
298
+
299
+ // Driven from the ruler, which is where a drag still moves the clock.
300
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, RULER_Y)) })
301
+ act(() => { document.dispatchEvent(mouse('mousemove', 950, RULER_Y)) })
302
+ expect(clock.get()).toBeCloseTo(9.5)
303
+ act(() => { document.dispatchEvent(mouse('mouseup', 950, RULER_Y)) })
304
+
305
+ // After release, movement over the page no longer scrubs.
306
+ act(() => { document.dispatchEvent(mouse('mousemove', 100, ROW_Y)) })
307
+ expect(clock.get()).toBeCloseTo(9.5)
308
+ })
309
+
310
+ it('ignores non-primary buttons', () => {
311
+ const { surface, clock } = mount()
312
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y, { button: 2 })) })
313
+ expect(clock.get()).toBe(0)
314
+ })
315
+
316
+ it('swallows the click so the DOM chrome above cannot seek a second time', () => {
317
+ // Timeline's own container click seeks by percentage of totalDuration,
318
+ // which is not the canvas' time axis. The press has already seeked.
319
+ const onOuterClick = vi.fn()
320
+ const { surface } = mount()
321
+ document.addEventListener('click', onOuterClick)
322
+ try {
323
+ act(() => { surface.dispatchEvent(mouse('click', 900, 20)) })
324
+ expect(onOuterClick).not.toHaveBeenCalled()
325
+ } finally {
326
+ document.removeEventListener('click', onOuterClick)
327
+ }
328
+ })
329
+
330
+ it('unmounts without leaving document listeners behind', () => {
331
+ const { surface, clock, unmount } = mount()
332
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
333
+ const seeked = clock.get()
334
+ unmount()
335
+ act(() => { document.dispatchEvent(mouse('mousemove', 100, ROW_Y)) })
336
+ expect(clock.get()).toBe(seeked)
337
+ })
338
+
339
+ it('freezes the surface rect for a gesture, so a mid-drag reflow cannot shift its own points', () => {
340
+ // Simulates the real bug: the drag's own `projectChange` echo adds a new
341
+ // track row, which reflows the page and shifts the container's rect
342
+ // (here, up by one row's worth — 44px — via scroll anchoring). A live
343
+ // re-read of the rect on every move would make the SAME physical cursor
344
+ // travel compute a different surface delta partway through the gesture.
345
+ const { surface, onOverlayEdit } = mount()
346
+
347
+ // Rect at press: top 0. Rect for every read after that: top -44. Installed
348
+ // only now, after `mount()` — mounting itself does one rect read (surface
349
+ // setup) that must not consume the "at mousedown" slot below.
350
+ let reads = 0
351
+ Element.prototype.getBoundingClientRect = function (this: Element) {
352
+ reads += 1
353
+ const top = reads === 1 ? 0 : -44
354
+ return { x: 0, y: top, top, left: 0, right: 1000, bottom: top + 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
355
+ }
356
+
357
+ // c0 is on the sole video track (row y 0–56). Press its body, then drag
358
+ // straight up 44px in PAGE coordinates — one row's worth — well past the
359
+ // drag threshold, to a new top track.
360
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
361
+ act(() => { document.dispatchEvent(mouse('mousemove', 200, 4)) })
362
+ act(() => { document.dispatchEvent(mouse('mousemove', 200, -24)) })
363
+ act(() => { document.dispatchEvent(mouse('mouseup', 200, -24)) })
364
+
365
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
366
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
367
+ const tracks = committed.tracks ?? []
368
+ // The true 44px upward travel must land c0 on a NEW track above its
369
+ // source track, not undo the drag back onto the track it started on.
370
+ expect(tracks).toHaveLength(2)
371
+ expect(tracks[0].items.some(i => i.id === 'c0')).toBe(false)
372
+ expect(tracks[1].items.some(i => i.id === 'c0')).toBe(true)
373
+ })
374
+
375
+ it('does nothing when the host supplies no edit callbacks', () => {
376
+ const { surface } = mount({ onProjectChange: undefined, onOverlayEdit: undefined, onSelectItem: undefined })
377
+ expect(() => {
378
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
379
+ act(() => { document.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
380
+ act(() => { document.dispatchEvent(mouse('mouseup', 300, ROW_Y)) })
381
+ }).not.toThrow()
382
+ })
383
+ })
384
+
385
+ // ── Preview axis ─────────────────────────────────────────────────────────
386
+ //
387
+ // Off (the default) this surface behaves exactly as every case above shows —
388
+ // clicks seek, nothing hovers. On, moving the pointer reports the time under it
389
+ // so the host can preview that frame, WITHOUT touching the playback clock.
390
+
391
+ describe('TimelineCanvas — preview axis', () => {
392
+ it('reports nothing on hover while the axis is off', () => {
393
+ const { surface, clock, onHoverScrub } = mount()
394
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
395
+ expect(onHoverScrub).not.toHaveBeenCalled()
396
+ expect(clock.get()).toBe(0)
397
+ })
398
+
399
+ it('reports the hovered time while the axis is on, without moving the clock', () => {
400
+ const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
401
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
402
+ act(() => { vi.advanceTimersByTime(32) })
403
+ expect(onHoverScrub).toHaveBeenCalledWith(5)
404
+ expect(clock.get()).toBe(0)
405
+ })
406
+
407
+ it('tracks the pointer across the surface, one frame at a time', () => {
408
+ const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
409
+ for (const x of [200, 640, 900]) {
410
+ act(() => { surface.dispatchEvent(mouse('mousemove', x, 20)) })
411
+ act(() => { vi.advanceTimersByTime(32) })
412
+ }
413
+ expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([2, 6.4, 9])
414
+ expect(clock.get()).toBe(0)
415
+ })
416
+
417
+ it('coalesces a burst of moves into ONE request, for the latest position', () => {
418
+ // A trackpad sweep delivers far more moves than the display can show, and
419
+ // on long-GOP media each seek cancels the decode still in flight — the
420
+ // reason a fast scrub flashes black. Intermediate positions are dropped,
421
+ // not queued, so the request is always where the pointer is NOW.
422
+ const { surface, onHoverScrub } = mount({ previewAxis: true })
423
+ act(() => {
424
+ for (const x of [100, 200, 300, 400, 500, 600, 700]) {
425
+ surface.dispatchEvent(mouse('mousemove', x, 20))
426
+ }
427
+ })
428
+ act(() => { vi.advanceTimersByTime(32) })
429
+ expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([7])
430
+ })
431
+
432
+ it('draws the cursor line on every move, even the coalesced ones', () => {
433
+ // Only the frame REQUEST is rate-limited. The line itself must stay glued
434
+ // to the pointer or the affordance feels broken.
435
+ const { surface } = mount({ previewAxis: true })
436
+ act(() => { surface.dispatchEvent(mouse('mousemove', 300, ROW_Y)) })
437
+ act(() => { surface.dispatchEvent(mouse('mousemove', 800, ROW_Y)) })
438
+ // No assertion on emissions here — TimelineCanvas.test.tsx owns the paint
439
+ // proof; this pins that a burst never throws or drops the redraw path.
440
+ act(() => { vi.advanceTimersByTime(32) })
441
+ })
442
+
443
+ it('drops a queued request when the pointer leaves before it fires', () => {
444
+ // Otherwise a stale position lands one frame after the release and pins the
445
+ // preview to a frame the pointer has already left.
446
+ const { surface, onHoverScrub } = mount({ previewAxis: true })
447
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
448
+ act(() => { surface.dispatchEvent(mouse('mouseleave', 500, 20)) })
449
+ act(() => { vi.advanceTimersByTime(32) })
450
+ expect(onHoverScrub.mock.calls.map(c => c[0])).toEqual([null])
451
+ })
452
+
453
+ it('hovers over clips too, not just bare track area', () => {
454
+ // The pointer is over clip c0's body (row y 0-56); the preview should follow
455
+ // it there exactly as over empty space.
456
+ const { surface, onHoverScrub } = mount({ previewAxis: true })
457
+ act(() => { surface.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
458
+ act(() => { vi.advanceTimersByTime(32) })
459
+ expect(onHoverScrub).toHaveBeenCalledWith(2.5)
460
+ })
461
+
462
+ it('releases the override when the pointer leaves the surface', () => {
463
+ const { surface, onHoverScrub } = mount({ previewAxis: true })
464
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
465
+ act(() => { vi.advanceTimersByTime(32) })
466
+ onHoverScrub.mockClear()
467
+ act(() => { surface.dispatchEvent(mouse('mouseleave', 500, 20)) })
468
+ expect(onHoverScrub).toHaveBeenCalledWith(null)
469
+ })
470
+
471
+ it('releases the override when a gesture starts, and the click still seeks', () => {
472
+ // A press hands the playhead to the gesture; leaving a hover override up
473
+ // would pin the preview to a frame the drag is moving away from.
474
+ const { surface, clock, onHoverScrub } = mount({ previewAxis: true })
475
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
476
+ act(() => { vi.advanceTimersByTime(32) })
477
+ onHoverScrub.mockClear()
478
+ act(() => { surface.dispatchEvent(mouse('mousedown', 900, ROW_Y)) })
479
+ expect(onHoverScrub).toHaveBeenCalledWith(null)
480
+ // The seek itself lands on release now — empty space defers, because the
481
+ // press may still turn into a marquee.
482
+ act(() => { document.dispatchEvent(mouse('mouseup', 900, ROW_Y)) })
483
+ expect(clock.get()).toBeCloseTo(9)
484
+ })
485
+
486
+ it('clicking a clip seeks with the axis on, exactly as with it off', () => {
487
+ const { surface, clock, onSelectItem } = mount({ previewAxis: true })
488
+ act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
489
+ act(() => { document.dispatchEvent(mouse('mouseup', 200, ROW_Y)) })
490
+ expect(onSelectItem).toHaveBeenCalledWith('c0', false)
491
+ expect(clock.get()).toBeCloseTo(2)
492
+ })
493
+
494
+ it('routes a caption double-click to onEditCaption, not to an inspector', () => {
495
+ const onEditCaption = vi.fn()
496
+ const { surface, onInspectClip, onInspectAudio } = mount({ project: captionedProject, onEditCaption })
497
+ act(() => { surface.dispatchEvent(mouse('dblclick', 200, CAPTION_Y)) })
498
+ expect(onEditCaption).toHaveBeenCalledWith('s0')
499
+ expect(onInspectClip).not.toHaveBeenCalled()
500
+ expect(onInspectAudio).not.toHaveBeenCalled()
501
+ })
502
+
503
+ it('releases the override when the axis is switched off mid-hover', () => {
504
+ // No further mousemove is coming to do it, so the preview would otherwise
505
+ // stay frozen on whatever frame the pointer last rested over.
506
+ const { surface, onHoverScrub, rerenderWithAxis } = mount({ previewAxis: true })
507
+ act(() => { surface.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
508
+ act(() => { vi.advanceTimersByTime(32) })
509
+ onHoverScrub.mockClear()
510
+ act(() => { rerenderWithAxis(false) })
511
+ expect(onHoverScrub).toHaveBeenCalledWith(null)
512
+ })
513
+ })
514
+
515
+ describe('TimelineCanvas — fade-curve context menu', () => {
516
+ it('right-clicking a fade grip calls onFadeCurveMenu with the track, side, and CLIENT coordinates', () => {
517
+ const onFadeCurveMenu = vi.fn()
518
+ const { surface } = mount({ onFadeCurveMenu })
519
+ // a0 runs 1..5s at 100px/s → its fade-in grip sits at its own start
520
+ // corner (x=100) when no fade is set (see hit-test.ts's `audioFadeGripX`).
521
+ act(() => { surface.dispatchEvent(mouse('contextmenu', 100, LANE_TOP)) })
522
+ expect(onFadeCurveMenu).toHaveBeenCalledTimes(1)
523
+ expect(onFadeCurveMenu).toHaveBeenCalledWith({ trackId: 'a0', side: 'in', x: 100, y: LANE_TOP })
524
+ })
525
+
526
+ it('right-clicking the OUT grip reports side "out"', () => {
527
+ const onFadeCurveMenu = vi.fn()
528
+ const { surface } = mount({ onFadeCurveMenu })
529
+ // a0 ends at t=5 → x=500.
530
+ act(() => { surface.dispatchEvent(mouse('contextmenu', 500, LANE_TOP)) })
531
+ expect(onFadeCurveMenu).toHaveBeenCalledWith({ trackId: 'a0', side: 'out', x: 500, y: LANE_TOP })
532
+ })
533
+
534
+ it('prevents the default browser menu only on a fade-grip hit', () => {
535
+ const onFadeCurveMenu = vi.fn()
536
+ const { surface } = mount({ onFadeCurveMenu })
537
+ const onGrip = mouse('contextmenu', 100, LANE_TOP)
538
+ surface.dispatchEvent(onGrip)
539
+ expect(onGrip.defaultPrevented).toBe(true)
540
+ })
541
+
542
+ it('does nothing on a right-click away from a fade grip — no menu, native default untouched', () => {
543
+ const onFadeCurveMenu = vi.fn()
544
+ const { surface } = mount({ onFadeCurveMenu })
545
+ const elsewhere = mouse('contextmenu', 300, LANE_TOP)
546
+ surface.dispatchEvent(elsewhere)
547
+ expect(onFadeCurveMenu).not.toHaveBeenCalled()
548
+ expect(elsewhere.defaultPrevented).toBe(false)
549
+ })
550
+
551
+ it('does nothing when the host implements no onFadeCurveMenu at all', () => {
552
+ const { surface } = mount()
553
+ // Must not throw — a host that predates the picker gets exactly the old
554
+ // browser context menu everywhere on the surface.
555
+ expect(() => { surface.dispatchEvent(mouse('contextmenu', 100, LANE_TOP)) }).not.toThrow()
556
+ })
557
+ })
558
+
559
+ describe('TimelineCanvas — keyframe-strip context menu (SP9b T3.3)', () => {
560
+ // A single overlay track, keyframed on offsetX at t=0.5 only — the strip's
561
+ // one diamond, at x=(2+0.5)×100=250 at the scale `mount` pins.
562
+ const keyframedProject = {
563
+ id: 'p',
564
+ tracks: [{
565
+ id: 'trk-0',
566
+ items: [{
567
+ id: 'o0', type: 'overlay', start: 2, end: 4,
568
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
569
+ }],
570
+ }],
571
+ } as unknown as Project
572
+
573
+ const KF_ROW = computeTimelineLayout(keyframedProject).rows[0]
574
+ const KF_ZONE_Y = Math.round(KF_ROW.y + KF_ROW.height - 2)
575
+ const KF_X = 250
576
+
577
+ it('right-clicking a diamond calls onKeyframeMenu with the item, t, props and CLIENT coordinates', () => {
578
+ const onKeyframeMenu = vi.fn()
579
+ const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'], onKeyframeMenu })
580
+ act(() => { surface.dispatchEvent(mouse('contextmenu', KF_X, KF_ZONE_Y)) })
581
+ expect(onKeyframeMenu).toHaveBeenCalledTimes(1)
582
+ expect(onKeyframeMenu).toHaveBeenCalledWith({ itemId: 'o0', t: 0.5, props: ['offsetX'], isLast: true, x: KF_X, y: KF_ZONE_Y })
583
+ })
584
+
585
+ it('prevents the default browser menu only on a diamond hit', () => {
586
+ const onKeyframeMenu = vi.fn()
587
+ const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'], onKeyframeMenu })
588
+ const onDiamond = mouse('contextmenu', KF_X, KF_ZONE_Y)
589
+ surface.dispatchEvent(onDiamond)
590
+ expect(onDiamond.defaultPrevented).toBe(true)
591
+ })
592
+
593
+ it('does not open when the keyframed overlay is not selected', () => {
594
+ const onKeyframeMenu = vi.fn()
595
+ const { surface } = mount({ project: keyframedProject, selectedIds: [], onKeyframeMenu })
596
+ const elsewhere = mouse('contextmenu', KF_X, KF_ZONE_Y)
597
+ surface.dispatchEvent(elsewhere)
598
+ expect(onKeyframeMenu).not.toHaveBeenCalled()
599
+ expect(elsewhere.defaultPrevented).toBe(false)
600
+ })
601
+
602
+ it('does nothing when the host implements no onKeyframeMenu at all', () => {
603
+ const { surface } = mount({ project: keyframedProject, selectedIds: ['o0'] })
604
+ expect(() => { surface.dispatchEvent(mouse('contextmenu', KF_X, KF_ZONE_Y)) }).not.toThrow()
605
+ })
606
+ })