@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,253 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The keyframe-strip popup (SP9b T3.3) — `Timeline.fadeCurveMenu.test.tsx`'s
4
+ * exact sibling. A right-click on a keyframe-strip diamond (selected,
5
+ * keyframed overlay only — see hit-test.ts's `'keyframe'` kind) opens a small
6
+ * menu of the six `EASING_NAMES` plus a "Remove keyframe" action; picking an
7
+ * easing or removing applies to EVERY prop the diamond represents (the
8
+ * "union of times" the strip draws — see `keyframe-strip.ts`), as ONE undo
9
+ * entry.
10
+ *
11
+ * `TimelineCanvas`'s `contextmenu` handler does its own hit-test and calls
12
+ * `onKeyframeMenu` with CLIENT coordinates; `Timeline` owns the menu's
13
+ * open/closed state and renders it as a `position: fixed` DOM overlay —
14
+ * mirrors `Timeline.fadeCurveMenu.test.tsx`'s own stubbing (`installCanvasHarness`,
15
+ * see `_canvasSelect.ts`).
16
+ */
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
+ import { render, cleanup, fireEvent, screen } from '@testing-library/react'
19
+ import type { Project } from '../../../types'
20
+ import { createPlaybackClock } from '../../playback-clock'
21
+ import Timeline from '../Timeline'
22
+ import { computeTimelineLayout } from '../canvas/draw'
23
+ import { installCanvasHarness, SURFACE_LEFT, timeToClientX } from './_canvasSelect'
24
+
25
+ let uninstall: () => void
26
+
27
+ beforeEach(() => {
28
+ uninstall = installCanvasHarness()
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ uninstall()
34
+ })
35
+
36
+ /** o0 (2s-4s, overlay) keyframed on offsetX at t=0.5 AND t=1.5, and on
37
+ * opacity at t=0.5 only. Union of times is {0.5, 1.5} (`keyframe-strip.ts`'s
38
+ * merged strip): the diamond at t=0.5 represents BOTH props (used to prove
39
+ * an action fans out to every prop sharing an instant), the diamond at
40
+ * t=1.5 represents offsetX alone AND is the item's LAST keyframe (used for
41
+ * the disabled-easing-picker test). */
42
+ function makeProject(): Project {
43
+ return {
44
+ id: 'p1',
45
+ status: 'draft',
46
+ settings: { resolution: [1080, 1920], fps: 30 },
47
+ tracks: [{
48
+ id: 'trk-0',
49
+ items: [{
50
+ id: 'o0', type: 'overlay', start: 2, end: 4,
51
+ keyframes: [
52
+ { prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
53
+ { prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
54
+ ],
55
+ }],
56
+ }],
57
+ } as unknown as Project
58
+ }
59
+
60
+ function mount(project: Project = makeProject()) {
61
+ const clock = createPlaybackClock()
62
+ const onProjectChange = vi.fn()
63
+ const onOverlayEdit = vi.fn()
64
+ const utils = render(
65
+ <Timeline
66
+ project={project}
67
+ clock={clock}
68
+ onProjectChange={onProjectChange}
69
+ onOverlayEdit={onOverlayEdit}
70
+ selectedIds={['o0']}
71
+ />,
72
+ )
73
+ const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
74
+ return { clock, onProjectChange, onOverlayEdit, surface, project, ...utils }
75
+ }
76
+
77
+ /** CLIENT (x, y) for the diamond at item-relative `t` on `project`'s o0,
78
+ * computed the same way `hit-test.ts`'s keyframe zone does: `item.start + t`
79
+ * through `timeToClientX` — the SAME fit-to-view viewport the mounted
80
+ * surface settles to on render — at a y a couple px above the row's own
81
+ * bottom edge (inside the bottom strip zone the diamond hit-tests in). */
82
+ function keyframeClientPoint(project: Project, t: number) {
83
+ const row = computeTimelineLayout(project).rows[0]
84
+ const clientX = timeToClientX(project, 2 + t) // o0.start is 2
85
+ return { clientX, clientY: row.y + row.height - 2 }
86
+ }
87
+
88
+ /** Easing buttons render VISIBLE text (unlike the icon-only fade picker), so
89
+ * their accessible name is that text — same `getByRole` lookup either way. */
90
+ function easingButton(name: string) {
91
+ return screen.getByRole('button', { name })
92
+ }
93
+ function queryEasingButton(name: string) {
94
+ return screen.queryByRole('button', { name })
95
+ }
96
+ function removeButton() {
97
+ return screen.getByRole('button', { name: 'Remove keyframe' })
98
+ }
99
+
100
+ describe('Timeline — keyframe-strip popup (right-click a keyframe diamond)', () => {
101
+ it('opens the picker with all six easing options and a remove action on a diamond right-click', () => {
102
+ const { surface, project } = mount()
103
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
104
+ for (const name of ['Linear', 'Ease', 'Ease In', 'Ease Out', 'Ease In Out', 'Hold']) {
105
+ expect(easingButton(name)).toBeInTheDocument()
106
+ }
107
+ expect(removeButton()).toBeInTheDocument()
108
+ })
109
+
110
+ it('does NOT open the picker from a right-click elsewhere on the clip', () => {
111
+ const { surface, project } = mount()
112
+ // Same x as a diamond, but well above the strip's bottom zone.
113
+ const row = computeTimelineLayout(project).rows[0]
114
+ fireEvent.contextMenu(surface, { clientX: SURFACE_LEFT + 300, clientY: row.y + 2 })
115
+ expect(queryEasingButton('Linear')).not.toBeInTheDocument()
116
+ })
117
+
118
+ it('picking an easing applies it to every prop sharing the diamond’s t, as ONE undo entry, and closes the menu', () => {
119
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
120
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
121
+
122
+ fireEvent.click(easingButton('Ease In'))
123
+
124
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
125
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
126
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
127
+ const item = committed.tracks![0].items[0]
128
+ const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
129
+ const opacity = item.keyframes!.find(t => t.prop === 'opacity')!
130
+ expect(offsetX.points.find(p => p.t === 0.5)?.easing).toBe('ease-in')
131
+ expect(opacity.points.find(p => p.t === 0.5)?.easing).toBe('ease-in')
132
+ // The OTHER keyframe on offsetX (t=1.5) is untouched.
133
+ expect(offsetX.points.find(p => p.t === 1.5)?.easing).toBeUndefined()
134
+ expect(queryEasingButton('Linear')).not.toBeInTheDocument()
135
+ })
136
+
137
+ it('removing clears every prop at that t, dropping a track entirely once it has no points left', () => {
138
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
139
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
140
+
141
+ fireEvent.click(removeButton())
142
+
143
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
144
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
145
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
146
+ const item = committed.tracks![0].items[0]
147
+ // offsetX keeps its OTHER point (t=1.5); its t=0.5 point is gone.
148
+ const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
149
+ expect(offsetX.points.map(p => p.t)).toEqual([1.5])
150
+ // opacity had ONLY the t=0.5 point — the whole track is gone, not just
151
+ // emptied (see keyframeOps.ts's `removeKeyframe`/`withTrack`).
152
+ expect(item.keyframes!.find(t => t.prop === 'opacity')).toBeUndefined()
153
+ expect(queryEasingButton('Linear')).not.toBeInTheDocument()
154
+ })
155
+
156
+ it('disables the easing picker on the LAST diamond, but removal still works there', () => {
157
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
158
+ // t=1.5 is offsetX's last (and only remaining) keyframe once t=0.5 is
159
+ // accounted for by the fixture — the item's overall last keyframe too.
160
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 1.5))
161
+
162
+ expect(easingButton('Linear')).toBeDisabled()
163
+ expect(easingButton('Hold')).toBeDisabled()
164
+ fireEvent.click(easingButton('Linear'))
165
+ // Disabled buttons don't fire clicks — nothing committed.
166
+ expect(onProjectChange).not.toHaveBeenCalled()
167
+
168
+ fireEvent.click(removeButton())
169
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
170
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
171
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
172
+ const item = committed.tracks![0].items[0]
173
+ const offsetX = item.keyframes!.find(t => t.prop === 'offsetX')!
174
+ expect(offsetX.points.map(p => p.t)).toEqual([0.5])
175
+ })
176
+
177
+ it('highlights the shared easing when every prop at t agrees, and none when they disagree', () => {
178
+ const project = makeProject()
179
+ // Pre-set offsetX and opacity's t=0.5 points to the SAME easing.
180
+ project.tracks![0].items[0].keyframes![0].points[0].easing = 'hold'
181
+ project.tracks![0].items[0].keyframes![1].points[0].easing = 'hold'
182
+ const { surface } = mount(project)
183
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
184
+ expect(easingButton('Hold')).toHaveAttribute('aria-pressed', 'true')
185
+ expect(easingButton('Linear')).toHaveAttribute('aria-pressed', 'false')
186
+
187
+ fireEvent.click(screen.getByTestId('keyframe-menu-backdrop'))
188
+
189
+ // Now disagree: offsetX stays 'hold', opacity becomes 'ease'.
190
+ const disagreeing = makeProject()
191
+ disagreeing.tracks![0].items[0].keyframes![0].points[0].easing = 'hold'
192
+ disagreeing.tracks![0].items[0].keyframes![1].points[0].easing = 'ease'
193
+ const remount = mount(disagreeing)
194
+ fireEvent.contextMenu(remount.surface, keyframeClientPoint(disagreeing, 0.5))
195
+ for (const name of ['Linear', 'Ease', 'Ease In', 'Ease Out', 'Ease In Out', 'Hold']) {
196
+ expect(easingButton(name)).toHaveAttribute('aria-pressed', 'false')
197
+ }
198
+ })
199
+
200
+ it('Escape closes the picker without committing anything', () => {
201
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
202
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
203
+ expect(easingButton('Linear')).toBeInTheDocument()
204
+
205
+ fireEvent.keyDown(document, { key: 'Escape' })
206
+
207
+ expect(queryEasingButton('Linear')).not.toBeInTheDocument()
208
+ expect(onProjectChange).not.toHaveBeenCalled()
209
+ expect(onOverlayEdit).not.toHaveBeenCalled()
210
+ })
211
+
212
+ it('clicking outside the menu closes it without touching the project', () => {
213
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
214
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
215
+ expect(easingButton('Linear')).toBeInTheDocument()
216
+
217
+ fireEvent.click(screen.getByTestId('keyframe-menu-backdrop'))
218
+
219
+ expect(queryEasingButton('Linear')).not.toBeInTheDocument()
220
+ expect(onProjectChange).not.toHaveBeenCalled()
221
+ expect(onOverlayEdit).not.toHaveBeenCalled()
222
+ })
223
+
224
+ it('freezes the value when the removed keyframe was a track\'s LAST point', () => {
225
+ // Plain `removeKeyframe` drops the track WITHOUT writing the sampled value
226
+ // into the static scalar, so the overlay would snap back to the stale
227
+ // `opacity: 0.1`. Both removal paths share `removeKeyframesAt` precisely so
228
+ // this menu and Delete cannot disagree.
229
+ const base = makeProject()
230
+ const stale = {
231
+ ...base,
232
+ tracks: [{
233
+ ...base.tracks![0],
234
+ items: [{
235
+ ...base.tracks![0].items[0],
236
+ opacity: 0.1,
237
+ keyframes: [
238
+ { prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
239
+ { prop: 'opacity', points: [{ t: 0.5, value: 0.4 }] },
240
+ ],
241
+ }],
242
+ }],
243
+ } as unknown as Project
244
+ const { surface, onOverlayEdit, project } = mount(stale)
245
+ fireEvent.contextMenu(surface, keyframeClientPoint(project, 0.5))
246
+
247
+ fireEvent.click(removeButton())
248
+
249
+ const item = (onOverlayEdit.mock.calls[0][0] as Project).tracks![0].items[0]
250
+ expect(item.keyframes!.find(t => t.prop === 'opacity')).toBeUndefined()
251
+ expect(item.opacity).toBeCloseTo(0.4, 5) // the curve's value, NOT the stale 0.1
252
+ })
253
+ })
@@ -0,0 +1,372 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, cleanup, fireEvent, act } from '@testing-library/react'
3
+ import type { Project } from '../../../types'
4
+ import { createPlaybackClock } from '../../playback-clock'
5
+ import Timeline, { type TimelineActions } from '../Timeline'
6
+ import { installCanvasHarness } from './_canvasSelect'
7
+
8
+ // SP5 — the DOM timeline is retired, so every Timeline mount is a canvas one
9
+ // and needs the canvas harness (stubbed getContext/getBoundingClientRect) even
10
+ // though none of these tests click or drag — TimelineCanvas paints on mount.
11
+ let uninstallCanvasHarness: () => void
12
+ beforeEach(() => { uninstallCanvasHarness = installCanvasHarness() })
13
+ afterEach(() => {
14
+ cleanup()
15
+ uninstallCanvasHarness()
16
+ })
17
+
18
+ function makeProject(): Project {
19
+ return {
20
+ id: 'p1',
21
+ status: 'draft',
22
+ settings: { resolution: [1080, 1920], fps: 10 }, // 10fps -> 0.1s frame step, easy to assert
23
+ tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }]],
24
+ } as unknown as Project
25
+ }
26
+
27
+ function makeProjectWithCaptions(): Project {
28
+ return {
29
+ ...makeProject(),
30
+ captions: {
31
+ style: 'clean',
32
+ segments: [
33
+ { id: 'cap-0', text: 'hello', start: 0, end: 1 },
34
+ { id: 'cap-1', text: 'world', start: 1, end: 2 },
35
+ ],
36
+ },
37
+ } as unknown as Project
38
+ }
39
+
40
+ /** Delete/Enter are focus-scoped to Timeline's own root (the `tabIndex={0}`
41
+ * container), mirroring pre-SP5 behavior — unlike arrows/Escape, which stay
42
+ * document-level. Timeline's root is the outermost DOM node it renders
43
+ * (`TimelineContext.Provider` contributes no element of its own), so it's
44
+ * always `container.firstElementChild` in these tests. */
45
+ function focusTimelineRoot(container: HTMLElement) {
46
+ (container.firstElementChild as HTMLElement).focus()
47
+ }
48
+
49
+ describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
50
+ it('ArrowRight steps the clock forward by one frame on a plain target', () => {
51
+ const clock = createPlaybackClock(0)
52
+ render(<Timeline project={makeProject()} clock={clock} />)
53
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }) })
54
+ expect(clock.get()).toBeCloseTo(0.1, 5)
55
+ })
56
+
57
+ // Shift is TEN FRAMES, not one second. At this fixture's 10fps the two
58
+ // happen to coincide at 1.0s, so both directions are asserted at a second
59
+ // fps below where they diverge — otherwise the old wall-clock behaviour
60
+ // would pass this test unchanged.
61
+ it('Shift+ArrowRight steps ten frames forward, and Shift+ArrowLeft ten back', () => {
62
+ const clock = createPlaybackClock(2)
63
+ render(<Timeline project={makeProject()} clock={clock} />)
64
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', shiftKey: true }) })
65
+ expect(clock.get()).toBeCloseTo(3, 5)
66
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', shiftKey: true }) })
67
+ expect(clock.get()).toBeCloseTo(2, 5)
68
+ })
69
+
70
+ it('the shifted step is ten FRAMES, so it scales with fps rather than being a flat second', () => {
71
+ const clock = createPlaybackClock(2)
72
+ const project = makeProject()
73
+ render(<Timeline project={{ ...project, settings: { ...project.settings!, fps: 25 } }} clock={clock} />)
74
+ // 10 frames at 25fps is 0.4s. The old `shiftKey ? 1` would give 3.0.
75
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', shiftKey: true }) })
76
+ expect(clock.get()).toBeCloseTo(2.4, 5)
77
+ })
78
+
79
+ // Content end = the furthest item's end across ALL clips/overlays/audio
80
+ // (`contentDuration` from computeDerivedTiming), not the zoom/scroll
81
+ // headroom-padded `totalDuration`. This fixture's video track ends at 4s,
82
+ // but a later audio track pushes the real content end out to 7s — proving
83
+ // the jump lands on the furthest ELEMENT, not just the longest video clip.
84
+ // totalDuration would pad that out further still (7 + max(5, 7*0.2) = 12),
85
+ // so asserting 7 (not 12, not 4) pins down both distinctions at once.
86
+ function makeProjectWithFurtherAudio(): Project {
87
+ const project = makeProject()
88
+ return {
89
+ ...project,
90
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 5, end: 7, lane: 0 }] },
91
+ } as unknown as Project
92
+ }
93
+
94
+ it('Cmd/Ctrl+ArrowLeft jumps the playhead to the start (0)', () => {
95
+ const clock = createPlaybackClock(3)
96
+ render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
97
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', metaKey: true }) })
98
+ expect(clock.get()).toBe(0)
99
+ })
100
+
101
+ it('Ctrl+ArrowLeft (non-Mac mod) also jumps to the start', () => {
102
+ const clock = createPlaybackClock(3)
103
+ render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
104
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft', ctrlKey: true }) })
105
+ expect(clock.get()).toBe(0)
106
+ })
107
+
108
+ it('Cmd/Ctrl+ArrowRight jumps the playhead to the content end (furthest element, not headroom)', () => {
109
+ const clock = createPlaybackClock(0)
110
+ render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
111
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
112
+ // 7, not 4 (the video clip alone) and not 12 (totalDuration's headroom).
113
+ expect(clock.get()).toBe(7)
114
+ })
115
+
116
+ it('a plain ArrowLeft/ArrowRight still frame-steps and does NOT jump, even with the further-audio fixture', () => {
117
+ const clock = createPlaybackClock(2)
118
+ render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
119
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }) })
120
+ expect(clock.get()).toBeCloseTo(2.1, 5)
121
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) })
122
+ expect(clock.get()).toBeCloseTo(2, 5)
123
+ })
124
+
125
+ it('Cmd+ArrowLeft/Right does not ALSO fire the frame-step binding (no double-action)', () => {
126
+ const clock = createPlaybackClock(3)
127
+ render(<Timeline project={makeProjectWithFurtherAudio()} clock={clock} />)
128
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
129
+ // If frame-step also fired, this would be 7 + 0.1 instead of 7.
130
+ expect(clock.get()).toBe(7)
131
+ })
132
+
133
+ it('Cmd/Ctrl+Arrow jump is inert on an empty project (guard: totalDuration > 0)', () => {
134
+ const clock = createPlaybackClock(0)
135
+ const emptyProject = { ...makeProject(), tracks: [] } as unknown as Project
136
+ render(<Timeline project={emptyProject} clock={clock} />)
137
+ act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight', metaKey: true }) })
138
+ expect(clock.get()).toBe(0)
139
+ })
140
+
141
+ it('does not step when the target is an input', () => {
142
+ const clock = createPlaybackClock(0)
143
+ const { container } = render(
144
+ <div>
145
+ <input data-testid="somewhere-else" />
146
+ <Timeline project={makeProject()} clock={clock} />
147
+ </div>,
148
+ )
149
+ const input = container.querySelector('input') as HTMLInputElement
150
+ fireEvent.keyDown(input, { key: 'ArrowRight' })
151
+ expect(clock.get()).toBe(0)
152
+ })
153
+
154
+ it('does not step when `modalOpen` is true (a host-level dialog is up)', () => {
155
+ const clock = createPlaybackClock(0)
156
+ render(<Timeline project={makeProject()} clock={clock} modalOpen />)
157
+ fireEvent.keyDown(document.body, { key: 'ArrowRight' })
158
+ expect(clock.get()).toBe(0)
159
+ })
160
+
161
+ // Enter and Escape used to place and clear the A/B range markers. That
162
+ // feature is gone, so the timeline binds neither: pressing them with the
163
+ // timeline focused must not move the playhead or touch the project.
164
+ it('Enter and Escape are not bound', () => {
165
+ const clock = createPlaybackClock(2)
166
+ const onProjectChange = vi.fn()
167
+ const { container } = render(
168
+ <Timeline project={makeProject()} clock={clock} onProjectChange={onProjectChange} />,
169
+ )
170
+ focusTimelineRoot(container)
171
+ fireEvent.keyDown(document.body, { key: 'Enter' })
172
+ fireEvent.keyDown(document.body, { key: 'Escape' })
173
+ expect(clock.get()).toBe(2)
174
+ expect(onProjectChange).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('Delete/Backspace two-step delete: fires only with a selection, commits via onProjectChange + onOverlayEdit + clears selection', () => {
178
+ const clock = createPlaybackClock(0)
179
+ const onProjectChange = vi.fn()
180
+ const onOverlayEdit = vi.fn()
181
+ const onSelectIds = vi.fn()
182
+ const { container } = render(
183
+ <Timeline
184
+ project={makeProject()}
185
+ clock={clock}
186
+ selectedIds={['clip-0']}
187
+ onSelectIds={onSelectIds}
188
+ onProjectChange={onProjectChange}
189
+ onOverlayEdit={onOverlayEdit}
190
+ />,
191
+ )
192
+ focusTimelineRoot(container)
193
+ fireEvent.keyDown(document.body, { key: 'Delete' })
194
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
195
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
196
+ expect(onSelectIds).toHaveBeenCalledWith([])
197
+ const updated = onProjectChange.mock.calls[0][0] as Project
198
+ expect(updated.tracks?.[0]?.items.find((i) => i.id === 'clip-0')).toBeUndefined()
199
+ })
200
+
201
+ // D1: a caption id can sit in `selectedIds` alongside a clip id (selected on
202
+ // the canvas timeline like any other item — see Timeline.tsx's
203
+ // handleSelectItem). `deleteSelection` only knows tracks/audio vocabulary, so
204
+ // the keymap action must strip selected caption segments itself, folded into
205
+ // the SAME onProjectChange/onOverlayEdit commit as the clip delete — one undo
206
+ // entry covering the mixed selection, not two.
207
+ it('Delete strips a selected caption segment AND a selected clip in one commit', () => {
208
+ const clock = createPlaybackClock(0)
209
+ const onProjectChange = vi.fn()
210
+ const onOverlayEdit = vi.fn()
211
+ const onSelectIds = vi.fn()
212
+ const { container } = render(
213
+ <Timeline
214
+ project={makeProjectWithCaptions()}
215
+ clock={clock}
216
+ selectedIds={['clip-0', 'cap-0']}
217
+ onSelectIds={onSelectIds}
218
+ onProjectChange={onProjectChange}
219
+ onOverlayEdit={onOverlayEdit}
220
+ />,
221
+ )
222
+ focusTimelineRoot(container)
223
+ fireEvent.keyDown(document.body, { key: 'Delete' })
224
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
225
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
226
+ expect(onSelectIds).toHaveBeenCalledWith([])
227
+ const updated = onProjectChange.mock.calls[0][0] as Project
228
+ expect(updated.tracks?.[0]?.items.find((i) => i.id === 'clip-0')).toBeUndefined()
229
+ expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-1'])
230
+ })
231
+
232
+ // Deleting every remaining caption segment must leave `captions` as an
233
+ // empty-segments object, not null the whole track — nulling it is the
234
+ // sidebar's explicit "Remove all" action, not a side effect of Delete.
235
+ it('Delete leaves an empty captions object when the last segment is removed, never nulls the track', () => {
236
+ const clock = createPlaybackClock(0)
237
+ const onProjectChange = vi.fn()
238
+ const project = makeProjectWithCaptions()
239
+ project.captions!.segments = [{ id: 'cap-0', text: 'hello', start: 0, end: 1 }]
240
+ const { container } = render(
241
+ <Timeline
242
+ project={project}
243
+ clock={clock}
244
+ selectedIds={['cap-0']}
245
+ onSelectIds={vi.fn()}
246
+ onProjectChange={onProjectChange}
247
+ />,
248
+ )
249
+ focusTimelineRoot(container)
250
+ fireEvent.keyDown(document.body, { key: 'Delete' })
251
+ const updated = onProjectChange.mock.calls[0][0] as Project
252
+ expect(updated.captions).not.toBeNull()
253
+ expect(updated.captions?.segments).toEqual([])
254
+ })
255
+
256
+ // Caption rows: emptying one leaves a HOLE lane, and the row has to collapse
257
+ // in the SAME commit as the delete that emptied it — otherwise the operator's
258
+ // Cmd-Z brings the caption back but leaves the row renumbering behind (or
259
+ // undoes it separately, one keystroke later).
260
+ it('Delete collapses and renumbers a caption row it empties, in the same commit', () => {
261
+ const clock = createPlaybackClock(0)
262
+ const onProjectChange = vi.fn()
263
+ const onOverlayEdit = vi.fn()
264
+ const project = {
265
+ ...makeProject(),
266
+ captions: {
267
+ style: 'clean',
268
+ segments: [
269
+ { id: 'cap-0', text: 'ground', start: 0, end: 1 }, // lane 0
270
+ { id: 'cap-1', text: 'middle', start: 0, end: 1, lane: 1 }, // lane 1, alone
271
+ { id: 'cap-2', text: 'top', start: 0, end: 1, lane: 2 }, // lane 2
272
+ ],
273
+ },
274
+ } as unknown as Project
275
+ const { container } = render(
276
+ <Timeline
277
+ project={project}
278
+ clock={clock}
279
+ selectedIds={['cap-1']}
280
+ onSelectIds={vi.fn()}
281
+ onProjectChange={onProjectChange}
282
+ onOverlayEdit={onOverlayEdit}
283
+ />,
284
+ )
285
+ focusTimelineRoot(container)
286
+ fireEvent.keyDown(document.body, { key: 'Delete' })
287
+ // ONE commit — the strip and the renumbering are a single undo entry.
288
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
289
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
290
+ const updated = onProjectChange.mock.calls[0][0] as Project
291
+ expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-0', 'cap-2'])
292
+ // cap-2 was on lane 2 with a hole below it; it drops into the vacated row.
293
+ expect(updated.captions?.segments.map((s) => s.lane ?? 0)).toEqual([0, 1])
294
+ })
295
+
296
+ it('Delete leaves the other rows alone when the row it empties is the TOP one', () => {
297
+ // Removing the highest lane leaves no hole, so nothing renumbers.
298
+ const clock = createPlaybackClock(0)
299
+ const onProjectChange = vi.fn()
300
+ const project = {
301
+ ...makeProject(),
302
+ captions: {
303
+ style: 'clean',
304
+ segments: [
305
+ { id: 'cap-0', text: 'ground', start: 0, end: 1 },
306
+ { id: 'cap-1', text: 'top', start: 0, end: 1, lane: 1 },
307
+ ],
308
+ },
309
+ } as unknown as Project
310
+ const { container } = render(
311
+ <Timeline
312
+ project={project}
313
+ clock={clock}
314
+ selectedIds={['cap-1']}
315
+ onSelectIds={vi.fn()}
316
+ onProjectChange={onProjectChange}
317
+ />,
318
+ )
319
+ focusTimelineRoot(container)
320
+ fireEvent.keyDown(document.body, { key: 'Delete' })
321
+ const updated = onProjectChange.mock.calls[0][0] as Project
322
+ expect(updated.captions?.segments.map((s) => s.id)).toEqual(['cap-0'])
323
+ expect(updated.captions?.segments[0].lane).toBeUndefined()
324
+ })
325
+
326
+ it('Delete does NOT delete the selection when focus is outside the timeline (restored pre-SP5 scoping)', () => {
327
+ const clock = createPlaybackClock(0)
328
+ const onProjectChange = vi.fn()
329
+ render(
330
+ <Timeline
331
+ project={makeProject()}
332
+ clock={clock}
333
+ selectedIds={['clip-0']}
334
+ onProjectChange={onProjectChange}
335
+ />,
336
+ )
337
+ // Focus left on document.body (or anywhere outside Timeline's own root) —
338
+ // never inside the timeline. T9 briefly widened Delete/Enter to fire from
339
+ // anywhere on the page; this proves that regression stays fixed.
340
+ ;(document.body as HTMLElement).focus()
341
+ fireEvent.keyDown(document.body, { key: 'Delete' })
342
+ expect(onProjectChange).not.toHaveBeenCalled()
343
+ })
344
+
345
+ it('Delete with no selection is a no-op', () => {
346
+ const clock = createPlaybackClock(0)
347
+ const onProjectChange = vi.fn()
348
+ render(<Timeline project={makeProject()} clock={clock} onProjectChange={onProjectChange} />)
349
+ fireEvent.keyDown(document.body, { key: 'Delete' })
350
+ expect(onProjectChange).not.toHaveBeenCalled()
351
+ })
352
+
353
+ it('Shift+Delete does NOT trigger Timeline\'s own delete binding (reserved for ripple-delete upstream)', () => {
354
+ const clock = createPlaybackClock(0)
355
+ const onProjectChange = vi.fn()
356
+ render(
357
+ <Timeline project={makeProject()} clock={clock} selectedIds={['clip-0']} onProjectChange={onProjectChange} />,
358
+ )
359
+ fireEvent.keyDown(document.body, { key: 'Delete', shiftKey: true })
360
+ expect(onProjectChange).not.toHaveBeenCalled()
361
+ })
362
+
363
+ it('exposes zoomFit through actionsRef for a host-level palette', () => {
364
+ const clock = createPlaybackClock(1)
365
+ const actionsRef: { current: TimelineActions | null } = { current: null }
366
+ render(<Timeline project={makeProject()} clock={clock} actionsRef={actionsRef} />)
367
+ expect(actionsRef.current).not.toBeNull()
368
+ act(() => { actionsRef.current!.zoomFit() })
369
+ // Smoke test: it doesn't throw, and the ref stays populated.
370
+ expect(actionsRef.current).not.toBeNull()
371
+ })
372
+ })