@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,64 @@
1
+ /**
2
+ * The editor's branded loading indicator: a miniature of the Montaj mark — a
3
+ * film clapperboard (slate + play triangle) whose striped clapper claps open and
4
+ * shut. Used for every in-editor loading state (cover/frame sampling, render
5
+ * progress, overlay/schema fetches, engine warmup) so "loading" reads
6
+ * consistently and on-brand. NOT for streaming agent-status text (that keeps its
7
+ * own log/pending treatment).
8
+ *
9
+ * The slate uses the theme accent (with a literal fallback so it stays visible
10
+ * even where `--editor-accent` is out of scope, e.g. a body-portaled modal). The
11
+ * clap is a SMIL `animateTransform` rotating the clapper about its hinge — no
12
+ * CSS transform-origin ambiguity, no keyframe injection.
13
+ */
14
+
15
+ export interface LoaderProps {
16
+ /** Rendered pixel size. sm 20 (inline/thumbnail), md 34, lg 56 (hero). */
17
+ size?: 'sm' | 'md' | 'lg'
18
+ /** Optional caption under the mark (e.g. "Loading cover"). */
19
+ label?: string
20
+ /** Extra classes on the wrapper (layout/positioning). */
21
+ className?: string
22
+ }
23
+
24
+ const PX: Record<NonNullable<LoaderProps['size']>, number> = { sm: 20, md: 34, lg: 56 }
25
+
26
+ export function Loader({ size = 'md', label, className = '' }: LoaderProps) {
27
+ const px = PX[size]
28
+ return (
29
+ <div
30
+ role="status"
31
+ aria-label={label ?? 'Loading'}
32
+ className={`flex flex-col items-center justify-center gap-2 ${className}`}
33
+ >
34
+ <svg width={px} height={px} viewBox="0 0 64 56" fill="none" aria-hidden="true">
35
+ {/* Slate (the board) + the Montaj play triangle. */}
36
+ <rect x="8" y="24" width="48" height="24" rx="5" fill="var(--editor-accent, #38bdf8)" />
37
+ <path d="M28 31 L41 36 L28 41 Z" fill="#ffffff" fillOpacity="0.92" />
38
+
39
+ {/* Clapper stick — hinged at the slate's top-left (11,24). Rests open,
40
+ snaps shut (the clap), reopens; repeat. */}
41
+ <g>
42
+ <animateTransform
43
+ attributeName="transform"
44
+ attributeType="XML"
45
+ type="rotate"
46
+ values="-32 11 24; -32 11 24; 0 11 24; -32 11 24"
47
+ keyTimes="0; 0.32; 0.46; 1"
48
+ dur="1.1s"
49
+ repeatCount="indefinite"
50
+ calcMode="spline"
51
+ keySplines="0 0 1 1; 0.45 0 0.3 1; 0.4 0 0.2 1"
52
+ />
53
+ <rect x="8" y="14" width="48" height="10" rx="3" fill="#0b1220" />
54
+ <path d="M15 14 l7 10 h7 l-7 -10 z" fill="#ec4899" />
55
+ <path d="M29 14 l7 10 h7 l-7 -10 z" fill="#f97316" />
56
+ <path d="M43 14 l7 10 h7 l-7 -10 z" fill="#facc15" />
57
+ </g>
58
+ </svg>
59
+ {label && <span className="text-[11px] text-[var(--editor-text)]/50">{label}</span>}
60
+ </div>
61
+ )
62
+ }
63
+
64
+ export default Loader
@@ -0,0 +1,254 @@
1
+ import { useRef, useState } from 'react'
2
+ import { ChevronDown, ChevronUp } from 'lucide-react'
3
+ import { cn } from './utils'
4
+ import { inspectorInputClass } from './input'
5
+
6
+ /**
7
+ * `stepValue` — the arithmetic behind ONE stepper click: nudge `value` by one
8
+ * `step` in `direction`, clamp to `[min, max]`, then round away the float noise
9
+ * the nudge itself introduces (`1.2 + 0.01` is `1.2100000000000002`, and a
10
+ * stepper is supposed to be exact).
11
+ *
12
+ * Exported and separate from the component because a stepper's arithmetic is
13
+ * NOT always the input's own `step`/`min`/`max`: the Transform panel's Scale
14
+ * box displays a PERCENTAGE (bounded 5–400) while its stepper nudges the
15
+ * stored multiplier (`0.01`, floored at `0.01`). The component therefore
16
+ * delegates stepping to its caller (see {@link NumberFieldProps.onStep}) and
17
+ * callers share this helper, rather than the component guessing which unit the
18
+ * nudge is in and every call site re-deriving the same three lines.
19
+ *
20
+ * Rounds to 2 decimal places, matching what the panel DISPLAYS — a stepper that
21
+ * lands on a value the box then rounds off would drift a little further from
22
+ * the shown number on every click.
23
+ */
24
+ export function stepValue(
25
+ value: number,
26
+ direction: 1 | -1,
27
+ { step, min, max }: { step: number; min?: number; max?: number },
28
+ ): number {
29
+ let next = value + direction * step
30
+ if (min !== undefined) next = Math.max(min, next)
31
+ if (max !== undefined) next = Math.min(max, next)
32
+ return Math.round(next * 100) / 100
33
+ }
34
+
35
+ export interface NumberFieldProps {
36
+ /** The control's ACCESSIBLE name — the box's `aria-label`, and the stem of
37
+ * both stepper labels (`Increase ${name}` / `Decrease ${name}`). Kept
38
+ * deliberately separate from any visible label the caller renders beside
39
+ * the field: accessible names are a behavioural contract that tests and
40
+ * assistive tech both depend on, so visible chrome can be restyled without
41
+ * touching them. */
42
+ name: string
43
+ /** The live value. Owned by the caller — this component is controlled and
44
+ * holds no value state of its own beyond the mid-typing `draft` below.
45
+ *
46
+ * A STRING is allowed, and `''` specifically means "no explicit value": the
47
+ * box reads blank and its {@link NumberFieldProps.placeholder} reports
48
+ * whatever default is actually in force. That is not the same as zero, and
49
+ * a field that showed the default as a real value would invite the operator
50
+ * to save a value they never chose. */
51
+ value: number | string
52
+ /** Live-preview a typed edit: fired on EVERY keystroke that parses to a
53
+ * finite number. Empty, a lone `-`, and anything else unparseable are
54
+ * swallowed here rather than by each caller — mid-typing states are not
55
+ * edits and must not reach an undo stack or a save. */
56
+ onPreview: (value: number) => void
57
+ /** Close the typing gesture — ONE undo entry for the whole run of keystrokes,
58
+ * carrying the last value {@link NumberFieldProps.onPreview} emitted.
59
+ *
60
+ * Fired on blur, and on Enter via that same blur, but ONLY when the gesture
61
+ * actually previewed something: focusing a field and leaving it untouched
62
+ * writes nothing, because that would be a pointless save and an undo entry
63
+ * for a no-op. That guard is also what stops Enter-then-blur from
64
+ * committing twice.
65
+ *
66
+ * Takes the value even though some callers ignore it: a zero-argument
67
+ * handler is assignable to this signature, so the wider shape serves both
68
+ * the callers that close over their own last-previewed value and the ones
69
+ * that want the number handed to them. */
70
+ onCommit: (value: number) => void
71
+ /** One DISCRETE nudge — the caller is expected to emit one final edit (one
72
+ * undo entry), NOT a preview/commit pair. Omit to render no steppers.
73
+ * Takes a direction rather than a value because the nudge's unit is the
74
+ * caller's business; see {@link stepValue}. */
75
+ onStep?: (direction: 1 | -1) => void
76
+ /** Native `step`/`min`/`max` for the box.
77
+ *
78
+ * These drive the browser's own validation and native spinner ONLY. A TYPED
79
+ * value is deliberately NOT clamped against them, for two reasons: clamping
80
+ * between keystrokes fights the operator (typing `0.5` into a `min={1}` box
81
+ * passes through `0`, and `5` into a `max={400}` one passes through `5` on
82
+ * the way to `500`), and the two call sites disagree about whether a typed
83
+ * value should be bounded at all — the Transform panel's opacity row lets a
84
+ * typed `5` through where the caption panel's line height clamps `9` to
85
+ * `2.5`. So the component stays dumb and emits the raw parsed number; a
86
+ * caller that wants a bounded write clamps inside its own `onPreview`.
87
+ * The STEPPERS always clamp — that arithmetic is {@link stepValue}'s. */
88
+ step?: number
89
+ min?: number
90
+ max?: number
91
+ /** Shown when the box is blank. An empty string is meaningful and is
92
+ * rendered as a real empty `placeholder` attribute — a caller whose default
93
+ * has no value to report needs the attribute present and empty, not absent. */
94
+ placeholder?: string
95
+ /** Visible leading axis marker (`X`, `Y`). Purely decorative and hidden from
96
+ * assistive tech — the accessible name already carries the axis. */
97
+ prefix?: string
98
+ /** Visible trailing unit (`%`, `px`, `em`). Decorative, same reasoning. */
99
+ unit?: string
100
+ /** Passed through so a caller can wire a `<label htmlFor>` to the box. */
101
+ id?: string
102
+ /** Sizing/alignment override for the `<input>` itself (the wrapper's layout
103
+ * is fixed). Merged over {@link inspectorInputClass} via `cn`. */
104
+ className?: string
105
+ /** Disables the box AND its steppers together — a caller never wants one
106
+ * live while the other is dead (e.g. ClipPropertiesPanel's ducking
107
+ * depth/attack/release, inert until the Ducking switch is on). Styled the
108
+ * same `opacity-50 cursor-not-allowed` treatment ClipPropertiesPanel's
109
+ * `DraftField` already uses for its own disabled fields, so a disabled
110
+ * number box reads identically wherever it appears. */
111
+ disabled?: boolean
112
+ }
113
+
114
+ /**
115
+ * `<NumberField>` — the editor's ONE compact number control: a typeable box
116
+ * with up/down steppers, an optional axis prefix and unit, and the two-phase
117
+ * preview/commit typing gesture.
118
+ *
119
+ * `draft` is the fix for a real bug, and the main reason this is a component
120
+ * rather than a bare `<input>`. Panels that host these fields re-render on
121
+ * every playback tick (~60Hz) whether or not the thing being edited is even
122
+ * involved, and a CONTROLLED input whose `value` comes straight from the
123
+ * tick-recomputed prop has no memory of what is mid-typed — typing into a
124
+ * field while the timeline played back got silently overwritten between
125
+ * keystrokes. `draft` breaks that link: it goes non-null the moment the
126
+ * operator types (first `onChange`) and stays the single source of truth for
127
+ * what is ON SCREEN until blur/Enter commits it, so ticks arriving mid-edit
128
+ * change `value` underneath without ever touching what is displayed. While
129
+ * `draft` is null (the common case — not currently being typed into) the field
130
+ * tracks `value` live, exactly as an uncontrolled-feeling box should,
131
+ * including the desired case of watching it animate during scrub/playback.
132
+ *
133
+ * Purely presentational and controlled: it never parses meaning out of the
134
+ * number, never decides what a step is worth, never bounds what is typed, and
135
+ * never reaches into a store. Keyframe diamonds, align buttons and row labels
136
+ * belong to the caller.
137
+ */
138
+ export function NumberField({
139
+ name,
140
+ value,
141
+ onPreview,
142
+ onCommit,
143
+ onStep,
144
+ step,
145
+ min,
146
+ max,
147
+ placeholder,
148
+ prefix,
149
+ unit,
150
+ id,
151
+ className,
152
+ disabled,
153
+ }: NumberFieldProps) {
154
+ const [draft, setDraft] = useState<string | null>(null)
155
+ // Whether this typing gesture previewed anything worth committing, and what
156
+ // the last such preview was. A ref, not state: neither changes what is
157
+ // rendered, and a re-render between the keystroke and the blur would be the
158
+ // very tick `draft` exists to survive.
159
+ //
160
+ // Dirty-based rather than comparing the incoming `value` prop against
161
+ // `last.current`: live preview has ALREADY written the pending number into
162
+ // the project by the time blur fires (that is the whole point of
163
+ // `onPreview`), so by blur `value` typically equals the pending value
164
+ // anyway. A value-comparison guard ("only commit if it changed") would read
165
+ // that as nothing to do and skip the commit entirely — silently losing an
166
+ // edit that every caller believes it already made. `dirty` asks the
167
+ // question this component can actually answer honestly: did a keystroke
168
+ // happen, not does the prop look different yet.
169
+ const dirty = useRef(false)
170
+ const last = useRef(0)
171
+ const shown = draft ?? String(value)
172
+
173
+ function handleChange(raw: string) {
174
+ setDraft(raw)
175
+ // Mid-typing states (empty field, a lone minus, an unparseable fragment)
176
+ // are not edits — hold the draft on screen but emit nothing, and leave the
177
+ // gesture un-armed so blurring out of one commits nothing either.
178
+ if (raw.trim() === '') return
179
+ const parsed = Number(raw)
180
+ if (!Number.isFinite(parsed)) return
181
+ dirty.current = true
182
+ last.current = parsed
183
+ onPreview(parsed)
184
+ }
185
+
186
+ function commit() {
187
+ // Releasing the draft is unconditional — a box left blank or unparseable
188
+ // has to snap back to the value the project actually holds rather than sit
189
+ // there misreporting it. Committing is not: see `onCommit`'s contract.
190
+ setDraft(null)
191
+ if (!dirty.current) return
192
+ dirty.current = false
193
+ onCommit(last.current)
194
+ }
195
+
196
+ return (
197
+ <div className="flex shrink-0 items-center gap-1">
198
+ {prefix && (
199
+ <span aria-hidden="true" className="w-2 shrink-0 text-[10px] text-[var(--editor-text)]/40">
200
+ {prefix}
201
+ </span>
202
+ )}
203
+ <input
204
+ id={id}
205
+ type="number"
206
+ aria-label={name}
207
+ step={step}
208
+ min={min}
209
+ max={max}
210
+ placeholder={placeholder}
211
+ value={shown}
212
+ disabled={disabled}
213
+ onChange={e => handleChange(e.target.value)}
214
+ onBlur={commit}
215
+ // Enter closes the typing gesture the same way blur does — it does not
216
+ // duplicate the commit logic, just triggers the same onBlur path.
217
+ onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
218
+ className={cn(inspectorInputClass, 'text-right disabled:opacity-50 disabled:cursor-not-allowed', className)}
219
+ />
220
+ {unit && (
221
+ <span aria-hidden="true" className="text-[10px] text-[var(--editor-text)]/40">
222
+ {unit}
223
+ </span>
224
+ )}
225
+ {onStep && <Stepper name={name} onStep={onStep} disabled={disabled} />}
226
+ </div>
227
+ )
228
+ }
229
+
230
+ /** Up/down nudge buttons for one number box. Each click is a DISCRETE edit —
231
+ * one final change, one undo entry — not a preview/commit pair.
232
+ *
233
+ * `disabled` rides along from the box: a disabled input with live arrows
234
+ * next to it would let the operator nudge a value the box itself refuses to
235
+ * show as editable, so the buttons carry the SAME native `disabled`
236
+ * attribute (which already blocks both the click and any keyboard
237
+ * activation — no separate pointer-events/aria bookkeeping needed) rather
238
+ * than only being dimmed for show. */
239
+ function Stepper({ name, onStep, disabled }: { name: string; onStep: (direction: 1 | -1) => void; disabled?: boolean }) {
240
+ const btn = cn(
241
+ 'flex h-3 w-4 items-center justify-center rounded-sm text-[var(--editor-text)]/45 transition-colors',
242
+ disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]',
243
+ )
244
+ return (
245
+ <div className="flex shrink-0 flex-col gap-px">
246
+ <button type="button" aria-label={`Increase ${name}`} onClick={() => onStep(1)} disabled={disabled} className={btn}>
247
+ <ChevronUp size={9} />
248
+ </button>
249
+ <button type="button" aria-label={`Decrease ${name}`} onClick={() => onStep(-1)} disabled={disabled} className={btn}>
250
+ <ChevronDown size={9} />
251
+ </button>
252
+ </div>
253
+ )
254
+ }
@@ -0,0 +1,128 @@
1
+ import { useRef } from 'react'
2
+ import { cn } from './utils'
3
+
4
+ export interface SliderProps {
5
+ /** The live value. Owned by the caller — this control is fully controlled. */
6
+ value: number
7
+ min: number
8
+ max: number
9
+ step?: number
10
+ /** Live-preview a drag/keyboard adjustment: fired on EVERY change, no undo
11
+ * entry, no save. Named `onChange` rather than `onPreview` because a caller
12
+ * that wants no two-phase gesture at all (a local scrub, say) simply omits
13
+ * {@link SliderProps.onCommit} and this is its only callback. */
14
+ onChange: (value: number) => void
15
+ /** Close the gesture — one undo entry for the whole drag. Fired at most ONCE
16
+ * per gesture, and only when the gesture actually moved something; see the
17
+ * `dirty` ref below. Omit for a slider whose changes are already final (or
18
+ * are purely local view state).
19
+ *
20
+ * Takes the value even though some callers ignore it: a zero-argument
21
+ * handler is assignable to this signature, so the wider shape serves both
22
+ * the callers that close over their own last-previewed value and the ones
23
+ * that want the number handed to them. */
24
+ onCommit?: (value: number) => void
25
+ /** Accessible name. Required — the control has no visible text of its own,
26
+ * and every call site sits beside a separate label element rather than
27
+ * inside one. */
28
+ 'aria-label': string
29
+ /** Passed through so a caller can wire a `<label htmlFor>` to the input. */
30
+ id?: string
31
+ /** Sizing override. Defaults to full width; a flex-row caller typically
32
+ * passes `min-w-0 flex-1`. */
33
+ className?: string
34
+ disabled?: boolean
35
+ }
36
+
37
+ /**
38
+ * Track + thumb chrome. Split out as a constant so the comment explaining WHY
39
+ * it looks like this isn't wedged into the middle of the JSX.
40
+ *
41
+ * `appearance-none` strips the platform slider, after which Chrome/Safari take
42
+ * the track styling off the INPUT itself (hence `h-1` + `bg-` + `rounded-full`
43
+ * on the element) while Firefox needs `::-moz-range-track` said separately.
44
+ * The thumb has to be restated per engine for the same reason — there is no
45
+ * cross-engine selector for it, and a bare `accent-color` cannot produce the
46
+ * thin-track/round-thumb pairing at all: it colors the platform widget, whose
47
+ * track thickness and thumb shape are not ours to set.
48
+ *
49
+ * Every color is a `--editor-*` var, never a literal, so the control follows
50
+ * the host's light/dark theme like the rest of the editor chrome. The accent
51
+ * var is Montaj's indigo in both themes (see theme.ts), which is where the
52
+ * indigo thumb comes from.
53
+ */
54
+ const SLIDER_CLASS = cn(
55
+ 'h-1 w-full cursor-pointer appearance-none rounded-full bg-[var(--editor-border)] outline-none',
56
+ 'focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)]',
57
+ 'disabled:cursor-not-allowed disabled:opacity-50',
58
+ // WebKit/Blink: track is the element, thumb is the pseudo-element.
59
+ '[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none',
60
+ '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0',
61
+ '[&::-webkit-slider-thumb]:bg-[var(--editor-accent)] [&::-webkit-slider-thumb]:cursor-grab',
62
+ // Gecko: both track and thumb are pseudo-elements.
63
+ '[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--editor-border)]',
64
+ '[&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:rounded-full',
65
+ '[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[var(--editor-accent)] [&::-moz-range-thumb]:cursor-grab',
66
+ )
67
+
68
+ /**
69
+ * `<Slider>` — the editor's ONE horizontal range control.
70
+ *
71
+ * Previews per change and commits ONCE per gesture: `dirty` arms on the first
72
+ * change and is disarmed by whichever gesture-end fires first, so the pointerup
73
+ * that is immediately followed by a blur cannot stack two undo entries onto the
74
+ * same drag. `onKeyUp` is in that set so a keyboard adjustment commits too, and
75
+ * a gesture that never changed anything commits nothing at all — a stray click
76
+ * on the track's current position must not spend an undo entry on a no-op.
77
+ *
78
+ * Still a native `<input type="range">` underneath, deliberately: it is
79
+ * keyboard-operable, exposes `role="slider"` with correct value semantics, and
80
+ * handles pointer capture for free. Only its LOOK is replaced.
81
+ */
82
+ export function Slider({
83
+ value,
84
+ min,
85
+ max,
86
+ step,
87
+ onChange,
88
+ onCommit,
89
+ 'aria-label': ariaLabel,
90
+ id,
91
+ className,
92
+ disabled,
93
+ }: SliderProps) {
94
+ const dirty = useRef(false)
95
+ // The last value actually previewed, so `end` reports what the caller saw
96
+ // rather than re-reading the DOM (blur's target is the input, but so is a
97
+ // programmatic one — the ref is simply the value we know we emitted).
98
+ const last = useRef(value)
99
+
100
+ function end() {
101
+ if (!dirty.current) return
102
+ dirty.current = false
103
+ onCommit?.(last.current)
104
+ }
105
+
106
+ return (
107
+ <input
108
+ id={id}
109
+ type="range"
110
+ aria-label={ariaLabel}
111
+ min={min}
112
+ max={max}
113
+ step={step}
114
+ value={value}
115
+ disabled={disabled}
116
+ onChange={e => {
117
+ const next = Number(e.target.value)
118
+ dirty.current = true
119
+ last.current = next
120
+ onChange(next)
121
+ }}
122
+ onPointerUp={end}
123
+ onKeyUp={end}
124
+ onBlur={end}
125
+ className={cn(SLIDER_CLASS, className)}
126
+ />
127
+ )
128
+ }
@@ -0,0 +1,118 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ /**
5
+ * A small dark pill that appears instantly on hover — the editor's replacement
6
+ * for native `title=` on toolbar buttons, which browsers delay ~1s and render
7
+ * in OS chrome nobody notices.
8
+ *
9
+ * ── Why a portal ─────────────────────────────────────────────────────────
10
+ * The tool buttons live inside containers that clip: the timeline's zoom row
11
+ * sits above an `overflow-x-auto` scroller (which computes `overflow-y` to
12
+ * `auto`, so it clips vertically too), and the editor body is `overflow-hidden`.
13
+ * An absolutely-positioned pill inside the trigger would be cut off by both.
14
+ * Rendering into `document.body` at fixed coordinates measured from the
15
+ * trigger sidesteps every ancestor's overflow.
16
+ *
17
+ * The trigger is wrapped in an `inline-flex` span rather than cloned, so the
18
+ * wrapper becomes the flex item in the toolbar row and the button inside keeps
19
+ * its own box untouched. Pass `className` to move layout utilities that used
20
+ * to sit on the button (e.g. `mr-auto`) onto the wrapper.
21
+ */
22
+ export interface TooltipProps {
23
+ /** Terse label — "Undo", "Split at playhead". Not a sentence. */
24
+ label: string
25
+ /** Optional shortcut chips rendered after the label, e.g. `['⌘', 'Z']`. */
26
+ keys?: string[]
27
+ /** Which side of the trigger the pill sits on. */
28
+ side?: 'top' | 'bottom'
29
+ /** Layout classes for the wrapper span (it is the flex item, not the child). */
30
+ className?: string
31
+ children: ReactNode
32
+ }
33
+
34
+ /** Gap in px between the trigger's edge and the pill. */
35
+ const OFFSET_PX = 6
36
+ /** Keeps the pill from hanging off either edge of the window. */
37
+ const VIEWPORT_MARGIN_PX = 4
38
+
39
+ interface Position {
40
+ x: number
41
+ y: number
42
+ side: 'top' | 'bottom'
43
+ }
44
+
45
+ export function Tooltip({ label, keys, side = 'top', className, children }: TooltipProps) {
46
+ const wrapperRef = useRef<HTMLSpanElement>(null)
47
+ const pillRef = useRef<HTMLDivElement>(null)
48
+ const [position, setPosition] = useState<Position | null>(null)
49
+
50
+ const show = useCallback(() => {
51
+ const rect = wrapperRef.current?.getBoundingClientRect()
52
+ if (!rect) return
53
+ setPosition({
54
+ x: rect.left + rect.width / 2,
55
+ y: side === 'top' ? rect.top - OFFSET_PX : rect.bottom + OFFSET_PX,
56
+ side,
57
+ })
58
+ }, [side])
59
+
60
+ const hide = useCallback(() => setPosition(null), [])
61
+
62
+ // Measured after paint, once the pill has a width: a button near the right
63
+ // edge of the window would otherwise render a centred pill that overflows.
64
+ // Clamping here (rather than at `show`) is what lets it use the real width.
65
+ useLayoutEffect(() => {
66
+ if (!position) return
67
+ const pill = pillRef.current
68
+ if (!pill) return
69
+ const half = pill.offsetWidth / 2
70
+ const min = VIEWPORT_MARGIN_PX + half
71
+ const max = window.innerWidth - VIEWPORT_MARGIN_PX - half
72
+ if (max < min) return
73
+ const clamped = Math.max(min, Math.min(max, position.x))
74
+ if (clamped !== position.x) setPosition({ ...position, x: clamped })
75
+ }, [position])
76
+
77
+ return (
78
+ <span
79
+ ref={wrapperRef}
80
+ className={className ? `inline-flex ${className}` : 'inline-flex'}
81
+ onMouseEnter={show}
82
+ onMouseLeave={hide}
83
+ // A click that opens a modal (or disables the button) leaves the pill
84
+ // orphaned with no `mouseleave` to follow, so dismiss on press too.
85
+ onMouseDown={hide}
86
+ // Keyboard users get the same affordance; `focusout` covers tabbing away.
87
+ onFocus={show}
88
+ onBlur={hide}
89
+ >
90
+ {children}
91
+ {position && typeof document !== 'undefined' && createPortal(
92
+ <div
93
+ ref={pillRef}
94
+ role="tooltip"
95
+ className="pointer-events-none fixed z-[100] flex items-center gap-1 whitespace-nowrap rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] px-1.5 py-1 text-[10px] leading-none text-[var(--editor-text)] shadow-lg"
96
+ style={{
97
+ left: position.x,
98
+ top: position.y,
99
+ transform: `translate(-50%, ${position.side === 'top' ? '-100%' : '0'})`,
100
+ }}
101
+ >
102
+ <span>{label}</span>
103
+ {keys?.map((k, i) => (
104
+ <kbd
105
+ key={i}
106
+ className="rounded border border-[var(--editor-border)] bg-[var(--editor-text)]/10 px-1 py-0.5 font-mono text-[9px] leading-none text-[var(--editor-text)]/70"
107
+ >
108
+ {k}
109
+ </kbd>
110
+ ))}
111
+ </div>,
112
+ document.body,
113
+ )}
114
+ </span>
115
+ )
116
+ }
117
+
118
+ export default Tooltip