@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,189 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import LeftPanelTabs, { type LeftPanelTab } from '../LeftPanelTabs'
4
+
5
+ beforeEach(() => window.localStorage.clear())
6
+ afterEach(cleanup)
7
+
8
+ function makeTabs(): LeftPanelTab[] {
9
+ return [
10
+ { id: 'media', icon: <span>M</span>, label: 'Media', content: <div data-testid="media-content">Media panel</div> },
11
+ { id: 'captions', icon: <span>C</span>, label: 'Captions', content: <div data-testid="captions-content">Captions panel</div> },
12
+ { id: 'versions', icon: <span>V</span>, label: 'Versions', content: <div data-testid="versions-content">Versions panel</div> },
13
+ ]
14
+ }
15
+
16
+ describe('LeftPanelTabs', () => {
17
+ it('renders one tab per entry, with its label text', () => {
18
+ render(<LeftPanelTabs tabs={makeTabs()} />)
19
+ const tabs = screen.getAllByRole('tab')
20
+ expect(tabs).toHaveLength(3)
21
+ expect(screen.getByText('Media')).toBeTruthy()
22
+ expect(screen.getByText('Captions')).toBeTruthy()
23
+ expect(screen.getByText('Versions')).toBeTruthy()
24
+ })
25
+
26
+ it('shows the default tab content and does not mount a non-default tab until first activation', () => {
27
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
28
+ expect(screen.getByTestId('captions-content')).toBeTruthy()
29
+ expect(screen.queryByTestId('media-content')).toBeNull()
30
+ expect(screen.queryByTestId('versions-content')).toBeNull()
31
+ })
32
+
33
+ it('clicking a tab shows its content and hides the previous one', () => {
34
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
35
+ fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
36
+
37
+ const mediaPanel = screen.getByTestId('media-content').closest('[role="tabpanel"]') as HTMLElement
38
+ const captionsPanel = screen.getByTestId('captions-content').closest('[role="tabpanel"]') as HTMLElement
39
+ expect(mediaPanel.hidden).toBe(false)
40
+ expect(captionsPanel.hidden).toBe(true)
41
+ })
42
+
43
+ it('keeps a tab mounted after switching away from it (state survives a tab switch)', () => {
44
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
45
+ fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
46
+ // Switch back to captions.
47
+ fireEvent.click(screen.getByRole('tab', { name: 'Captions' }))
48
+
49
+ // Media was mounted when activated; it should still be in the DOM, just hidden.
50
+ const mediaContent = screen.getByTestId('media-content')
51
+ expect(mediaContent).toBeTruthy()
52
+ const mediaPanel = mediaContent.closest('[role="tabpanel"]') as HTMLElement
53
+ expect(mediaPanel.hidden).toBe(true)
54
+ })
55
+
56
+ it('persists the active tab to localStorage under the default key, and restores it on a fresh render', () => {
57
+ const { unmount } = render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
58
+ fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
59
+ expect(window.localStorage.getItem('montaj.editor.leftPanelTab')).toBe(JSON.stringify('media'))
60
+ unmount()
61
+
62
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
63
+ expect(screen.getByTestId('media-content')).toBeTruthy()
64
+ expect(screen.queryByTestId('captions-content')).toBeNull()
65
+ })
66
+
67
+ it('falls back to defaultTabId when the persisted value is not a string', () => {
68
+ // The other half of `revive`'s guard: a stored value of the wrong TYPE
69
+ // (an older key format, a hand-edited entry) must degrade to the default
70
+ // rather than being handed through as an active tab id.
71
+ window.localStorage.setItem('montaj.editor.leftPanelTab', JSON.stringify(3))
72
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="versions" />)
73
+ expect(screen.getByTestId('versions-content')).toBeTruthy()
74
+ })
75
+
76
+ it('hides an inactive panel with inline display:none, not just the hidden attribute', () => {
77
+ // `hidden` alone is a UA-stylesheet `display: none`, which loses to the
78
+ // panel wrapper's own `flex` utility class. The inline style is the line
79
+ // actually doing the hiding, so it gets its own assertion.
80
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
81
+ fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
82
+
83
+ const captionsPanel = screen.getByTestId('captions-content').closest('[role="tabpanel"]') as HTMLElement
84
+ expect(captionsPanel.hidden).toBe(true)
85
+ expect(captionsPanel.style.display).toBe('none')
86
+ })
87
+
88
+ it('falls back to defaultTabId when the persisted id is stale/unknown', () => {
89
+ window.localStorage.setItem('montaj.editor.leftPanelTab', JSON.stringify('some-removed-tab'))
90
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="versions" />)
91
+ expect(screen.getByTestId('versions-content')).toBeTruthy()
92
+ expect(screen.queryByTestId('media-content')).toBeNull()
93
+ expect(screen.queryByTestId('captions-content')).toBeNull()
94
+ })
95
+
96
+ it('falls back to tabs[0] when defaultTabId is absent', () => {
97
+ render(<LeftPanelTabs tabs={makeTabs()} />)
98
+ expect(screen.getByTestId('media-content')).toBeTruthy()
99
+ })
100
+
101
+ it('has aria-selected true on exactly one tab, wired via aria-labelledby/aria-controls', () => {
102
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
103
+ const tabs = screen.getAllByRole('tab')
104
+ const selected = tabs.filter(t => t.getAttribute('aria-selected') === 'true')
105
+ expect(selected).toHaveLength(1)
106
+ expect(selected[0].textContent).toContain('Captions')
107
+
108
+ const panel = screen.getByRole('tabpanel')
109
+ expect(panel.getAttribute('aria-labelledby')).toBe(selected[0].id)
110
+ expect(selected[0].getAttribute('aria-controls')).toBe(panel.id)
111
+ })
112
+
113
+ it('supports an empty tabs array without crashing', () => {
114
+ render(<LeftPanelTabs tabs={[]} />)
115
+ expect(screen.queryAllByRole('tab')).toHaveLength(0)
116
+ expect(screen.queryAllByRole('tabpanel')).toHaveLength(0)
117
+ })
118
+
119
+ describe('keyboard navigation on the rail', () => {
120
+ it('ArrowDown moves to the next tab and wraps at the end', () => {
121
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="media" />)
122
+ const media = screen.getByRole('tab', { name: 'Media' })
123
+ media.focus()
124
+
125
+ fireEvent.keyDown(media, { key: 'ArrowDown' })
126
+ expect(screen.getByTestId('captions-content')).toBeTruthy()
127
+ expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'Captions' }))
128
+
129
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Captions' }), { key: 'ArrowDown' })
130
+ expect(screen.getByTestId('versions-content')).toBeTruthy()
131
+
132
+ // Wraps from the last tab back to the first.
133
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Versions' }), { key: 'ArrowDown' })
134
+ expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'Media' }))
135
+ })
136
+
137
+ it('Home and End jump to the first and last tab', () => {
138
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="captions" />)
139
+ const captions = screen.getByRole('tab', { name: 'Captions' })
140
+
141
+ fireEvent.keyDown(captions, { key: 'End' })
142
+ expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'Versions' }))
143
+
144
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Versions' }), { key: 'Home' })
145
+ expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'Media' }))
146
+ })
147
+ })
148
+
149
+ describe('host-driven activationRequest', () => {
150
+ const shown = (tabId: string) =>
151
+ !(screen.getByTestId(`${tabId}-content`).closest('[role="tabpanel"]') as HTMLElement).hidden
152
+
153
+ it('nonce 0 never forces a tab — the persisted/default preference wins on mount', () => {
154
+ render(<LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'captions', nonce: 0 }} />)
155
+ expect(screen.getByTestId('media-content')).toBeTruthy()
156
+ expect(screen.queryByTestId('captions-content')).toBeNull()
157
+ })
158
+
159
+ it('a positive nonce switches the panel to the requested tab (and mounts it)', () => {
160
+ const { rerender } = render(
161
+ <LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'captions', nonce: 0 }} />,
162
+ )
163
+ expect(screen.getByTestId('media-content')).toBeTruthy()
164
+ rerender(<LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'captions', nonce: 1 }} />)
165
+ expect(shown('captions')).toBe(true)
166
+ })
167
+
168
+ it('re-switches on a fresh nonce even for the same target after the user navigated away (per-event, not per-value)', () => {
169
+ const { rerender } = render(
170
+ <LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'captions', nonce: 1 }} />,
171
+ )
172
+ expect(shown('captions')).toBe(true)
173
+ // User manually clicks away.
174
+ fireEvent.click(screen.getByRole('tab', { name: 'Versions' }))
175
+ expect(shown('versions')).toBe(true)
176
+ // Selecting a caption again bumps the nonce to the SAME id → back to captions.
177
+ rerender(<LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'captions', nonce: 2 }} />)
178
+ expect(shown('captions')).toBe(true)
179
+ })
180
+
181
+ it('ignores an activation for an id that is not a real tab (no crash, no blank pane)', () => {
182
+ const { rerender } = render(
183
+ <LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'ghost', nonce: 0 }} />,
184
+ )
185
+ rerender(<LeftPanelTabs tabs={makeTabs()} defaultTabId="media" activationRequest={{ id: 'ghost', nonce: 1 }} />)
186
+ expect(shown('media')).toBe(true)
187
+ })
188
+ })
189
+ })
@@ -0,0 +1,222 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'
3
+ import type { VisualItem } from '../../../schema'
4
+ import OverlayContentPanel from '../OverlayContentPanel'
5
+
6
+ afterEach(cleanup)
7
+
8
+ function overlayItem(props: Record<string, unknown>): VisualItem {
9
+ return { id: 'overlay-1', type: 'overlay', src: 'scoreboard.jsx', start: 0, end: 4, props }
10
+ }
11
+
12
+ function renderPanel(
13
+ props: Record<string, unknown>,
14
+ extra: { fileUrl?: (p: string) => string; uploadFile?: (f: File) => Promise<string> } = {},
15
+ ) {
16
+ const onPreview = vi.fn()
17
+ const onCommit = vi.fn()
18
+ const utils = render(
19
+ <OverlayContentPanel
20
+ item={overlayItem(props)}
21
+ onPreview={onPreview}
22
+ onCommit={onCommit}
23
+ {...extra}
24
+ />,
25
+ )
26
+ return { ...utils, onPreview, onCommit }
27
+ }
28
+
29
+ describe('OverlayContentPanel — fields inferred from item.props', () => {
30
+ it('renders one control per primitive prop, in the kind its value implies', () => {
31
+ renderPanel({
32
+ homeName: 'Colombia', // text
33
+ homeScore: 2, // number
34
+ accent: '#FCD116', // color (hex)
35
+ badge: 'assets/crest.png', // image (path)
36
+ showClock: true, // boolean
37
+ })
38
+
39
+ expect((screen.getByLabelText('homeName') as HTMLTextAreaElement).tagName).toBe('TEXTAREA')
40
+ expect((screen.getByLabelText('homeScore') as HTMLInputElement).type).toBe('number')
41
+ expect((screen.getByLabelText('accent') as HTMLInputElement).type).toBe('color')
42
+ expect((screen.getByLabelText('showClock') as HTMLInputElement).type).toBe('checkbox')
43
+ // The image field with no `uploadFile` degrades to an editable path box.
44
+ expect((screen.getByLabelText('badge') as HTMLInputElement).type).toBe('text')
45
+ })
46
+
47
+ it('skips non-primitive props but carries them through every write untouched', () => {
48
+ const { onPreview } = renderPanel({
49
+ homeName: 'Colombia',
50
+ players: [{ n: 1 }],
51
+ meta: { round: 'final' },
52
+ })
53
+
54
+ // Not shown: `inferOverlayPropFields` only surfaces primitives.
55
+ expect(screen.queryByLabelText('players')).toBeNull()
56
+ expect(screen.queryByLabelText('meta')).toBeNull()
57
+
58
+ fireEvent.change(screen.getByLabelText('homeName'), { target: { value: 'Argentina' } })
59
+ // The whole props record is spread on the way out, so the skipped values
60
+ // ride through rather than being dropped by the edit.
61
+ expect(onPreview).toHaveBeenLastCalledWith({
62
+ homeName: 'Argentina',
63
+ players: [{ n: 1 }],
64
+ meta: { round: 'final' },
65
+ })
66
+ })
67
+
68
+ it('shows the empty state for an overlay whose props yield no editable fields', () => {
69
+ renderPanel({ players: [{ n: 1 }], meta: null })
70
+ expect(screen.getByText('This overlay has no editable content.')).toBeTruthy()
71
+ })
72
+
73
+ it('shows the empty state for no selection at all', () => {
74
+ render(<OverlayContentPanel item={null} onPreview={vi.fn()} onCommit={vi.fn()} />)
75
+ expect(screen.getByText('Select an overlay to edit its content.')).toBeTruthy()
76
+ })
77
+ })
78
+
79
+ // ── The commit model: preview per change, commit on blur ────────────────────
80
+ // The dialog this panel replaces had a Save button; a panel has no such moment,
81
+ // so a typing gesture is closed by the field's blur and that is what becomes
82
+ // one undo step. These pin BOTH halves: that an in-progress edit previews
83
+ // without committing, and that the blur commits exactly once.
84
+ describe('OverlayContentPanel — preview then commit on blur', () => {
85
+ it('a text edit previews per keystroke and commits once on blur', () => {
86
+ const { onPreview, onCommit } = renderPanel({ homeName: 'Colombia' })
87
+ const field = screen.getByLabelText('homeName')
88
+
89
+ fireEvent.change(field, { target: { value: 'Arg' } })
90
+ fireEvent.change(field, { target: { value: 'Argentina' } })
91
+ expect(onPreview).toHaveBeenCalledTimes(2)
92
+ expect(onPreview).toHaveBeenLastCalledWith({ homeName: 'Argentina' })
93
+ // Still uncommitted — this is the whole point of the transient path.
94
+ expect(onCommit).not.toHaveBeenCalled()
95
+
96
+ fireEvent.blur(field)
97
+ expect(onCommit).toHaveBeenCalledTimes(1)
98
+ })
99
+
100
+ it('a number edit previews as a NUMBER and commits on blur', () => {
101
+ const { onPreview, onCommit } = renderPanel({ homeScore: 2 })
102
+ const field = screen.getByLabelText('homeScore')
103
+
104
+ fireEvent.change(field, { target: { value: '3' } })
105
+ expect(onPreview).toHaveBeenLastCalledWith({ homeScore: 3 })
106
+ fireEvent.blur(field)
107
+ expect(onCommit).toHaveBeenCalledTimes(1)
108
+ })
109
+
110
+ it('a half-typed number is not previewed over the value being replaced', () => {
111
+ const { onPreview } = renderPanel({ homeScore: 2 })
112
+ const field = screen.getByLabelText('homeScore')
113
+
114
+ // Clearing the box is mid-typing, not "set it to zero" — the dialog this
115
+ // replaces coerced '' to 0 and snapped the field back.
116
+ fireEvent.change(field, { target: { value: '' } })
117
+ expect(onPreview).not.toHaveBeenCalled()
118
+ expect((field as HTMLInputElement).value).toBe('')
119
+
120
+ fireEvent.change(field, { target: { value: '12' } })
121
+ expect(onPreview).toHaveBeenLastCalledWith({ homeScore: 12 })
122
+ })
123
+
124
+ it('a blur on an untouched field does not spend a commit', () => {
125
+ const { onPreview, onCommit } = renderPanel({ homeName: 'Colombia' })
126
+ fireEvent.blur(screen.getByLabelText('homeName'))
127
+ expect(onPreview).not.toHaveBeenCalled()
128
+ // `onCommit` reaches sync.commit(), which enqueues a save whether or not a
129
+ // gesture happened — so an untouched blur has to be filtered out here.
130
+ expect(onCommit).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('a color pick previews live and commits when the picker closes', () => {
134
+ const { onPreview, onCommit } = renderPanel({ accent: '#FCD116' })
135
+ const swatch = screen.getByLabelText('accent')
136
+
137
+ fireEvent.change(swatch, { target: { value: '#003893' } })
138
+ expect(onPreview).toHaveBeenLastCalledWith({ accent: '#003893' })
139
+ expect(onCommit).not.toHaveBeenCalled()
140
+
141
+ fireEvent.blur(swatch)
142
+ expect(onCommit).toHaveBeenCalledTimes(1)
143
+ })
144
+
145
+ it('a boolean toggle previews and commits back to back — there is no blur to wait for', () => {
146
+ const { onPreview, onCommit } = renderPanel({ showClock: false })
147
+ fireEvent.click(screen.getByLabelText('showClock'))
148
+ expect(onPreview).toHaveBeenLastCalledWith({ showClock: true })
149
+ expect(onCommit).toHaveBeenCalledTimes(1)
150
+ })
151
+ })
152
+
153
+ describe('OverlayContentPanel — image fields', () => {
154
+ it('resolves the thumbnail through fileUrl and swaps the prop to the uploaded path', async () => {
155
+ const uploadFile = vi.fn(async () => 'assets/uploaded.png')
156
+ const { onPreview, onCommit, container } = renderPanel(
157
+ { badge: 'assets/crest.png' },
158
+ { fileUrl: (p: string) => `/served/${p}`, uploadFile },
159
+ )
160
+
161
+ expect(container.querySelector('img')?.getAttribute('src')).toBe('/served/assets/crest.png')
162
+
163
+ const picker = screen.getByLabelText('badge') as HTMLInputElement
164
+ expect(picker.type).toBe('file')
165
+ fireEvent.change(picker, { target: { files: [new File(['x'], 'crest2.png', { type: 'image/png' })] } })
166
+
167
+ // A finished upload is discrete: preview + commit, no blur in between.
168
+ await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith({ badge: 'assets/uploaded.png' }))
169
+ expect(uploadFile).toHaveBeenCalledTimes(1)
170
+ expect(onCommit).toHaveBeenCalledTimes(1)
171
+ })
172
+
173
+ it('accepts a data: URL as an image prop', () => {
174
+ const tiny = 'data:image/png;base64,iVBORw0KGgo='
175
+ const { container } = renderPanel({ badge: tiny })
176
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(tiny)
177
+ })
178
+
179
+ it('degrades to an editable path field when the host cannot upload', () => {
180
+ const { onPreview, onCommit } = renderPanel({ badge: 'assets/crest.png' })
181
+ const field = screen.getByLabelText('badge')
182
+
183
+ fireEvent.change(field, { target: { value: 'assets/other.png' } })
184
+ expect(onPreview).toHaveBeenLastCalledWith({ badge: 'assets/other.png' })
185
+ expect(onCommit).not.toHaveBeenCalled()
186
+
187
+ fireEvent.blur(field)
188
+ expect(onCommit).toHaveBeenCalledTimes(1)
189
+ })
190
+ })
191
+
192
+ describe('OverlayContentPanel — derived from the item, not stored', () => {
193
+ it('re-derives its fields when the selection changes to a different overlay', () => {
194
+ const onPreview = vi.fn()
195
+ const onCommit = vi.fn()
196
+ const { rerender } = render(
197
+ <OverlayContentPanel item={overlayItem({ homeName: 'Colombia' })} onPreview={onPreview} onCommit={onCommit} />,
198
+ )
199
+ expect(screen.getByLabelText('homeName')).toBeTruthy()
200
+
201
+ // A panel stays mounted while the operator clicks from overlay to overlay,
202
+ // so fields seeded once in a useState initializer (as the retired dialog
203
+ // did) would keep showing the PREVIOUS overlay's props.
204
+ rerender(
205
+ <OverlayContentPanel
206
+ item={{ ...overlayItem({ caption: 'Full time' }), id: 'overlay-2' }}
207
+ onPreview={onPreview}
208
+ onCommit={onCommit}
209
+ />,
210
+ )
211
+ expect(screen.queryByLabelText('homeName')).toBeNull()
212
+ expect(screen.getByLabelText('caption')).toBeTruthy()
213
+ })
214
+
215
+ it('draws no section header of its own — the tab strip above it already says "Content"', () => {
216
+ renderPanel({ homeName: 'Colombia' })
217
+ // A "CONTENT" header under a "Content" tab is the same word twice, and it
218
+ // made `getByText('Content')` ambiguous at the VideoEditor seam.
219
+ expect(screen.queryByText('Content')).toBeNull()
220
+ expect(screen.queryByRole('button', { expanded: true })).toBeNull()
221
+ })
222
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import TabNav, { type TabNavTab } from '../TabNav'
4
+
5
+ afterEach(cleanup)
6
+
7
+ type Tab = 'content' | 'transform' | 'style'
8
+
9
+ function makeTabs(): TabNavTab<Tab>[] {
10
+ return [
11
+ { value: 'content', label: 'Content' },
12
+ { value: 'transform', label: 'Transform' },
13
+ { value: 'style', label: 'Style' },
14
+ ]
15
+ }
16
+
17
+ describe('TabNav', () => {
18
+ it('renders one button per tab, with its label text', () => {
19
+ render(<TabNav tabs={makeTabs()} value="content" onChange={() => {}} ariaLabel="Test panel view" />)
20
+ expect(screen.getAllByRole('button')).toHaveLength(3)
21
+ expect(screen.getByText('Content')).toBeTruthy()
22
+ expect(screen.getByText('Transform')).toBeTruthy()
23
+ expect(screen.getByText('Style')).toBeTruthy()
24
+ })
25
+
26
+ it('marks the active tab aria-pressed=true and the others aria-pressed=false', () => {
27
+ render(<TabNav tabs={makeTabs()} value="transform" onChange={() => {}} ariaLabel="Test panel view" />)
28
+ expect(screen.getByText('Content').getAttribute('aria-pressed')).toBe('false')
29
+ expect(screen.getByText('Transform').getAttribute('aria-pressed')).toBe('true')
30
+ expect(screen.getByText('Style').getAttribute('aria-pressed')).toBe('false')
31
+ })
32
+
33
+ it('calls onChange with the clicked tab value', () => {
34
+ const onChange = vi.fn()
35
+ render(<TabNav tabs={makeTabs()} value="content" onChange={onChange} ariaLabel="Test panel view" />)
36
+ fireEvent.click(screen.getByText('Style'))
37
+ expect(onChange).toHaveBeenCalledWith('style')
38
+ expect(onChange).toHaveBeenCalledTimes(1)
39
+ })
40
+
41
+ it('exposes the given ariaLabel on the group', () => {
42
+ render(<TabNav tabs={makeTabs()} value="content" onChange={() => {}} ariaLabel="Overlay panel view" />)
43
+ expect(screen.getByRole('group', { name: 'Overlay panel view' })).toBeTruthy()
44
+ })
45
+
46
+ it('renders no element with role="tab" or role="tablist"', () => {
47
+ // Pins the deliberate aria-pressed-not-role="tab" decision: a second
48
+ // tablist anywhere in the tree collides with the host's LEFT rail
49
+ // (LeftPanelTabs), which is a real role="tablist".
50
+ render(<TabNav tabs={makeTabs()} value="content" onChange={() => {}} ariaLabel="Test panel view" />)
51
+ expect(screen.queryAllByRole('tab')).toHaveLength(0)
52
+ expect(screen.queryAllByRole('tablist')).toHaveLength(0)
53
+ })
54
+
55
+ it('merges an extra className onto the wrapper alongside the base classes', () => {
56
+ render(
57
+ <TabNav
58
+ tabs={makeTabs()}
59
+ value="content"
60
+ onChange={() => {}}
61
+ ariaLabel="Test panel view"
62
+ className="shrink-0 border-b border-[var(--editor-border)]"
63
+ />,
64
+ )
65
+ const group = screen.getByRole('group', { name: 'Test panel view' })
66
+ expect(group.className).toContain('flex')
67
+ expect(group.className).toContain('items-center')
68
+ expect(group.className).toContain('shrink-0')
69
+ expect(group.className).toContain('border-b')
70
+ })
71
+ })