@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,187 @@
1
+ import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { cn } from '../../ui'
3
+ import { usePersistentState } from '../../ui/usePersistentState'
4
+
5
+ export interface LeftPanelTab {
6
+ /** Stable id, also the persisted value. */
7
+ id: string
8
+ /** Rendered above the label in the rail. Callers pass a lucide icon element. */
9
+ icon: ReactNode
10
+ /** Short rail label, also the tab's accessible name. */
11
+ label: string
12
+ /** Panel body. Lazily mounted (see below). */
13
+ content: ReactNode
14
+ }
15
+
16
+ export interface LeftPanelTabsProps {
17
+ tabs: LeftPanelTab[]
18
+ /** Tab shown when nothing is persisted (or the persisted id is stale).
19
+ * Defaults to `tabs[0].id` when omitted or not found in `tabs`. */
20
+ defaultTabId?: string
21
+ /** localStorage key for the active-tab preference.
22
+ * Default: 'montaj.editor.leftPanelTab'. */
23
+ storageKey?: string
24
+ /** Host-driven tab activation. When `nonce` changes to a positive value, the
25
+ * panel switches to `id` (if it names a real tab). Each activation is a
26
+ * fresh `nonce` so re-selecting the same target still switches — this stays
27
+ * generic (it never learns what a "caption" is): the editor bumps the nonce
28
+ * when a caption is selected so the panel jumps to Captions. `nonce` 0 (the
29
+ * initial value) never forces a tab, so a persisted preference wins on mount. */
30
+ activationRequest?: { id: string; nonce: number }
31
+ className?: string
32
+ }
33
+
34
+ const DEFAULT_STORAGE_KEY = 'montaj.editor.leftPanelTab'
35
+
36
+ /**
37
+ * Generic, presentational tabbed shell for the editor's left browser panel.
38
+ * Knows nothing about media/captions/versions — the host supplies tabs and
39
+ * this component only handles the rail, persistence, and lazy mounting.
40
+ */
41
+ export default function LeftPanelTabs({ tabs, defaultTabId, storageKey = DEFAULT_STORAGE_KEY, activationRequest, className }: LeftPanelTabsProps) {
42
+ const baseId = useId()
43
+ const buttonRefs = useRef(new Map<string, HTMLButtonElement | null>())
44
+
45
+ const resolvedDefaultId =
46
+ defaultTabId !== undefined && tabs.some(t => t.id === defaultTabId) ? defaultTabId : tabs[0]?.id
47
+
48
+ const [activeId, setActiveId] = usePersistentState<string>(
49
+ storageKey,
50
+ resolvedDefaultId ?? '',
51
+ // Reject anything that isn't a string, or that isn't the id of a tab
52
+ // that exists right now — a stale id from an older build (a tab that's
53
+ // since been renamed or removed) falls back to the default rather than
54
+ // rendering a blank pane.
55
+ raw => (typeof raw === 'string' && tabs.some(t => t.id === raw) ? raw : null),
56
+ )
57
+
58
+ // Host-driven activation (see `activationRequest`). Keyed ONLY on the nonce:
59
+ // `tabs` is a fresh array every render, so depending on it would re-fire the
60
+ // effect (and stomp the user's manual tab choice) on every render. When the
61
+ // nonce ticks we read the current `id`/`tabs` from this render's closure,
62
+ // which are up to date, so there is no stale-value risk.
63
+ const activationNonce = activationRequest?.nonce ?? 0
64
+ const activationId = activationRequest?.id
65
+ useEffect(() => {
66
+ if (activationNonce <= 0 || !activationId) return
67
+ if (tabs.some(t => t.id === activationId)) setActiveId(activationId)
68
+ // Intentionally keyed on the nonce alone (see the note above); `tabs`/
69
+ // `setActiveId` are deliberately not deps. `react-hooks/exhaustive-deps`
70
+ // is not enabled in this package's eslint config, so no disable is used.
71
+ }, [activationNonce, activationId])
72
+
73
+ // Stale-tab safety at render time too: `tabs` can change after mount (a
74
+ // host swapping which tabs it offers). If the persisted/active id no
75
+ // longer names a real tab, fall back to the resolved default instead of
76
+ // rendering nothing.
77
+ const currentId = tabs.some(t => t.id === activeId) ? activeId : resolvedDefaultId
78
+
79
+ // Lazy mount, then keep mounted: a tab's `content` isn't rendered into the
80
+ // tree until that tab is first activated (the Media tab can be heavy and
81
+ // shouldn't mount until asked for), but once mounted it stays mounted and
82
+ // is only hidden on switch-away (the Captions tab owns sub-control state —
83
+ // sub-tabs, pickers, scroll position — that must survive a tab switch).
84
+ const [mountedIds, setMountedIds] = useState<Set<string>>(() => new Set(currentId ? [currentId] : []))
85
+ if (currentId && !mountedIds.has(currentId)) {
86
+ setMountedIds(prev => {
87
+ const next = new Set(prev)
88
+ next.add(currentId)
89
+ return next
90
+ })
91
+ }
92
+
93
+ function moveFocus(nextId: string) {
94
+ setActiveId(nextId)
95
+ buttonRefs.current.get(nextId)?.focus()
96
+ }
97
+
98
+ function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, tabId: string) {
99
+ const ids = tabs.map(t => t.id)
100
+ const idx = ids.indexOf(tabId)
101
+ if (idx === -1) return
102
+ let nextIdx: number
103
+ switch (e.key) {
104
+ case 'ArrowDown':
105
+ case 'ArrowRight':
106
+ nextIdx = (idx + 1) % ids.length
107
+ break
108
+ case 'ArrowUp':
109
+ case 'ArrowLeft':
110
+ nextIdx = (idx - 1 + ids.length) % ids.length
111
+ break
112
+ case 'Home':
113
+ nextIdx = 0
114
+ break
115
+ case 'End':
116
+ nextIdx = ids.length - 1
117
+ break
118
+ default:
119
+ return
120
+ }
121
+ e.preventDefault()
122
+ moveFocus(ids[nextIdx])
123
+ }
124
+
125
+ return (
126
+ <div className={cn('flex h-full min-h-0', className)}>
127
+ <div
128
+ role="tablist"
129
+ aria-orientation="vertical"
130
+ className="w-16 shrink-0 flex flex-col overflow-y-auto border-r border-[var(--editor-border)] bg-[var(--editor-bg)]"
131
+ >
132
+ {tabs.map(tab => {
133
+ const selected = tab.id === currentId
134
+ return (
135
+ <button
136
+ key={tab.id}
137
+ ref={el => { buttonRefs.current.set(tab.id, el) }}
138
+ type="button"
139
+ role="tab"
140
+ id={`${baseId}-tab-${tab.id}`}
141
+ aria-selected={selected}
142
+ aria-controls={`${baseId}-panel-${tab.id}`}
143
+ tabIndex={selected ? 0 : -1}
144
+ onClick={() => setActiveId(tab.id)}
145
+ onKeyDown={e => handleKeyDown(e, tab.id)}
146
+ className={cn(
147
+ 'relative flex flex-col items-center gap-1 px-1 py-2.5 text-[10px] transition-colors',
148
+ selected
149
+ // Label is ~10px accent TEXT → the AA-safe accent-text token
150
+ // (indigo-600 in light); the tint fill keeps the plain accent.
151
+ ? 'text-[var(--editor-accent-text)] bg-[var(--editor-accent)]/10'
152
+ : 'text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/5',
153
+ )}
154
+ >
155
+ {selected && (
156
+ <span className="absolute left-0 top-0 bottom-0 w-0.5 bg-[var(--editor-accent)]" aria-hidden="true" />
157
+ )}
158
+ {/* Decorative — the label carries the accessible name. */}
159
+ <span className="text-base leading-none" aria-hidden="true">{tab.icon}</span>
160
+ <span className="leading-tight text-center">{tab.label}</span>
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ <div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
166
+ {tabs
167
+ .filter(tab => mountedIds.has(tab.id))
168
+ .map(tab => {
169
+ const active = tab.id === currentId
170
+ return (
171
+ <div
172
+ key={tab.id}
173
+ role="tabpanel"
174
+ id={`${baseId}-panel-${tab.id}`}
175
+ aria-labelledby={`${baseId}-tab-${tab.id}`}
176
+ hidden={!active}
177
+ style={active ? undefined : { display: 'none' }}
178
+ className="flex-1 min-h-0 flex flex-col overflow-hidden"
179
+ >
180
+ {tab.content}
181
+ </div>
182
+ )
183
+ })}
184
+ </div>
185
+ </div>
186
+ )
187
+ }
@@ -0,0 +1,351 @@
1
+ import { useRef, useState, type ChangeEvent } from 'react'
2
+ import type { VisualItem } from '../../schema'
3
+ import { inferOverlayPropFields, type PropField } from '../preview/overlay-prop-fields'
4
+ import { cn, inspectorInputClass, NumberField, SwatchInput } from '../../ui'
5
+
6
+ /**
7
+ * `<OverlayContentPanel>` — the editor's contextual right-hand **Content**
8
+ * properties panel for the selected overlay: the overlay's own primitive
9
+ * props (its text, colors, numbers, toggles, image paths), as opposed to the
10
+ * transform geometry its sibling `OverlayInspector` edits.
11
+ *
12
+ * These fields used to live in the floating, draggable `OverlayPropsModal`,
13
+ * opened by a preview double-click / a Pencil button in the controls bar. That
14
+ * dialog is retired: this is the Content TAB of the same right column
15
+ * `OverlayInspector` (Transform) and `ClipPropertiesPanel` mount into, and it
16
+ * shares their visual language — one collapsible section, `var(--editor-*)`
17
+ * custom properties, `inspectorInputClass` inputs.
18
+ *
19
+ * ── No schema, by design ─────────────────────────────────────────────────
20
+ * The editable fields are INFERRED from whatever primitives the item's `props`
21
+ * happens to carry (see `inferOverlayPropFields`), which is why an AI-written
22
+ * one-off overlay is as editable as a shipped template. Non-primitive props
23
+ * (arrays, objects, null) are not shown and are never touched: every write
24
+ * spreads the item's whole `props` record, so they ride through untouched.
25
+ *
26
+ * ── Commit model ─────────────────────────────────────────────────────────
27
+ * Continuous edits (typing, dragging the OS color picker) PREVIEW per change
28
+ * and COMMIT ON BLUR — one undo step per typing gesture, matching
29
+ * `OverlayInspector`'s number boxes and `ClipPropertiesPanel`'s `DraftField`.
30
+ * Discrete edits (a checkbox, a finished image upload) have no blur to commit
31
+ * on, so they preview and commit back to back as one user-visible action —
32
+ * the same split those two panels make.
33
+ *
34
+ * The modal this replaces had a Save button and a Cancel that restored a
35
+ * pre-open snapshot. Neither survives: a panel is never "open", so there is no
36
+ * open-time snapshot to revert to and no moment at which a Save could be the
37
+ * thing that persists. Undo is the revert path now, and it works because each
38
+ * committed gesture is exactly one undo step.
39
+ *
40
+ * Props-driven and host-agnostic, like the rest of this package: it never
41
+ * reaches into a store. All persistence flows back through the two callbacks,
42
+ * which the host (VideoEditor) wires to its own sync core.
43
+ */
44
+ export interface OverlayContentPanelProps {
45
+ /** The single selected item, or null. A null item and one with no editable
46
+ * props both render the empty state — the component enforces its own
47
+ * contract rather than trusting the caller to have already filtered. */
48
+ item: VisualItem | null
49
+ /** Live-preview an in-progress edit: no undo entry, no save yet. Carries the
50
+ * item's FULL next `props` record, not a patch — the caller replaces
51
+ * `item.props` wholesale. Mirrors `VideoEditor`'s `previewOverlayProps`. */
52
+ onPreview: (nextProps: Record<string, unknown>) => void
53
+ /** Commit the last previewed edit as one undo step + queued save. Fired on
54
+ * a field's blur, closing the typing gesture. */
55
+ onCommit: () => void
56
+ /** Resolve a workspace path to a servable URL (for image thumbnails). */
57
+ fileUrl?: (path: string) => string
58
+ /** Upload a picked file, returning its new workspace path (for image
59
+ * "change"). Absent on a non-Montaj host — see `ImageField`. */
60
+ uploadFile?: (file: File) => Promise<string>
61
+ /** Editor theme mode — light/dark. Only affects the image field's upload-
62
+ * error text (red-400 is sub-AA on a light `--editor-surface`). Absent ->
63
+ * dark, matching every existing caller. */
64
+ mode?: 'light' | 'dark'
65
+ }
66
+
67
+ const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
68
+ const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
69
+
70
+ /**
71
+ * Image prop control: a thumbnail preview plus a file picker that uploads the
72
+ * chosen file (via the host adapter) and swaps the prop to the returned path.
73
+ * When no `uploadFile` is available (non-Montaj host) it degrades to an
74
+ * editable path text field so the value is still reachable — carried over from
75
+ * `OverlayPropsModal` unchanged, because both halves handle real cases: a
76
+ * workspace path, an http URL, and a `data:image/…` URL all render here.
77
+ *
78
+ * A finished upload is a DISCRETE edit (`onChange`): there is no blur to
79
+ * commit on once the picker has closed.
80
+ */
81
+ function ImageField({
82
+ name,
83
+ value,
84
+ fileUrl,
85
+ uploadFile,
86
+ onChange,
87
+ onInput,
88
+ onCommit,
89
+ mode = 'dark',
90
+ }: {
91
+ name: string
92
+ value: string
93
+ fileUrl?: (path: string) => string
94
+ uploadFile?: (file: File) => Promise<string>
95
+ /** Discrete — a completed upload. */
96
+ onChange: (v: string) => void
97
+ /** Continuous — a keystroke in the degraded path field. */
98
+ onInput: (v: string) => void
99
+ /** Closes the degraded path field's typing gesture. */
100
+ onCommit: () => void
101
+ mode?: 'light' | 'dark'
102
+ }) {
103
+ const [busy, setBusy] = useState(false)
104
+ const [err, setErr] = useState<string | null>(null)
105
+ const preview = value ? (fileUrl ? fileUrl(value) : value) : ''
106
+
107
+ async function onPick(e: ChangeEvent<HTMLInputElement>) {
108
+ const file = e.target.files?.[0]
109
+ e.target.value = '' // let the same file be re-picked later
110
+ if (!file || !uploadFile) return
111
+ setBusy(true)
112
+ setErr(null)
113
+ try {
114
+ onChange(await uploadFile(file))
115
+ } catch (x) {
116
+ setErr(String(x))
117
+ } finally {
118
+ setBusy(false)
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className="flex items-center gap-3">
124
+ {preview ? (
125
+ <img
126
+ src={preview}
127
+ alt={name}
128
+ className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
129
+ />
130
+ ) : (
131
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]/40">
132
+ none
133
+ </div>
134
+ )}
135
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
136
+ <span className="truncate font-mono text-xs text-[var(--editor-text)]/70" title={value}>
137
+ {value ? value.split('/').pop() : '—'}
138
+ </span>
139
+ {uploadFile ? (
140
+ <label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5">
141
+ {busy ? 'Uploading…' : 'Change…'}
142
+ <input
143
+ type="file"
144
+ accept="image/*"
145
+ aria-label={name}
146
+ className="hidden"
147
+ onChange={onPick}
148
+ disabled={busy}
149
+ />
150
+ </label>
151
+ ) : (
152
+ <input
153
+ type="text"
154
+ aria-label={name}
155
+ value={value}
156
+ onChange={e => onInput(e.target.value)}
157
+ onBlur={onCommit}
158
+ onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
159
+ className={cn(inspectorInputClass, 'font-mono')}
160
+ />
161
+ )}
162
+ {err && <span className={`text-[11px] ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{err}</span>}
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ /**
169
+ * One inferred prop, rendered as the control its kind calls for, and the one
170
+ * place this panel's preview/commit split is decided.
171
+ *
172
+ * Split into its own component so it can hold its OWN state — hooks can't live
173
+ * inside a loop, and each field needs an independent "have I been edited"
174
+ * answer. `dirty` is that answer, and it is what stops a blur on an UNTOUCHED
175
+ * field from spending a save: `commit()` reaches `sync.commit()`, which
176
+ * enqueues a save whether or not a transient gesture actually happened. Same
177
+ * reasoning (and the same ref) as `OverlayInspector`'s `ScaleSlider`.
178
+ *
179
+ * `draft` is the fix for the same bug `ClipPropertiesPanel`'s `DraftField`
180
+ * documents: a controlled input bound straight to the incoming value gets its
181
+ * keystrokes clobbered by any re-render arriving mid-edit. It goes non-null
182
+ * the moment the operator types and stays the single source of truth for
183
+ * what's ON SCREEN until blur/Enter clears it; until then the field tracks the
184
+ * previewed value live.
185
+ */
186
+ function PropFieldRow({
187
+ field,
188
+ onPreview,
189
+ onCommit,
190
+ fileUrl,
191
+ uploadFile,
192
+ mode = 'dark',
193
+ }: {
194
+ field: PropField
195
+ onPreview: (value: string | number | boolean) => void
196
+ onCommit: () => void
197
+ fileUrl?: (path: string) => string
198
+ uploadFile?: (file: File) => Promise<string>
199
+ mode?: 'light' | 'dark'
200
+ }) {
201
+ const [draft, setDraft] = useState<string | null>(null)
202
+ const dirty = useRef(false)
203
+
204
+ /** A continuous gesture step — no undo entry yet. */
205
+ function preview(value: string | number | boolean) {
206
+ dirty.current = true
207
+ onPreview(value)
208
+ }
209
+
210
+ /** Close a typing/picking gesture. A no-op on a field nobody touched. */
211
+ function commit() {
212
+ setDraft(null)
213
+ if (!dirty.current) return
214
+ dirty.current = false
215
+ onCommit()
216
+ }
217
+
218
+ /** A discrete, already-final edit — one undo step, no separate blur. */
219
+ function change(value: string | number | boolean) {
220
+ preview(value)
221
+ commit()
222
+ }
223
+
224
+ return (
225
+ <div className="flex flex-col gap-1">
226
+ <span className={FIELD_LABEL_CLASS}>{field.name}</span>
227
+ {field.kind === 'boolean' ? (
228
+ <input
229
+ type="checkbox"
230
+ aria-label={field.name}
231
+ checked={field.value as boolean}
232
+ onChange={e => change(e.target.checked)}
233
+ className="h-4 w-4 accent-[var(--editor-accent)]"
234
+ />
235
+ ) : field.kind === 'number' ? (
236
+ // The shared box (ui/NumberField.tsx) already owns the draft/
237
+ // preview/commit dance this branch used to hand-roll (including
238
+ // rejecting the mid-typing states `previewNumber` used to guard —
239
+ // an empty field, a lone minus). `step={1}` with no min/max: an
240
+ // inferred prop carries no schema (see this file's own doc comment),
241
+ // so there is no natural bound to hand the stepper either.
242
+ //
243
+ // The stepper deliberately does NOT go through `stepValue`, unlike
244
+ // every other adopter of this shared box. `stepValue` rounds to 2
245
+ // decimal places to match what the TRANSFORM panel displays — but
246
+ // this panel neither displays nor stores rounded values, and its
247
+ // props are SCHEMA-LESS floats (see this file's doc comment): an
248
+ // inferred prop sitting at `0.125` would become `1.13` on the very
249
+ // first arrow click and stay wrong forever. With `step: 1` there is
250
+ // no float noise to strip in the first place, so a plain integer
251
+ // nudge is already exact and `stepValue`'s rounding would only
252
+ // throw away precision this panel has no business touching.
253
+ <NumberField
254
+ name={field.name}
255
+ className="w-24"
256
+ step={1}
257
+ value={String(field.value)}
258
+ onPreview={v => preview(v)}
259
+ onCommit={() => commit()}
260
+ onStep={d => change(Number(field.value ?? 0) + d)}
261
+ />
262
+ ) : field.kind === 'color' ? (
263
+ // SwatchInput already splits live picking from the picker closing, so
264
+ // it maps onto preview/commit directly — no draft needed, a color
265
+ // input has no partial value to protect.
266
+ <SwatchInput
267
+ value={String(field.value)}
268
+ ariaLabel={field.name}
269
+ onChange={v => preview(v)}
270
+ onCommit={() => commit()}
271
+ />
272
+ ) : field.kind === 'image' ? (
273
+ <ImageField
274
+ name={field.name}
275
+ value={String(field.value)}
276
+ fileUrl={fileUrl}
277
+ uploadFile={uploadFile}
278
+ onChange={v => change(v)}
279
+ onInput={v => { setDraft(v); preview(v) }}
280
+ onCommit={commit}
281
+ mode={mode}
282
+ />
283
+ ) : (
284
+ <textarea
285
+ aria-label={field.name}
286
+ value={draft ?? String(field.value)}
287
+ rows={1}
288
+ onChange={e => { setDraft(e.target.value); preview(e.target.value) }}
289
+ onBlur={commit}
290
+ className={cn(inspectorInputClass, 'h-auto resize-y')}
291
+ />
292
+ )}
293
+ </div>
294
+ )
295
+ }
296
+
297
+ export default function OverlayContentPanel({
298
+ item,
299
+ onPreview,
300
+ onCommit,
301
+ fileUrl,
302
+ uploadFile,
303
+ mode = 'dark',
304
+ }: OverlayContentPanelProps) {
305
+ // Derived on EVERY render, never held in state. The modal seeded a `draft`
306
+ // array once in a `useState` initializer, which it could get away with
307
+ // because it remounted on every open; a panel that stays mounted while the
308
+ // operator clicks from overlay to overlay would keep showing the PREVIOUS
309
+ // overlay's fields. Same reasoning as OverlayInspector deriving `uniform`
310
+ // from the item rather than storing it.
311
+ const props = item?.props ?? {}
312
+ const fields = inferOverlayPropFields(props)
313
+
314
+ // Deliberately NO collapsible section header, unlike its siblings. The tab
315
+ // strip immediately above this already reads "Content", and a "CONTENT"
316
+ // section header under a "Content" tab is the same word twice in 40px of
317
+ // chrome — it also made `getByText('Content')` ambiguous at the VideoEditor
318
+ // seam. Collapsing is what the OTHER tab is for. The section FRAME stays, so
319
+ // the column's bottom border lines up whichever tab is showing.
320
+ //
321
+ // `OverlayInspector` still draws its own "TRANSFORM" header; folding that one
322
+ // away is a change to a file this task does not own.
323
+ return (
324
+ <div className={SECTION_CLASS}>
325
+ <div className="flex flex-col gap-3 p-2">
326
+ {fields.length === 0 ? (
327
+ <p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]/45">
328
+ {item
329
+ ? 'This overlay has no editable content.'
330
+ : 'Select an overlay to edit its content.'}
331
+ </p>
332
+ ) : (
333
+ fields.map(field => (
334
+ <PropFieldRow
335
+ key={field.name}
336
+ field={field}
337
+ // Every write spreads the item's whole `props` record, so the
338
+ // non-primitive props `inferOverlayPropFields` skipped (arrays,
339
+ // objects, null) survive untouched rather than being dropped.
340
+ onPreview={value => onPreview({ ...props, [field.name]: value })}
341
+ onCommit={onCommit}
342
+ fileUrl={fileUrl}
343
+ uploadFile={uploadFile}
344
+ mode={mode}
345
+ />
346
+ ))
347
+ )}
348
+ </div>
349
+ </div>
350
+ )
351
+ }
@@ -0,0 +1,60 @@
1
+ import { cn } from '../../ui'
2
+
3
+ export interface TabNavTab<T extends string = string> {
4
+ value: T
5
+ label: string
6
+ }
7
+
8
+ export interface TabNavProps<T extends string = string> {
9
+ tabs: readonly TabNavTab<T>[]
10
+ value: T
11
+ onChange: (next: T) => void
12
+ /** Accessible name for the group, e.g. "Overlay panel view". */
13
+ ariaLabel: string
14
+ /** Extra classes merged onto the wrapper, after the base `flex items-center`. */
15
+ className?: string
16
+ }
17
+
18
+ /**
19
+ * Shared underline tab strip used by the editor's small in-panel tab
20
+ * switches (CaptionListPanel's Styles/Format/Captions switch, VideoEditor's
21
+ * overlay Content/Transform pair, ClipPropertiesPanel's clip tabs):
22
+ * uppercase labels, an accent underline under the active tab, muted
23
+ * inactive — NOT a filled segmented control — so every panel that switches
24
+ * between two or three views speaks the same tab language. Purely
25
+ * presentational: the caller owns `value` and any persistence (e.g. via
26
+ * `usePersistentState`), this component only renders the strip and reports
27
+ * clicks.
28
+ *
29
+ * Deliberately NOT ARIA `role="tab"` / `role="tablist"`: the editor's LEFT
30
+ * rail (`LeftPanelTabs`) already owns a real `role="tablist"`, and a second
31
+ * tablist anywhere else in the same tree would put ambiguous `role="tab"`
32
+ * nodes in the a11y tree and make `getByRole('tab', …)` ambiguous in the
33
+ * host's own tests. A plain `role="group"` of `aria-pressed` buttons carries
34
+ * the same "these are alternatives, one is active" meaning without the
35
+ * collision — do not "fix" this back to `role="tab"`.
36
+ */
37
+ export default function TabNav<T extends string>({ tabs, value, onChange, ariaLabel, className }: TabNavProps<T>) {
38
+ return (
39
+ <div role="group" aria-label={ariaLabel} className={cn('flex items-center', className)}>
40
+ {tabs.map(tab => {
41
+ const active = tab.value === value
42
+ return (
43
+ <button
44
+ key={tab.value}
45
+ type="button"
46
+ aria-pressed={active}
47
+ onClick={() => onChange(tab.value)}
48
+ className={`px-3 py-2 text-[11px] font-medium uppercase tracking-wide border-b-2 -mb-px transition-colors ${
49
+ active
50
+ ? 'border-[var(--editor-accent)] text-[var(--editor-text)]'
51
+ : 'border-transparent text-[var(--editor-text)] opacity-50 hover:opacity-80'
52
+ }`}
53
+ >
54
+ {tab.label}
55
+ </button>
56
+ )
57
+ })}
58
+ </div>
59
+ )
60
+ }