@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,159 @@
1
+ import type { PlaybackClock } from './playback-clock'
2
+
3
+ /**
4
+ * J/K/L shuttle. L plays forward, J plays backward, K stops. Repeating the same
5
+ * direction doubles the rate (1x -> 2x -> 4x, capped); the opposite direction
6
+ * resets to that direction at 1x.
7
+ *
8
+ * Two mechanisms, split by what can carry audio:
9
+ *
10
+ * - **Forward (1x/2x/4x) IS real playback, with sound.** The engine has a live
11
+ * transport rate (`deps.setRate`): project time advances R× and audio is
12
+ * pitch-corrected via a streaming time-stretch, so 2x/4x forward is audible,
13
+ * not just faster. A forward press ensures the engine transport is running
14
+ * (via `deps.play()` — the same gesture-anchored path Space uses, AudioContext
15
+ * resume and all) and sets the rate to the shuttle's magnitude.
16
+ * - **Reverse is a silent seek-loop.** The engine has no reverse rate (reversed
17
+ * audio is not a thing), so J can only fast-scrub the shared `PlaybackClock`:
18
+ * real playback is paused (muting audio) and the transport rate reset to 1x,
19
+ * then an rAF loop steps `clock.set(clock.get() + rate * dt)`. This touches
20
+ * only the clock, never the engine or <video> elements directly, so it works
21
+ * identically over both playback paths.
22
+ */
23
+ export type ShuttleRate = -4 | -2 | -1 | 0 | 1 | 2 | 4
24
+
25
+ /**
26
+ * Pure rate-stepping — unit-testable without rAF. Pressing the same
27
+ * direction again doubles the magnitude (capped at 4x); pressing the
28
+ * opposite direction resets to that direction at 1x.
29
+ */
30
+ export function nextShuttleRate(current: ShuttleRate, direction: 1 | -1): ShuttleRate {
31
+ const sign = Math.sign(current)
32
+ if (sign !== 0 && sign === direction) {
33
+ return (Math.min(4, Math.abs(current) * 2) * direction) as ShuttleRate
34
+ }
35
+ return direction
36
+ }
37
+
38
+ export interface ShuttleDeps {
39
+ clock: PlaybackClock
40
+ /** The seekable upper bound — the loop stops when it reaches either end. */
41
+ getDuration: () => number
42
+ /** True while playback is running OUTSIDE the shuttle's own loop. Polled
43
+ * every tick so a real "user hit play" transport change cancels the
44
+ * shuttle instead of racing it. */
45
+ isPlaying: () => boolean
46
+ /** Start real forward playback WITH audio — the engine transport (the same
47
+ * gesture-anchored path Space uses, AudioContext resume included). Called on a
48
+ * forward press; a no-op if already playing. */
49
+ play: () => void
50
+ /** Pause real playback (audio implicitly muted). Called when entering the
51
+ * reverse seek-loop, and by stop() when the shuttle itself started playback. */
52
+ pause: () => void
53
+ /** Set the engine's live transport rate R. A forward press sets it to the
54
+ * shuttle magnitude (1/2/4) for audible fast-forward; reverse and stop reset
55
+ * it to 1x before pausing/scrubbing so nothing plays at a stale rate. */
56
+ setRate: (rate: number) => void
57
+ /** Injectable for tests; defaults to `requestAnimationFrame`/`cancelAnimationFrame`. */
58
+ raf?: (cb: (ms: number) => void) => number
59
+ caf?: (id: number) => void
60
+ }
61
+
62
+ export interface ShuttleController {
63
+ /** J or L. Forward = real playback at the transport rate; reverse = the silent
64
+ * seek-loop. */
65
+ press: (direction: 1 | -1) => void
66
+ /** K, or any other transport/seek change — stops the loop. */
67
+ stop: () => void
68
+ getRate: () => ShuttleRate
69
+ }
70
+
71
+ export function createShuttleController(deps: ShuttleDeps): ShuttleController {
72
+ const raf = deps.raf ?? requestAnimationFrame
73
+ const caf = deps.caf ?? cancelAnimationFrame
74
+
75
+ let rate: ShuttleRate = 0
76
+ let rafId: number | null = null
77
+ let lastMs: number | null = null
78
+ // The value WE last wrote — lets the tick tell "the clock changed because
79
+ // we changed it" apart from "something external moved the playhead" (a
80
+ // Scrubber click, an arrow-key step, another editor gesture).
81
+ let lastWritten: number | null = null
82
+ // True while the shuttle itself is driving real forward playback (any of
83
+ // 1x/2x/4x). Tells stop() to pause it back, and tells a move to reverse to
84
+ // mute first.
85
+ let realPlaying = false
86
+
87
+ function stopLoop() {
88
+ if (rafId !== null) caf(rafId)
89
+ rafId = null
90
+ lastMs = null
91
+ }
92
+
93
+ function stop() {
94
+ rate = 0
95
+ stopLoop()
96
+ deps.setRate(1)
97
+ if (realPlaying) { deps.pause(); realPlaying = false }
98
+ }
99
+
100
+ // Only ever runs for reverse now (forward is real playback, no loop). Keeps
101
+ // the existing external-change/isPlaying cancellation guards so a real play or
102
+ // an external seek during a reverse scrub cancels the shuttle instead of
103
+ // racing it.
104
+ function tick(ms: number) {
105
+ if (rate === 0) { stopLoop(); return }
106
+ if (deps.isPlaying()) { stop(); return }
107
+ if (lastWritten !== null && deps.clock.get() !== lastWritten) { stop(); return }
108
+
109
+ if (lastMs !== null) {
110
+ const dt = (ms - lastMs) / 1000
111
+ const duration = deps.getDuration()
112
+ const next = Math.max(0, Math.min(duration, deps.clock.get() + rate * dt))
113
+ deps.clock.set(next)
114
+ lastWritten = next
115
+ if (next <= 0 || next >= duration) { stop(); return }
116
+ }
117
+ lastMs = ms
118
+ rafId = raf(tick)
119
+ }
120
+
121
+ function startLoopIfNeeded() {
122
+ if (rafId !== null) return
123
+ lastMs = null
124
+ lastWritten = deps.clock.get()
125
+ rafId = raf(tick)
126
+ }
127
+
128
+ return {
129
+ press(direction: 1 | -1) {
130
+ const next = nextShuttleRate(rate, direction)
131
+
132
+ if (next > 0) {
133
+ // FORWARD 1x/2x/4x = REAL playback at the engine transport rate — with
134
+ // audio, pitch-corrected. Drop any reverse seek-loop, make sure the
135
+ // engine is playing (resuming the AudioContext), then set the rate.
136
+ // Ownership (`realPlaying`) is only claimed when the shuttle itself
137
+ // starts playback from idle — if playback was already running (e.g.
138
+ // the user pressed Space first), stop() must leave it running rather
139
+ // than pausing playback the shuttle never started.
140
+ stopLoop()
141
+ if (!deps.isPlaying()) { deps.play(); realPlaying = true }
142
+ deps.setRate(next)
143
+ rate = next
144
+ return
145
+ }
146
+
147
+ // REVERSE = the silent seek-loop. Reset the transport rate and pause real
148
+ // playback before scrubbing, so nothing plays at a stale rate and nothing
149
+ // double-drives the clock while the loop fast-scrubs it.
150
+ deps.setRate(1)
151
+ if (deps.isPlaying()) deps.pause()
152
+ realPlaying = false
153
+ rate = next
154
+ startLoopIfNeeded()
155
+ },
156
+ stop,
157
+ getRate: () => rate,
158
+ }
159
+ }
@@ -0,0 +1,66 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /**
4
+ * The source-preview override: "show this OFF-TIMELINE clip's frame in the main
5
+ * preview instead of the timeline's picture", or null for "show the timeline".
6
+ *
7
+ * A sibling of `hover-scrub.ts`, and for the same structural reason: it repaints
8
+ * the preview on every hover move without touching the playback clock. But where
9
+ * hover-scrub resolves a NUMBER against the timeline, this carries a directly
10
+ * loadable proxy `url` and a `fraction` into it — a clip the timeline knows
11
+ * nothing about, driven by a host's footage bin as the pointer travels one of
12
+ * its cards.
13
+ *
14
+ * The seam is entirely opt-in. A host that never creates the store (Hub, LP, the
15
+ * classic layout) passes nothing; `useSourcePreview(undefined)` returns null and
16
+ * no overlay is ever mounted, so the main preview behaves exactly as before.
17
+ *
18
+ * An external store rather than React state because it is written on every
19
+ * mousemove: only the preview-stage overlay subscribes, so the editor shell, the
20
+ * timeline and the caption row don't re-render as the pointer travels a card.
21
+ */
22
+ export interface SourcePreviewValue {
23
+ /** A directly-loadable proxy URL (the host resolves it before setting). */
24
+ url: string
25
+ /** Position within the source, in [0, 1]. */
26
+ fraction: number
27
+ }
28
+
29
+ export interface SourcePreviewStore {
30
+ get(): SourcePreviewValue | null
31
+ set(v: SourcePreviewValue | null): void
32
+ subscribe(cb: () => void): () => void
33
+ }
34
+
35
+ export function createSourcePreviewStore(): SourcePreviewStore {
36
+ let value: SourcePreviewValue | null = null
37
+ const subs = new Set<() => void>()
38
+ return {
39
+ get: () => value,
40
+ set(v) {
41
+ // Skip the notify when nothing actually changed — a host may re-set the
42
+ // same url+fraction on repeated pointer events over the same tile.
43
+ if (v === value) return
44
+ if (v && value && v.url === value.url && v.fraction === value.fraction) return
45
+ value = v
46
+ subs.forEach((cb) => cb())
47
+ },
48
+ subscribe(cb) {
49
+ subs.add(cb)
50
+ return () => { subs.delete(cb) }
51
+ },
52
+ }
53
+ }
54
+
55
+ /** Stable snapshot for an absent store, so `useSyncExternalStore` isn't handed
56
+ * a fresh closure per render when a host omits source-preview entirely. */
57
+ const NO_SUBSCRIBE = () => () => {}
58
+ const NO_VALUE = () => null
59
+
60
+ export function useSourcePreview(store?: SourcePreviewStore): SourcePreviewValue | null {
61
+ return useSyncExternalStore(
62
+ store?.subscribe ?? NO_SUBSCRIBE,
63
+ store?.get ?? NO_VALUE,
64
+ store?.get ?? NO_VALUE,
65
+ )
66
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Parse a user-typed timecode into seconds, for the command palette's
3
+ * "go to time" entry. Accepted shapes:
4
+ *
5
+ * - bare seconds: "90", "12.5"
6
+ * - mm:ss: "1:30", "12:03.5"
7
+ * - hh:mm:ss: "01:02:03", "1:02:03.25"
8
+ *
9
+ * A fractional part is only valid on the LAST component (seconds) — "1.5:30"
10
+ * is rejected. Minutes/seconds components (everywhere except the leading
11
+ * hours) must be < 60. Anything that doesn't match — empty input, letters,
12
+ * too many `:` groups, out-of-range minutes/seconds — returns `null`.
13
+ */
14
+ export function parseTimecode(input: string): number | null {
15
+ const trimmed = input.trim()
16
+ if (!/^\d+(:\d+){0,2}(\.\d+)?$/.test(trimmed)) return null
17
+
18
+ const parts = trimmed.split(':').map(Number)
19
+ if (parts.length === 1) return parts[0]
20
+
21
+ const seconds = parts[parts.length - 1]
22
+ if (seconds >= 60) return null
23
+
24
+ if (parts.length === 2) {
25
+ const [minutes] = parts
26
+ return minutes * 60 + seconds
27
+ }
28
+
29
+ const [hours, minutes] = parts
30
+ if (minutes >= 60) return null
31
+ return hours * 3600 + minutes * 60 + seconds
32
+ }
33
+
34
+ /**
35
+ * Format seconds for the preview transport row's timecode readout, as
36
+ * `m:ss.f` (or `h:mm:ss.f` once the value reaches an hour) — one decimal
37
+ * place, rounded to the nearest tenth of a second.
38
+ *
39
+ * Deliberately NOT frames (no CapCut-style `hh:mm:ss:ff`): `parseTimecode`
40
+ * above has no notion of frames — its trailing fractional part is seconds
41
+ * only — so a frames-style display would silently mis-parse if a user
42
+ * copied it into the command palette's "go to time" field (a trailing
43
+ * `:18` would be read as 18 SECONDS, not 18 frames). Emitting the same
44
+ * `m:ss[.f]` shape `parseTimecode` already accepts keeps the two in sync;
45
+ * see the round-trip tests in `__tests__/timecode.test.ts`.
46
+ */
47
+ export function formatTimecode(sec: number): string {
48
+ const clamped = Number.isFinite(sec) ? Math.max(0, sec) : 0
49
+ const totalTenths = Math.round(clamped * 10)
50
+ const wholeSeconds = Math.floor(totalTenths / 10)
51
+ const tenths = totalTenths % 10
52
+ const hours = Math.floor(wholeSeconds / 3600)
53
+ const minutes = Math.floor((wholeSeconds % 3600) / 60)
54
+ const seconds = wholeSeconds % 60
55
+ const secondsStr = `${String(seconds).padStart(2, '0')}.${tenths}`
56
+ return hours > 0
57
+ ? `${hours}:${String(minutes).padStart(2, '0')}:${secondsStr}`
58
+ : `${minutes}:${secondsStr}`
59
+ }
@@ -22,7 +22,7 @@ export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit:
22
22
  suppressContentEditableWarning
23
23
  onBlur={handleBlur}
24
24
  onKeyDown={handleKeyDown}
25
- className="cursor-text rounded px-0.5 hover:bg-white/5 focus:bg-white/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
25
+ className="cursor-text rounded px-0.5 hover:bg-[var(--editor-text)]/5 focus:bg-[var(--editor-text)]/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
26
26
  >
27
27
  {seg.text}
28
28
  </span>
@@ -1,186 +1,58 @@
1
- import { formatTime, pct, ratioFromClientX } from './utils'
1
+ import { formatTime } from './utils'
2
2
  import { useTimelineContext } from './TimelineContext'
3
3
  import { usePlaybackTime } from '../playback-clock'
4
-
4
+ import { Tooltip } from '../../ui/Tooltip'
5
+
6
+ /**
7
+ * The strip above the tracks: current time on the left, project duration on
8
+ * the right.
9
+ *
10
+ * ── Why there is no bar here any more ────────────────────────────────────
11
+ * This used to also render an overview BAR — a track, an elapsed fill and a
12
+ * draggable red handle spanning the whole project across the full width. The
13
+ * canvas timeline owns its own zoom and scroll, so at any zoom but "fit" that
14
+ * handle sat at a different x than the canvas playhead for the same instant:
15
+ * two red markers disagreeing about where you are. The bar came out with the
16
+ * DOM timeline and its per-bar affordances (drag-to-scrub with boundary
17
+ * snapping, the hover-time tooltip) went with it — the canvas surface has its
18
+ * own playhead grab band and its own preview axis for those.
19
+ *
20
+ * The readouts stay, because they are TIMES rather than positions: they read
21
+ * from the same clock the canvas paints from and cannot disagree with it.
22
+ */
5
23
  interface ScrubberProps {
6
- hoverPct: number | null
7
- draggingPlayhead: boolean
8
- setDraggingPlayhead: (v: boolean) => void
9
- keyNavTime: number | null
10
- onSplit?: (at: number) => void
11
- onCut?: (cut: { start: number; end: number }) => void
12
- cutButtonLabel: string
24
+ /** SP5 T9 — opens the command palette's "go to time" input. Absent →
25
+ * the time readout stays plain, non-interactive text. */
26
+ onOpenGoToTime?: () => void
13
27
  }
14
28
 
15
- export default function Scrubber({
16
- hoverPct,
17
- draggingPlayhead,
18
- setDraggingPlayhead,
19
- keyNavTime,
20
- onSplit,
21
- onCut,
22
- cutButtonLabel,
23
- }: ScrubberProps) {
24
- const { clock, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, scrubberRef, selection } = useTimelineContext()
29
+ export default function Scrubber({ onOpenGoToTime }: ScrubberProps) {
30
+ const { clock, contentDuration } = useTimelineContext()
25
31
  const currentTime = usePlaybackTime(clock)
26
32
 
27
- function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
28
- e.stopPropagation()
29
- if (totalDuration === 0) return
30
- clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
31
- }
32
-
33
- function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
34
- if (totalDuration === 0) return
35
- e.preventDefault()
36
- const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
37
- setMarkers(([a, b]) => {
38
- if (a === null) return [t, null] // place first marker
39
- if (b === null) return [a, t] // place second → selection complete
40
- return [t, null] // reset: start fresh with new first marker
41
- })
42
- }
43
-
44
33
  return (
45
- <>
46
- {/* ── Scrubber ── */}
47
- <div
48
- ref={scrubberRef}
49
- className={`relative h-4 rounded-full bg-gray-200 dark:bg-gray-800 group ${markers[0] !== null && markers[1] === null ? 'cursor-cell' : 'cursor-crosshair'}`}
50
- onClick={handleScrubClick}
51
- onDoubleClick={handleScrubDoubleClick}
52
- >
53
- {/* Elapsed fill */}
54
- <div
55
- className="absolute inset-y-0 left-0 rounded-full bg-gray-400 dark:bg-gray-600 pointer-events-none"
56
- style={{ width: `${pct(currentTime, totalDuration)}%` }}
57
- />
58
-
59
- {/* Selection range fill (both markers placed) */}
60
- {selection && (
61
- <div
62
- className="absolute inset-y-0 bg-amber-500/25 pointer-events-none"
63
- style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
64
- />
65
- )}
66
-
67
- {/* Marker A */}
68
- {markers[0] !== null && (
69
- <div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[0], totalDuration)}%` }}>
70
- <div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
71
- </div>
72
- )}
73
-
74
- {/* Marker B */}
75
- {markers[1] !== null && (
76
- <div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[1], totalDuration)}%` }}>
77
- <div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
78
- </div>
79
- )}
80
-
81
- {/* Playhead handle */}
82
- <div
83
- className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-red-500 ring-2 ring-red-500/30 transition-transform group-hover:scale-125 ${draggingPlayhead ? 'cursor-grabbing scale-125' : 'cursor-grab'}`}
84
- style={{ left: `${pct(currentTime, totalDuration)}%` }}
85
- onMouseDown={(e) => {
86
- e.stopPropagation()
87
- if (totalDuration === 0) return
88
- setDraggingPlayhead(true)
89
- const boundaries = snapBoundaries
90
- let snappedTo: number | null = null // which boundary we're currently locked to
91
- function onMove(me: MouseEvent) {
92
- const rect = scrubberRef.current?.getBoundingClientRect()
93
- if (!rect) return
94
- const attractPx = 18 // cursor enters this range → snaps in
95
- const releasePx = 28 // cursor must leave this range → breaks free
96
- const attract = (attractPx / rect.width) * totalDuration
97
- const release = (releasePx / rect.width) * totalDuration
98
- const rawT = ratioFromClientX(me.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
99
- // Already snapped — hold until cursor escapes release radius
100
- if (snappedTo !== null) {
101
- if (Math.abs(rawT - snappedTo) < release) { clock.set(snappedTo); return }
102
- snappedTo = null
103
- }
104
- // Scan for attraction
105
- for (const b of boundaries) {
106
- if (Math.abs(rawT - b) < attract) { snappedTo = b; clock.set(b); return }
107
- }
108
- clock.set(rawT)
109
- }
110
- function onUp() {
111
- setDraggingPlayhead(false)
112
- document.removeEventListener('mousemove', onMove)
113
- document.removeEventListener('mouseup', onUp)
114
- }
115
- document.addEventListener('mousemove', onMove)
116
- document.addEventListener('mouseup', onUp)
117
- }}
118
- onDoubleClick={(e) => {
119
- e.stopPropagation()
120
- handleScrubDoubleClick(e as React.MouseEvent<HTMLDivElement>)
121
- }}
122
- >
123
- {keyNavTime !== null && (
124
- <div className="absolute -top-7 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20">
125
- {formatTime(keyNavTime)}
126
- </div>
127
- )}
128
- </div>
129
-
130
- {/* Time tooltip */}
131
- {hoverPct !== null && totalDuration > 0 && (
132
- <div
133
- className="absolute -top-7 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20"
134
- style={{ left: `${hoverPct}%` }}
34
+ /* `cursor-pointer`: the row is a strip of timeline background, and
35
+ Timeline's container click seeks through it (see `handleContainerClick`),
36
+ so it should look live rather than dead. */
37
+ <div className="flex items-center justify-between text-[10px] font-mono text-[var(--editor-text)]/35 cursor-pointer">
38
+ {onOpenGoToTime ? (
39
+ <Tooltip label="Go to time">
40
+ <button
41
+ type="button"
42
+ aria-label="Go to time"
43
+ onClick={(e) => { e.stopPropagation(); onOpenGoToTime() }}
44
+ className="hover:text-[var(--editor-text)]/80 transition-colors"
135
45
  >
136
- {formatTime((hoverPct / 100) * totalDuration)}
137
- </div>
138
- )}
139
- </div>
140
-
141
- {/* Time readout + marker / selection range */}
142
- <div className="flex items-center justify-between text-[10px] font-mono text-gray-600 -mt-1">
46
+ {formatTime(currentTime)}
47
+ </button>
48
+ </Tooltip>
49
+ ) : (
143
50
  <span>{formatTime(currentTime)}</span>
144
- {markers[0] !== null && markers[1] === null && (
145
- <span className="flex items-center gap-2 text-amber-400/80">
146
- {onSplit && (
147
- <button
148
- className="px-2 py-0.5 rounded bg-amber-500/80 hover:bg-amber-400 text-black text-[10px] font-medium transition-colors"
149
- onClick={(e) => { e.stopPropagation(); onSplit(markers[0]!); setMarkers([null, null]) }}
150
- >
151
- Split
152
- </button>
153
- )}
154
- {formatTime(markers[0])} — double-click to set end
155
- <button
156
- className="text-gray-600 hover:text-gray-400"
157
- onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
158
- >✕</button>
159
- </span>
160
- )}
161
- {selection && (
162
- <span className="flex items-center gap-2 text-amber-400">
163
- <button
164
- className="text-gray-600 hover:text-gray-400"
165
- onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
166
- >✕</button>
167
- {formatTime(selection.start)} – {formatTime(selection.end)}
168
- {onCut && (
169
- <button
170
- className="px-2 py-0.5 rounded bg-red-600/80 hover:bg-red-500 text-white text-[10px] font-medium transition-colors"
171
- onClick={(e) => {
172
- e.stopPropagation()
173
- onCut(selection)
174
- setMarkers([null, null])
175
- }}
176
- >
177
- {cutButtonLabel}
178
- </button>
179
- )}
180
- </span>
181
- )}
182
- <span>{formatTime(contentDuration)}</span>
183
- </div>
184
- </>
51
+ )}
52
+ {/* `data-timeline-chrome`: a readout, not a place to seek to. Clicking
53
+ it would otherwise jump the playhead to the far right of the view,
54
+ since it sits at the right edge of a strip that IS a time axis. */}
55
+ <span data-timeline-chrome className="cursor-default">{formatTime(contentDuration)}</span>
56
+ </div>
185
57
  )
186
58
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A controlled speed picker: a slider plus a row of clickable preset chips
3
+ * snapping it to a common value. Shared by the per-clip inspect modal (host
4
+ * `montaj_assets/ui` package) and the track-wide settings popover
5
+ * (TrackSettingsPopover.tsx) — one control, one look, wherever speed is set.
6
+ *
7
+ * Controlled, not stateful: the caller owns `value` and reacts to `onChange`,
8
+ * exactly like the range inputs elsewhere in this rail (volume, fontsize).
9
+ * That keeps the two mount points free to wire it up differently — the clip
10
+ * modal previews live and commits on an explicit Save button; the track popover
11
+ * previews into local state and commits directly via `onCommit` (on slider
12
+ * release and on a chip click), so touching a track control just applies, no
13
+ * button, the same live-preview-then-commit split the volume fader uses.
14
+ *
15
+ * The slider itself is the shared `<Slider>` (ui/Slider.tsx) — see
16
+ * `VolumeControl`'s twin comment for the one behaviour change it brings
17
+ * (commit only on an actual gesture change, not on every pointerup/keyup).
18
+ */
19
+ import { Slider } from '../../ui'
20
+
21
+ export interface SpeedControlProps {
22
+ value: number
23
+ onChange: (v: number) => void
24
+ /** Optional commit signal, fired on slider release (pointer/keyboard) and on
25
+ * a chip click, carrying the value to commit. Callers that commit on their
26
+ * own control (e.g. a Save button) omit it and just read `onChange`. */
27
+ onCommit?: (v: number) => void
28
+ min?: number
29
+ max?: number
30
+ step?: number
31
+ presets?: number[]
32
+ label?: string
33
+ /** Base id for the slider input, so a caller can wire a `<label htmlFor>`
34
+ * to it. Optional — the control is already self-labelled via `aria-label`. */
35
+ idBase?: string
36
+ }
37
+
38
+ const DEFAULT_PRESETS = [0.25, 0.5, 1, 2, 4]
39
+
40
+ export default function SpeedControl({
41
+ value,
42
+ onChange,
43
+ onCommit,
44
+ min = 0.25,
45
+ max = 4,
46
+ step = 0.05,
47
+ presets = DEFAULT_PRESETS,
48
+ label,
49
+ idBase,
50
+ }: SpeedControlProps) {
51
+ const sliderId = idBase ? `${idBase}-speed-slider` : undefined
52
+
53
+ return (
54
+ <div className="flex flex-col gap-2">
55
+ <div className={`flex items-center text-[11px] text-[var(--editor-text)]/70 ${label ? 'justify-between' : 'justify-end'}`}>
56
+ {label && <span>{label}</span>}
57
+ <span className="font-mono text-[10px] text-[var(--editor-text)]/50">{value.toFixed(2)}×</span>
58
+ </div>
59
+ <Slider
60
+ id={sliderId}
61
+ min={min}
62
+ max={max}
63
+ step={step}
64
+ value={value}
65
+ aria-label={label ?? 'Speed'}
66
+ onChange={onChange}
67
+ onCommit={onCommit}
68
+ />
69
+ {/* Quick-set chips under the slider — a click snaps straight to that
70
+ value rather than dragging for it. The active one is marked with
71
+ `aria-pressed` and the accent fill, mirroring the toolbar's own
72
+ bold/italic toggle buttons (TextFormattingToolbar.tsx). */}
73
+ <div role="group" aria-label={label ? `${label} presets` : 'Speed presets'} className="flex flex-wrap gap-1">
74
+ {presets.map(p => {
75
+ const active = Math.abs(value - p) < 1e-6
76
+ return (
77
+ <button
78
+ key={p}
79
+ type="button"
80
+ aria-pressed={active}
81
+ onClick={() => { onChange(p); onCommit?.(p) }}
82
+ className={`rounded-md border px-2 py-0.5 text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] ${
83
+ active
84
+ ? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
85
+ : 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
86
+ }`}
87
+ >
88
+ {p}×
89
+ </button>
90
+ )
91
+ })}
92
+ </div>
93
+ </div>
94
+ )
95
+ }