@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,466 @@
1
+ /**
2
+ * CaptionStyleGallery — the Style tab's preset picker: one card per caption
3
+ * style, each a LIVE, animated preview of that style.
4
+ *
5
+ * Replaces the row of text chips (`word-by-word`, `pop`, `karaoke`, …), which
6
+ * asked the operator to know from a kebab-case identifier what seven different
7
+ * animations look like. Each card here runs the SAME JSX template the render
8
+ * engine runs (`render/templates/captions/<style>.jsx`, fetched through the
9
+ * host's `compileOverlay` / `resolveCaptionTemplate` adapter pair, exactly as
10
+ * `preview/CaptionPreview.tsx` does for the active style) against a synthetic
11
+ * four-word sample, so what a card shows is what the export will actually
12
+ * produce.
13
+ *
14
+ * Cost control, in three parts:
15
+ * - Compiles are kicked off EAGERLY on mount for all seven cards, not on
16
+ * first hover, so hovering never stutters waiting on a fetch+transpile.
17
+ * - Compiled factories are cached per template src (see `templateCache`), so
18
+ * re-mounting the gallery — every switch back to the Style tab — is free.
19
+ * - The per-frame `requestAnimationFrame` loop runs ONLY while a card is
20
+ * hovered. Seven templates re-rendering at 30fps forever would burn the
21
+ * main thread the whole time the panel is open; an unhovered card is a
22
+ * static poster frame and costs nothing.
23
+ *
24
+ * A host with no overlay compiler (Hub / LP) degrades to a static
25
+ * `CaptionSpecimen` per card rather than breaking — same graceful-absence
26
+ * contract `CaptionPreview` already honours for `resolveCaptionTemplate`.
27
+ */
28
+ import { useEffect, useRef, useState } from 'react'
29
+ import type { CaptionSegment, Captions } from '../schema'
30
+ import type { OverlayFactory, Project } from '../types'
31
+ import OverlayErrorBoundary from '../carousel/OverlayErrorBoundary'
32
+ import { ensureGoogleFontsLoaded } from '../lib/google-fonts'
33
+ import CaptionSpecimen from './CaptionSpecimen'
34
+ import { CAPTION_STYLE_LETTER_SPACING, CAPTION_STYLE_TEXT_TRANSFORM } from './captionStyleDefaults'
35
+
36
+ type CaptionStyle = Captions['style']
37
+ type CompileOverlay = (src: string) => Promise<OverlayFactory>
38
+
39
+ /** Every caption style, in gallery order — the per-word animated ones first,
40
+ * the calmer block styles after. Typed against the schema union rather than
41
+ * as a bare string tuple so adding a style to `Captions['style']` without
42
+ * adding it here is at least a type error at the `CAPTION_STYLE_LABELS`
43
+ * record below. This is the single source of truth for the list; the chip row
44
+ * in `CaptionListPanel.tsx` had its own private copy, which is retired when
45
+ * this gallery is mounted in its place. */
46
+ export const CAPTION_STYLES: readonly CaptionStyle[] = [
47
+ 'word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean',
48
+ ]
49
+
50
+ /** Human labels for the cards. The kebab-case identifier is a code name, not a
51
+ * UI string — it went straight onto the old chips only because the chips had
52
+ * no room for anything better. */
53
+ export const CAPTION_STYLE_LABELS: Record<CaptionStyle, string> = {
54
+ 'word-by-word': 'Word by word',
55
+ pop: 'Pop',
56
+ karaoke: 'Karaoke',
57
+ subtitle: 'Subtitle',
58
+ 'highlight-box': 'Highlight box',
59
+ outline: 'Outline',
60
+ clean: 'Clean',
61
+ }
62
+
63
+ // The caption templates position themselves against the native render frame
64
+ // (percent anchors and `position: fixed` inside a transformed wrapper — see
65
+ // `captionOuterStyle` in montaj_assets/overlay-runtime/position.js), so the
66
+ // preview layer has to BE that frame and be scaled down, exactly as
67
+ // CaptionPreview does. Same constants, same reason.
68
+ const RENDER_W = 1080
69
+ const RENDER_H = 1920
70
+
71
+ /** Card geometry. A card is a BAND across the bottom of the frame (BAND_H tall,
72
+ * ~90px at rail width), not the whole 1080×1920 — the templates all anchor
73
+ * their text near the bottom edge (`bottom: '25%'` for five, 17% for `clean`,
74
+ * 15% for `subtitle`), so a full-frame thumbnail would be mostly empty and
75
+ * shrink the text to a few illegible pixels.
76
+ *
77
+ * All seven caption templates now anchor at the SAME 25% (see
78
+ * `render/templates/captions/*.jsx`), so one fixed window frames every card
79
+ * identically — no per-style offset needed. BAND_TOP is tuned so the
80
+ * full-sentence block styles (five of the seven, two lines at CARD_FONT_SIZE)
81
+ * sit roughly centred; the two single-word styles (word-by-word, pop) then sit
82
+ * a touch low, which is fine for a one-word specimen. BAND_H sets the card's
83
+ * aspect ratio. */
84
+ const BAND_TOP = 950
85
+ const BAND_H = 720
86
+
87
+ /** Font size handed to every card's template, overriding both the project's
88
+ * `fontsize` and each template's own default.
89
+ *
90
+ * Deliberately NOT faithful: at rail width a card scales the frame by ~1/8,
91
+ * so a real 46px caption would render at ~6 screen px — unreadable, and
92
+ * identically unreadable on all seven cards, which defeats the point of a
93
+ * gallery. Size is chosen elsewhere in this panel (the fontsize slider and
94
+ * `CaptionSpecimen`, which IS pixel-faithful); a card answers "which
95
+ * animation", and answers it best when all seven are drawn at one comparable,
96
+ * legible size.
97
+ *
98
+ * 130 (the two-word-sample value) was trimmed to 108 when the sample grew to
99
+ * four words: the three block styles that lay every word out at once
100
+ * (`highlight-box`, `outline`, `subtitle`/`clean`-style full-sentence
101
+ * layouts) wrap "The quick brown fox" onto two lines inside the card's
102
+ * crop band (see BAND_TOP), and `highlight-box` — two lines plus its
103
+ * word-gap — is the tightest: at 130 its top line cleared BAND_TOP by only
104
+ * ~10px of the 1920-tall frame, one bad font metric from being clipped. 108
105
+ * restores a comfortable ~72px margin there (measured headlessly against the
106
+ * fallback sans stack; see the plan's legibility check) while every other
107
+ * style keeps far more room to spare. This does not protect against an
108
+ * unusually wide user-chosen Google Font pushing `highlight-box` to a THIRD
109
+ * line — no font size in a fixed-width card can — but that risk already
110
+ * existed at two words and two-line wraps are the overwhelming case for
111
+ * caption-style typefaces. */
112
+ const CARD_FONT_SIZE = 108
113
+
114
+ /** Playback rate for the sample loop. Fixed rather than the project's fps: the
115
+ * card is a specimen, and every template's animation is defined in seconds
116
+ * (`t = frame / fps`), so the look is fps-independent. */
117
+ const SAMPLE_FPS = 30
118
+
119
+ /** The sample the cards animate. Four words — "The quick brown fox" — because
120
+ * two cannot show what distinguishes half these styles: `word-by-word`/`pop`
121
+ * step through one word at a time and read as a stutter (entrance, hold,
122
+ * exit, done) rather than as the effect with only one step to take;
123
+ * `karaoke`'s left-to-right sweep and `highlight-box`/`outline`'s box
124
+ * hopping both read as a single jump rather than a run across a sentence.
125
+ * Four words gives every per-word style room to actually demonstrate
126
+ * itself — CapCut's own style-picker cards use a comparable four-word
127
+ * sample for the same reason.
128
+ *
129
+ * The timings are chosen so no template's short-word guard fires: `pop`
130
+ * only runs its exit fade on words longer than 6 frames, and every word
131
+ * here is 19.5 frames at 30fps (`SAMPLE_FPS`) — identical to the original
132
+ * two-word sample's first word — so every card shows the complete entry
133
+ * AND exit of the animation rather than a clipped one. */
134
+ export const SAMPLE_SEGMENT: CaptionSegment = {
135
+ id: 'caption-style-sample',
136
+ text: 'The quick brown fox',
137
+ start: 0,
138
+ end: 2.6,
139
+ words: [
140
+ { word: 'The', start: 0, end: 0.65 },
141
+ { word: 'quick', start: 0.65, end: 1.3 },
142
+ { word: 'brown', start: 1.3, end: 1.95 },
143
+ { word: 'fox', start: 1.95, end: 2.6 },
144
+ ],
145
+ }
146
+ const SAMPLE_SEGMENTS: CaptionSegment[] = [SAMPLE_SEGMENT]
147
+ const SAMPLE_TOTAL_FRAMES = Math.round(SAMPLE_SEGMENT.end * SAMPLE_FPS)
148
+
149
+ /** Length of one hover loop, in seconds. Longer than the sample (2.6s) on
150
+ * purpose: the tail leaves the card empty for ~0.4s so the replay reads as a
151
+ * replay, and so the entrance — which is the whole animation for `subtitle`,
152
+ * `clean` and `karaoke` — is visible on every pass rather than only the
153
+ * first. Same 0.4s tail as the original two-word sample, just added onto the
154
+ * now-longer 2.6s sample. */
155
+ const LOOP_SECONDS = 3
156
+
157
+ /** The still every card shows when it is NOT hovered: 1.0s into the sample,
158
+ * where all seven have finished their entrance and the SECOND word is the
159
+ * active one — so a resting card already shows the style's distinguishing
160
+ * treatment (accent fill, highlight box, karaoke split) rather than a blank
161
+ * frame or a mid-fade. */
162
+ const POSTER_FRAME = Math.round(1.0 * SAMPLE_FPS)
163
+
164
+ /** Width assumed for a card before/without a real measurement.
165
+ *
166
+ * `CaptionPreview` renders NOTHING until its ResizeObserver has fired, which
167
+ * is correct there (it overlays a real player and a wrong scale would put the
168
+ * caption in the wrong place) but wrong here: jsdom never lays anything out,
169
+ * and the package's test-setup ResizeObserver stub never fires, so a
170
+ * null-scale gate would mean the gallery renders no caption at all under test
171
+ * and every assertion about it would pass vacuously. A card is a
172
+ * self-contained specimen with nothing to line up against, so it renders
173
+ * immediately at this nominal width and corrects itself on the first real
174
+ * measurement. Same reasoning as `CaptionSpecimen`'s MIN/MAX clamps.
175
+ *
176
+ * The value is one card of a 2-column grid in the ~300px right rail. */
177
+ const FALLBACK_CARD_W = 135
178
+
179
+ /** `Captions.fontsize`'s default, mirroring CaptionListPanel's slider seed.
180
+ * Used only by the static-specimen fallback, which IS size-faithful. */
181
+ const DEFAULT_FONT_SIZE = 46
182
+
183
+ // ── Compiled-template cache ─────────────────────────────────────────────────
184
+ // Module-level, so it survives the gallery unmounting — which happens on every
185
+ // switch to the Captions tab — and keyed by template src, which is what
186
+ // `compileOverlay` actually consumes.
187
+ //
188
+ // The cached compiler function is part of the key check, not just the src: a
189
+ // host that swaps `compileOverlay` (a re-created adapter, a template hot
190
+ // reload, a different fake in a test) must not be served the previous one's
191
+ // output for the same src. Same-identity hits are the common case and stay
192
+ // free.
193
+ const templateCache = new Map<string, { compile: CompileOverlay; promise: Promise<OverlayFactory> }>()
194
+
195
+ function compileCached(src: string, compile: CompileOverlay): Promise<OverlayFactory> {
196
+ const hit = templateCache.get(src)
197
+ if (hit && hit.compile === compile) return hit.promise
198
+ const entry = { compile, promise: compile(src) }
199
+ templateCache.set(src, entry)
200
+ // A failed compile must not be cached forever — a transient fetch error
201
+ // would otherwise leave that style's card permanently blank for the rest of
202
+ // the session. Evict only if this entry is still the current one, so a
203
+ // newer compile that has already replaced it survives.
204
+ entry.promise.catch(() => {
205
+ if (templateCache.get(src) === entry) templateCache.delete(src)
206
+ })
207
+ return entry.promise
208
+ }
209
+
210
+ export interface CaptionStyleGalleryProps {
211
+ /** The active caption track: supplies the selected style and the theme props
212
+ * (colour, family, case, spacing, …) every card's template is fed, so a
213
+ * card previews what THIS project would look like in that style. */
214
+ captions: Captions
215
+ /** The whole project — a card click commits `captions.style` onto it. */
216
+ project: Project
217
+ /** Whole-project commit, the same channel the retired chip row used. */
218
+ onCaptionEdit?: (project: Project) => void
219
+ /** Compile a JSX template into an `OverlayFactory` (from
220
+ * `adapter.compileOverlay`). Absent — or `resolveCaptionTemplate` absent —
221
+ * ⇒ static specimen cards. */
222
+ compileOverlay?: CompileOverlay
223
+ /** Resolve a style name to the template src `compileOverlay` expects (from
224
+ * `adapter.resolveCaptionTemplate`). */
225
+ resolveCaptionTemplate?: (style: string) => string
226
+ }
227
+
228
+ export default function CaptionStyleGallery({
229
+ captions,
230
+ project,
231
+ onCaptionEdit,
232
+ compileOverlay,
233
+ resolveCaptionTemplate,
234
+ }: CaptionStyleGalleryProps) {
235
+ // Theme props for the templates: everything on the track except `style` and
236
+ // `segments` (each card supplies its own), `googleFonts` (loaded below, not
237
+ // a template prop) and `fontsize` (deliberately overridden per card — see
238
+ // CARD_FONT_SIZE). The legacy lowercase `fontsize` key is what the project
239
+ // stores; templates read camelCase `fontSize`, which is set explicitly on
240
+ // the card.
241
+ const { style: _style, segments: _segments, googleFonts, fontsize: _fontsize, ...rest } = captions
242
+ // Spread into a fresh literal rather than passing `rest` straight through:
243
+ // `Captions` is an interface, so its rest type has no index signature and
244
+ // does not assign to `Record<string, unknown>` — the same shape
245
+ // CaptionPreview builds its `themeProps` with.
246
+ const theme: Record<string, unknown> = { ...rest }
247
+
248
+ // Load the track's Google Fonts once for the whole gallery rather than once
249
+ // per card — same URL, same injected <link>, and `ensureGoogleFontsLoaded`
250
+ // dedupes anyway. Without it every card would preview in a fallback face and
251
+ // misreport what the export will look like. Keyed on the joined list because
252
+ // `captions` is a fresh object on every project edit (see CaptionPreview).
253
+ useEffect(() => { ensureGoogleFontsLoaded(googleFonts) }, [String(googleFonts)])
254
+
255
+ function selectStyle(style: CaptionStyle) {
256
+ // Guarded on `project.captions`, not the `captions` prop: the commit below
257
+ // spreads the project's own track, and this is byte-for-byte the write the
258
+ // chip row performed.
259
+ if (!project.captions) return
260
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, style } })
261
+ }
262
+
263
+ return (
264
+ <div role="group" aria-label="Caption style" className="grid grid-cols-2 gap-1.5">
265
+ {CAPTION_STYLES.map(style => (
266
+ <CaptionStyleCard
267
+ key={style}
268
+ style={style}
269
+ selected={captions.style === style}
270
+ captions={captions}
271
+ theme={theme}
272
+ compileOverlay={compileOverlay}
273
+ resolveCaptionTemplate={resolveCaptionTemplate}
274
+ onSelect={selectStyle}
275
+ />
276
+ ))}
277
+ </div>
278
+ )
279
+ }
280
+
281
+ export interface CaptionStyleCardProps {
282
+ style: CaptionStyle
283
+ selected: boolean
284
+ /** The project's caption track — read for the static fallback's per-style
285
+ * look. The live path uses `theme` instead. */
286
+ captions: Captions
287
+ /** Track-level theme fields, already stripped of style/segments/googleFonts/
288
+ * fontsize by the gallery. */
289
+ theme: Record<string, unknown>
290
+ compileOverlay?: CompileOverlay
291
+ resolveCaptionTemplate?: (style: string) => string
292
+ onSelect: (style: CaptionStyle) => void
293
+ }
294
+
295
+ export function CaptionStyleCard({
296
+ style,
297
+ selected,
298
+ captions,
299
+ theme,
300
+ compileOverlay,
301
+ resolveCaptionTemplate,
302
+ onSelect,
303
+ }: CaptionStyleCardProps) {
304
+ const wrapRef = useRef<HTMLDivElement>(null)
305
+ const [measuredWidth, setMeasuredWidth] = useState(0)
306
+ const [factory, setFactory] = useState<OverlayFactory | null>(null)
307
+ const [frame, setFrame] = useState(POSTER_FRAME)
308
+ const [hovered, setHovered] = useState(false)
309
+ // A compile that REJECTS (bad template, network hiccup) must not leave the
310
+ // card stuck on the live branch rendering `element === null` forever — an
311
+ // empty black band with a label under it. Falling back to the static
312
+ // specimen on failure matches the already-built compiler-less path.
313
+ const [failed, setFailed] = useState(false)
314
+
315
+ const live = !!compileOverlay && !!resolveCaptionTemplate && !failed
316
+
317
+ // Measure the card so the 1080-wide layer can be scaled to it, same trick as
318
+ // CaptionPreview / CaptionSpecimen. Never fires in jsdom — see
319
+ // FALLBACK_CARD_W for why that is handled by a fallback rather than a gate.
320
+ useEffect(() => {
321
+ const el = wrapRef.current
322
+ if (!el) return
323
+ const obs = new ResizeObserver(([entry]) => setMeasuredWidth(entry.contentRect.width))
324
+ obs.observe(el)
325
+ return () => obs.disconnect()
326
+ }, [live])
327
+
328
+ // Compile on MOUNT, not on first hover: a card that starts fetching and
329
+ // transpiling when the pointer arrives spends the first frames of the
330
+ // animation blank, which reads as a broken card.
331
+ useEffect(() => {
332
+ if (!compileOverlay || !resolveCaptionTemplate) {
333
+ setFactory(null)
334
+ return
335
+ }
336
+ let cancelled = false
337
+ setFailed(false)
338
+ compileCached(resolveCaptionTemplate(style), compileOverlay)
339
+ .then(f => { if (!cancelled) setFactory(() => f) })
340
+ .catch(e => {
341
+ if (cancelled) return
342
+ console.warn(`[CaptionStyleGallery] failed to load template for ${style}:`, e)
343
+ setFailed(true)
344
+ })
345
+ return () => { cancelled = true }
346
+ }, [style, compileOverlay, resolveCaptionTemplate])
347
+
348
+ // Hover-gated frame loop. Driven off the rAF timestamp rather than a frame
349
+ // counter so the sample plays at real speed on any refresh rate, and modulo
350
+ // LOOP_SECONDS so it replays for as long as the pointer stays. Torn down by
351
+ // the cleanup below on unhover AND on unmount, so no frame is left pending.
352
+ useEffect(() => {
353
+ if (!hovered) return
354
+ let raf = 0
355
+ let startedAt: number | null = null
356
+ const tick = (now: number) => {
357
+ if (startedAt === null) startedAt = now
358
+ const elapsed = ((now - startedAt) / 1000) % LOOP_SECONDS
359
+ setFrame(Math.round(elapsed * SAMPLE_FPS))
360
+ raf = requestAnimationFrame(tick)
361
+ }
362
+ raf = requestAnimationFrame(tick)
363
+ return () => cancelAnimationFrame(raf)
364
+ }, [hovered])
365
+
366
+ const scale = (measuredWidth > 0 ? measuredWidth : FALLBACK_CARD_W) / RENDER_W
367
+
368
+ // Templates are pure functions of `frame`, so re-rendering IS the animation.
369
+ const element = factory
370
+ ? factory(frame, SAMPLE_FPS, SAMPLE_TOTAL_FRAMES, {
371
+ segments: SAMPLE_SEGMENTS,
372
+ ...theme,
373
+ fontSize: CARD_FONT_SIZE,
374
+ })
375
+ : null
376
+
377
+ return (
378
+ <button
379
+ type="button"
380
+ data-style={style}
381
+ aria-pressed={selected}
382
+ onClick={() => onSelect(style)}
383
+ onMouseEnter={() => setHovered(true)}
384
+ // Snap straight back to the poster frame rather than freezing wherever
385
+ // the loop happened to stop — a card left mid-fade reads as broken.
386
+ onMouseLeave={() => { setHovered(false); setFrame(POSTER_FRAME) }}
387
+ // Keyboard/touch/pen parity with the mouse handlers above: without these
388
+ // a card only ever advances for a mouse hover, so a keyboard user
389
+ // tabbing through the grid never sees anything but the poster frame.
390
+ onFocus={() => setHovered(true)}
391
+ onBlur={() => { setHovered(false); setFrame(POSTER_FRAME) }}
392
+ className={`flex flex-col gap-1 rounded-md border p-1 text-left transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)] ${
393
+ selected
394
+ // Same selected treatment as RenderModal's option cards: accent
395
+ // border plus a 1px ring, so the pick reads at a glance in a grid.
396
+ ? 'border-[var(--editor-accent)] ring-1 ring-[var(--editor-accent)] bg-[var(--editor-surface)]'
397
+ : 'border-[var(--editor-border)] bg-[var(--editor-surface)] hover:border-[var(--editor-accent)]'
398
+ }`}
399
+ >
400
+ {/* The preview is decorative: the label below is the button's accessible
401
+ name, and a screen reader gaining "The quick brown fox" seven times
402
+ over adds nothing. */}
403
+ {live ? (
404
+ <div
405
+ ref={wrapRef}
406
+ aria-hidden="true"
407
+ className="relative w-full overflow-hidden rounded bg-black"
408
+ style={{ aspectRatio: `${RENDER_W} / ${BAND_H}` }}
409
+ >
410
+ {/* Per card, so one template that throws costs one card rather than
411
+ the whole gallery. `resetKey` clears a caught error if this card's
412
+ style is ever re-pointed at a different template. */}
413
+ <OverlayErrorBoundary label={`caption style: ${style}`} resetKey={style}>
414
+ {element && (
415
+ <div style={{
416
+ position: 'absolute',
417
+ // Slide the full-height layer up so BAND_TOP lands on the card's
418
+ // top edge; `overflow-hidden` above crops the rest. Every caption
419
+ // template now anchors at the same 25% (see the render templates),
420
+ // so one fixed window frames all seven cards identically.
421
+ top: -BAND_TOP * scale,
422
+ left: 0,
423
+ width: RENDER_W,
424
+ height: RENDER_H,
425
+ transform: `scale(${scale})`,
426
+ transformOrigin: 'top left',
427
+ }}>
428
+ {element}
429
+ </div>
430
+ )}
431
+ </OverlayErrorBoundary>
432
+ </div>
433
+ ) : (
434
+ <div aria-hidden="true" className="w-full">
435
+ {/* No compiler on this host: fall back to the static type specimen,
436
+ seeded with THIS card's style so it reports that style's own
437
+ default face and weight. `textTransform` / `letterSpacing` have no
438
+ fallback inside CaptionSpecimen, so the style defaults are applied
439
+ here. */}
440
+ <CaptionSpecimen
441
+ captions={{ ...captions, style, segments: SAMPLE_SEGMENTS }}
442
+ currentTime={POSTER_FRAME / SAMPLE_FPS}
443
+ fontFamily={captions.fontFamily}
444
+ fontSize={captions.fontsize ?? DEFAULT_FONT_SIZE}
445
+ textTransform={captions.textTransform ?? CAPTION_STYLE_TEXT_TRANSFORM[style]}
446
+ letterSpacing={captions.letterSpacing ?? CAPTION_STYLE_LETTER_SPACING[style]}
447
+ fontWeight={captions.fontWeight}
448
+ />
449
+ </div>
450
+ )}
451
+
452
+ {/* NOT `text-[var(--editor-text)]/60` — Tailwind cannot generate an
453
+ opacity modifier on an arbitrary var() colour, so that class is a
454
+ silent no-op. See the long note in CaptionListPanel.tsx. */}
455
+ <span
456
+ className={`truncate px-0.5 text-[10px] leading-none ${
457
+ selected
458
+ ? 'text-[var(--editor-accent)]'
459
+ : 'text-[var(--editor-text)] opacity-60'
460
+ }`}
461
+ >
462
+ {CAPTION_STYLE_LABELS[style]}
463
+ </span>
464
+ </button>
465
+ )
466
+ }
@@ -0,0 +1,165 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Search } from 'lucide-react'
4
+ import { parseTimecode } from './timecode'
5
+
6
+ export interface PaletteCommand {
7
+ id: string
8
+ label: string
9
+ keyHint?: string[]
10
+ run: () => void
11
+ }
12
+
13
+ export interface CommandPaletteProps {
14
+ commands: PaletteCommand[]
15
+ /** Opens straight into the "go to time" input instead of the filtered list
16
+ * — the time readout's click target uses this. */
17
+ initialMode?: 'list' | 'goto'
18
+ /** Parsed timecode → `clock.set`-style seek. */
19
+ onGoToTime: (seconds: number) => void
20
+ onClose: () => void
21
+ /** Editor theme mode — light/dark. Named `themeMode` (not `mode`) because
22
+ * this component already has its own internal `mode` state ('list' |
23
+ * 'goto'). Panel follows `--editor-surface`, so the goto-error hue needs
24
+ * to darken in light mode. Absent -> dark, matching every existing
25
+ * caller. */
26
+ themeMode?: 'light' | 'dark'
27
+ }
28
+
29
+ /**
30
+ * Cmd/Ctrl+K command palette. Styled after the other editor modals (portal to
31
+ * `document.body`, dark centered panel, backdrop-click-to-close) so it reads
32
+ * as part of the same modal family.
33
+ *
34
+ * List mode: type to filter, arrow keys to move the highlight, Enter runs
35
+ * the highlighted command, Escape closes. Goto mode: a single timecode input
36
+ * (`mm:ss` / `hh:mm:ss` / bare seconds), Enter seeks and closes, Escape
37
+ * returns to the list (or closes, if opened directly into goto mode).
38
+ */
39
+ export default function CommandPalette({ commands, initialMode = 'list', onGoToTime, onClose, themeMode = 'dark' }: CommandPaletteProps) {
40
+ const [mode, setMode] = useState<'list' | 'goto'>(initialMode)
41
+ const [query, setQuery] = useState('')
42
+ const [gotoValue, setGotoValue] = useState('')
43
+ const [highlight, setHighlight] = useState(0)
44
+ const [gotoError, setGotoError] = useState(false)
45
+ const inputRef = useRef<HTMLInputElement>(null)
46
+
47
+ useEffect(() => {
48
+ inputRef.current?.focus()
49
+ }, [mode])
50
+
51
+ const filtered = useMemo(() => {
52
+ const q = query.trim().toLowerCase()
53
+ if (!q) return commands
54
+ return commands.filter((c) => c.label.toLowerCase().includes(q))
55
+ }, [commands, query])
56
+
57
+ useEffect(() => {
58
+ setHighlight(0)
59
+ }, [filtered.length, query])
60
+
61
+ function runHighlighted() {
62
+ const cmd = filtered[highlight]
63
+ if (!cmd) return
64
+ cmd.run()
65
+ onClose()
66
+ }
67
+
68
+ function submitGoto() {
69
+ const seconds = parseTimecode(gotoValue)
70
+ if (seconds === null) { setGotoError(true); return }
71
+ onGoToTime(seconds)
72
+ onClose()
73
+ }
74
+
75
+ function handleListKeyDown(e: React.KeyboardEvent) {
76
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(filtered.length - 1, h + 1)); return }
77
+ if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)); return }
78
+ if (e.key === 'Enter') { e.preventDefault(); runHighlighted(); return }
79
+ if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
80
+ }
81
+
82
+ function handleGotoKeyDown(e: React.KeyboardEvent) {
83
+ if (e.key === 'Enter') { e.preventDefault(); submitGoto(); return }
84
+ if (e.key === 'Escape') {
85
+ e.preventDefault()
86
+ // Opened directly (time-readout click) → close outright. Opened from
87
+ // the list's "Go to time..." entry → fall back to the list.
88
+ if (initialMode === 'goto') { onClose(); return }
89
+ setMode('list')
90
+ setGotoValue('')
91
+ setGotoError(false)
92
+ }
93
+ }
94
+
95
+ return createPortal(
96
+ <div
97
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/70 backdrop-blur-sm"
98
+ onClick={onClose}
99
+ >
100
+ <div
101
+ className="relative w-full max-w-lg flex flex-col bg-[var(--editor-bg)] border border-[var(--editor-border)] rounded-lg shadow-2xl mx-4 overflow-hidden"
102
+ onClick={(e) => e.stopPropagation()}
103
+ >
104
+ {mode === 'goto' ? (
105
+ <div className="flex flex-col gap-2 px-4 py-3">
106
+ <span className="text-xs font-medium text-[var(--editor-text)]/60">Go to time</span>
107
+ <input
108
+ ref={inputRef}
109
+ type="text"
110
+ value={gotoValue}
111
+ onChange={(e) => { setGotoValue(e.target.value); setGotoError(false) }}
112
+ onKeyDown={handleGotoKeyDown}
113
+ placeholder="mm:ss, hh:mm:ss, or seconds"
114
+ className="w-full bg-transparent text-sm text-[var(--editor-text)] placeholder-[var(--editor-text)]/25 outline-none border border-[var(--editor-border)] rounded px-2 py-1.5"
115
+ />
116
+ {gotoError && <span className={`text-xs ${themeMode === 'light' ? 'text-red-600' : 'text-red-400'}`}>Couldn&rsquo;t parse that timecode</span>}
117
+ </div>
118
+ ) : (
119
+ <>
120
+ <div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
121
+ <Search size={14} className="text-[var(--editor-text)]/60 shrink-0" />
122
+ <input
123
+ ref={inputRef}
124
+ type="text"
125
+ value={query}
126
+ onChange={(e) => setQuery(e.target.value)}
127
+ onKeyDown={handleListKeyDown}
128
+ placeholder="Type a command…"
129
+ className="w-full bg-transparent text-sm text-[var(--editor-text)] placeholder-[var(--editor-text)]/25 outline-none"
130
+ />
131
+ </div>
132
+ <div className="max-h-80 overflow-y-auto py-1">
133
+ {filtered.length === 0 && (
134
+ <div className="px-4 py-3 text-xs text-[var(--editor-text)]/35">No matching commands</div>
135
+ )}
136
+ {filtered.map((cmd, i) => (
137
+ <button
138
+ key={cmd.id}
139
+ type="button"
140
+ onMouseEnter={() => setHighlight(i)}
141
+ onClick={() => { cmd.run(); onClose() }}
142
+ className={`w-full flex items-center justify-between gap-3 px-4 py-2 text-left text-sm transition-colors ${
143
+ i === highlight ? 'bg-[var(--editor-text)]/10 text-[var(--editor-text)]' : 'text-[var(--editor-text)]/70'
144
+ }`}
145
+ >
146
+ <span>{cmd.label}</span>
147
+ {cmd.keyHint && cmd.keyHint.length > 0 && (
148
+ <span className="flex items-center gap-1 shrink-0">
149
+ {cmd.keyHint.map((k, j) => (
150
+ <kbd key={j} className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-[var(--editor-border)] text-[var(--editor-text)]/60">
151
+ {k}
152
+ </kbd>
153
+ ))}
154
+ </span>
155
+ )}
156
+ </button>
157
+ ))}
158
+ </div>
159
+ </>
160
+ )}
161
+ </div>
162
+ </div>,
163
+ document.body,
164
+ )
165
+ }