@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,258 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Slider } from '../ui'
4
+
5
+ /**
6
+ * The editor-relevant slice of a version — matches `VersionEntry` in
7
+ * ../types, kept structural here so this file has no dependency direction on
8
+ * the caller's exact type (mirrors VersionPanel's own `ProjectVersion`).
9
+ */
10
+ interface CompareVersionEntry {
11
+ hash: string
12
+ message: string
13
+ timestamp: string
14
+ }
15
+
16
+ export interface VersionCompareProps {
17
+ projectId: string
18
+ /** Dedup'd list from the panel (the same entries VersionPanel renders). */
19
+ versions: CompareVersionEntry[]
20
+ /** The version the user clicked "Compare" on — seeds the LEFT picker. */
21
+ initialLeftHash: string
22
+ /** Builds the `<img src>` for a rendered frame of `commit` (a version hash,
23
+ * or the sentinel `"working"` for the live on-disk state) at `t` seconds. */
24
+ frameUrl: (id: string, commit: string, t: number) => string
25
+ /** Slider max, in seconds. Defaults to 30 when the host can't cheaply
26
+ * compute the project's real duration. */
27
+ durationSeconds?: number
28
+ onClose: () => void
29
+ /** Editor theme mode — light/dark. Only affects the frame panes' load-error
30
+ * text (red-400 is sub-AA on the light `--editor-bg` placeholder box).
31
+ * Absent -> dark, matching every existing caller. */
32
+ mode?: 'light' | 'dark'
33
+ }
34
+
35
+ /** Sentinel commit id for "the live on-disk state", matching the backend's
36
+ * `GET .../versions/:commit/frame` contract (T8a). Not a real git hash. */
37
+ const WORKING = 'working'
38
+
39
+ /** Slider ceiling when the host doesn't pass a real project duration. */
40
+ const DEFAULT_DURATION_SECONDS = 30
41
+
42
+ /** Debounce (ms) between the slider's raw drag value and the value that
43
+ * actually drives the `<img src>` — mirrors RenderModal's cover-frame
44
+ * slider debounce so dragging doesn't fire a frame render per pixel. */
45
+ const SCRUB_DEBOUNCE_MS = 200
46
+
47
+ /** Human label for a picker option / pane header: "Current (working)" for the
48
+ * sentinel, else the version's message (falling back to a short hash). */
49
+ function labelFor(hash: string, versions: CompareVersionEntry[]): string {
50
+ if (hash === WORKING) return 'Current (working)'
51
+ const v = versions.find(v => v.hash === hash)
52
+ return v?.message?.trim() || hash.slice(0, 8)
53
+ }
54
+
55
+ /** mm:ss.d for the scrub readout. */
56
+ function formatT(sec: number): string {
57
+ return `${sec.toFixed(1)}s`
58
+ }
59
+
60
+ function VersionFramePane({
61
+ label,
62
+ projectId,
63
+ commit,
64
+ t,
65
+ frameUrl,
66
+ mode = 'dark',
67
+ }: {
68
+ label: string
69
+ projectId: string
70
+ commit: string
71
+ t: number
72
+ frameUrl: (id: string, commit: string, t: number) => string
73
+ mode?: 'light' | 'dark'
74
+ }) {
75
+ const src = frameUrl(projectId, commit, t)
76
+ const [loading, setLoading] = useState(true)
77
+ const [error, setError] = useState(false)
78
+
79
+ // A new src (different commit/time) resets the pane to its loading state —
80
+ // `onLoadStart` doesn't fire reliably for every `<img>` src swap across
81
+ // browsers, so gate on the src changing directly.
82
+ useEffect(() => {
83
+ setLoading(true)
84
+ setError(false)
85
+ }, [src])
86
+
87
+ return (
88
+ <div className="flex-1 min-w-0 flex flex-col gap-1.5">
89
+ <span className="text-[11px] font-medium text-[var(--editor-text)]/60 truncate" title={label}>
90
+ {label}
91
+ </span>
92
+ <div className="relative aspect-video w-full overflow-hidden rounded-lg border border-[var(--editor-border)] bg-[var(--editor-bg)] flex items-center justify-center">
93
+ <img
94
+ key={src}
95
+ src={src}
96
+ alt={`${label} frame at ${formatT(t)}`}
97
+ onLoadStart={() => setLoading(true)}
98
+ onLoad={() => setLoading(false)}
99
+ onError={() => { setLoading(false); setError(true) }}
100
+ className="max-w-full max-h-full object-contain"
101
+ style={error ? { display: 'none' } : undefined}
102
+ />
103
+ {loading && !error && (
104
+ <span className="absolute inset-0 flex items-center justify-center text-xs text-[var(--editor-text)]/50">
105
+ Loading…
106
+ </span>
107
+ )}
108
+ {error && (
109
+ <span className={`absolute inset-0 flex items-center justify-center text-xs px-3 text-center ${mode === 'light' ? 'text-red-600' : 'text-red-400/90'}`}>
110
+ Error rendering frame
111
+ </span>
112
+ )}
113
+ </div>
114
+ </div>
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Visual A/B compare for project versions — two rendered-frame panes, a
120
+ * shared time-scrub slider, and LEFT/RIGHT version pickers. Deliberately has
121
+ * NO textual diff/summary: comparison is purely "what does this frame look
122
+ * like" between two commits (or a commit and the live working state).
123
+ */
124
+ export default function VersionCompare({
125
+ projectId,
126
+ versions,
127
+ initialLeftHash,
128
+ frameUrl,
129
+ durationSeconds,
130
+ onClose,
131
+ mode = 'dark',
132
+ }: VersionCompareProps) {
133
+ const duration = durationSeconds && durationSeconds > 0 ? durationSeconds : DEFAULT_DURATION_SECONDS
134
+
135
+ // "Current (working)" always heads the list — it's a synthetic sentinel,
136
+ // not a real entry in `versions`.
137
+ const options = useMemo<CompareVersionEntry[]>(
138
+ () => [{ hash: WORKING, message: 'Current (working)', timestamp: '' }, ...versions],
139
+ [versions],
140
+ )
141
+
142
+ const [leftHash, setLeftHash] = useState(initialLeftHash)
143
+ const [rightHash, setRightHash] = useState<string>(() => {
144
+ // The common case: compare the clicked version against the live state.
145
+ // The only way `initialLeftHash` could already BE "working" is a future
146
+ // caller wiring Compare from somewhere other than a version-list entry —
147
+ // guard it anyway so RIGHT never silently mirrors LEFT.
148
+ if (initialLeftHash !== WORKING) return WORKING
149
+ const idx = versions.findIndex(v => v.hash === initialLeftHash)
150
+ return versions[idx + 1]?.hash ?? versions[0]?.hash ?? WORKING
151
+ })
152
+
153
+ // Slider value updates immediately for the readout; the value that drives
154
+ // the `<img src>` (and thus a frame render) is debounced so a drag doesn't
155
+ // fire one render per pixel.
156
+ const [t, setT] = useState(duration / 2)
157
+ const [sampleT, setSampleT] = useState(t)
158
+ useEffect(() => {
159
+ const id = setTimeout(() => setSampleT(t), SCRUB_DEBOUNCE_MS)
160
+ return () => clearTimeout(id)
161
+ }, [t])
162
+
163
+ useEffect(() => {
164
+ const onKey = (e: KeyboardEvent) => {
165
+ if (e.key === 'Escape') onClose()
166
+ }
167
+ document.addEventListener('keydown', onKey)
168
+ return () => document.removeEventListener('keydown', onKey)
169
+ }, [onClose])
170
+
171
+ return createPortal(
172
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
173
+ <div className="w-full max-w-5xl bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
174
+
175
+ {/* Header */}
176
+ <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
177
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">Compare versions</h2>
178
+ <button
179
+ onClick={onClose}
180
+ aria-label="Close"
181
+ className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
182
+ >
183
+ ×
184
+ </button>
185
+ </div>
186
+
187
+ {/* Body */}
188
+ <div className="flex flex-col gap-4 px-5 py-4 max-h-[75vh] overflow-y-auto">
189
+
190
+ {/* Picker row */}
191
+ <div className="flex flex-col sm:flex-row gap-3">
192
+ <label className="flex-1 min-w-0 flex flex-col gap-1.5">
193
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Left</span>
194
+ <select
195
+ value={leftHash}
196
+ onChange={(e) => setLeftHash(e.target.value)}
197
+ className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-bg)] border border-[var(--editor-border)] text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] transition-colors"
198
+ >
199
+ {options.map(o => (
200
+ <option key={o.hash} value={o.hash}>{labelFor(o.hash, versions)}</option>
201
+ ))}
202
+ </select>
203
+ </label>
204
+ <label className="flex-1 min-w-0 flex flex-col gap-1.5">
205
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Right</span>
206
+ <select
207
+ value={rightHash}
208
+ onChange={(e) => setRightHash(e.target.value)}
209
+ className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-bg)] border border-[var(--editor-border)] text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] transition-colors"
210
+ >
211
+ {options.map(o => (
212
+ <option key={o.hash} value={o.hash}>{labelFor(o.hash, versions)}</option>
213
+ ))}
214
+ </select>
215
+ </label>
216
+ </div>
217
+
218
+ {/* Frame panes */}
219
+ <div className="flex flex-col sm:flex-row gap-3">
220
+ <VersionFramePane
221
+ label={labelFor(leftHash, versions)}
222
+ projectId={projectId}
223
+ commit={leftHash}
224
+ t={sampleT}
225
+ frameUrl={frameUrl}
226
+ mode={mode}
227
+ />
228
+ <VersionFramePane
229
+ label={labelFor(rightHash, versions)}
230
+ projectId={projectId}
231
+ commit={rightHash}
232
+ t={sampleT}
233
+ frameUrl={frameUrl}
234
+ mode={mode}
235
+ />
236
+ </div>
237
+
238
+ {/* Time-scrub slider */}
239
+ <div className="flex flex-col gap-1.5">
240
+ <div className="flex items-center justify-between">
241
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Time</span>
242
+ <span className="text-xs text-[var(--editor-text)]/60 tabular-nums">{formatT(t)}</span>
243
+ </div>
244
+ <Slider
245
+ min={0}
246
+ max={duration}
247
+ step={0.1}
248
+ value={t}
249
+ onChange={setT}
250
+ aria-label="Scrub time"
251
+ />
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>,
256
+ document.body,
257
+ )
258
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { Save } from 'lucide-react'
2
3
  import type { VersionEntry } from '../types'
3
4
 
4
5
  // VersionPanel reads only the editor-relevant slice of a version — hash,
@@ -16,68 +17,142 @@ function parseVersion(v: ProjectVersion): { run: number; label: string } {
16
17
  return m ? { run: parseInt(m[1]), label: m[2] } : { run: 0, label: v.message }
17
18
  }
18
19
 
19
- function dedupeVersions(versions: ProjectVersion[]): ProjectVersion[] {
20
+ const KNOWN_LABELS: Record<string, string> = {
21
+ draft: 'Draft',
22
+ final: 'Final',
23
+ pending: 'Pending',
24
+ export: 'Exported',
25
+ 'autosave before restore': 'Auto-save before restore',
26
+ }
27
+
28
+ /** Turns a raw parsed label into what the operator sees. Known backend labels
29
+ * get a human-readable name; an empty label (no message) reads as "Untitled
30
+ * save" rather than blank; anything else is an operator-typed name and is
31
+ * shown verbatim, unmangled apart from surrounding whitespace. Returns the
32
+ * TRIMMED name, not the raw one: the save path already trims what it stores
33
+ * (`nameInput.trim()` below), so stray padding here only ever comes from a
34
+ * hand-edited commit message, and rendering it would just misalign the row. */
35
+ export function humanizeLabel(label: string): string {
36
+ const trimmed = label.trim()
37
+ if (trimmed === '') return 'Untitled save'
38
+ const known = KNOWN_LABELS[trimmed.toLowerCase()]
39
+ return known ?? trimmed
40
+ }
41
+
42
+ function timestampMs(v: ProjectVersion): number {
43
+ const ms = Date.parse(v.timestamp)
44
+ return Number.isNaN(ms) ? 0 : ms
45
+ }
46
+
47
+ /** Every saved version gets its own row — no collapsing by backend "run".
48
+ * Only the run-0 init baseline is filtered out. Sorted newest first by
49
+ * timestamp; the sort is stable so same-timestamp entries keep their input
50
+ * order, and it never mutates the caller's array. */
51
+ export function listVersions(versions: ProjectVersion[]): ProjectVersion[] {
20
52
  const nonInit = versions.filter(v => parseVersion(v).run > 0)
21
- const byRun = new Map<number, ProjectVersion>()
22
- for (const v of nonInit) {
23
- const { run, label } = parseVersion(v)
24
- const existing = byRun.get(run)
25
- const isDefault = label === 'draft' || label === 'final' || label === 'pending'
26
- if (!existing) { byRun.set(run, v); continue }
27
- const { label: existingLabel } = parseVersion(existing)
28
- const existingIsDefault = existingLabel === 'draft' || existingLabel === 'final' || existingLabel === 'pending'
29
- if (existingIsDefault && !isDefault) byRun.set(run, v)
30
- }
31
- return [...byRun.values()].sort((a, b) => parseVersion(b).run - parseVersion(a).run)
53
+ return [...nonInit].sort((a, b) => timestampMs(b) - timestampMs(a))
32
54
  }
33
55
 
34
56
  interface VersionPanelProps {
35
57
  versions: ProjectVersion[]
36
58
  restoring: string | null
37
59
  onRestore: (hash: string) => void
60
+ /** Saves the current project state as a new named version. Absent when the
61
+ * host adapter doesn't support it — the Save affordance disables itself
62
+ * rather than disappearing, so the panel's layout doesn't jump. */
63
+ onSaveVersion?: (name?: string) => void
64
+ saving?: boolean
65
+ /** Opens the visual A/B compare view seeded on this version's hash. Absent
66
+ * when the host adapter has no `versionFrameUrl` — the Compare button then
67
+ * just doesn't render, matching Restore/Save's degrade-gracefully pattern. */
68
+ onCompareVersion?: (hash: string) => void
69
+ /** Editor theme mode — light/dark. Only affects the Restore button's hue
70
+ * (indigo-300 is sub-AA on a light `--editor-surface` card). Absent ->
71
+ * dark, matching every existing caller. */
72
+ mode?: 'light' | 'dark'
38
73
  }
39
74
 
40
- export default function VersionPanel({ versions, restoring, onRestore }: VersionPanelProps) {
41
- const [open, setOpen] = useState(true)
42
- const deduped = dedupeVersions(versions)
75
+ export default function VersionPanel({ versions, restoring, onRestore, onSaveVersion, saving, onCompareVersion, mode = 'dark' }: VersionPanelProps) {
76
+ const [nameInput, setNameInput] = useState('')
77
+ const rows = listVersions(versions)
78
+
79
+ function handleSaveClick() {
80
+ onSaveVersion?.(nameInput.trim() || undefined)
81
+ setNameInput('')
82
+ }
43
83
 
44
84
  return (
45
- <div className="shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden" style={{ maxHeight: open ? 224 : 0, transition: 'max-height 0.15s ease' }}>
46
- <button
47
- onClick={() => setOpen(o => !o)}
48
- className="flex items-center justify-between px-3 py-2 border-b border-[var(--editor-border)] hover:bg-[var(--editor-surface)] transition-colors w-full text-left"
49
- >
85
+ <div className="flex-1 min-h-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden">
86
+ {/* No collapse control: Versions has its own tab now, so the whole panel
87
+ IS the list. The count sits on the right as an explicit "N versions"
88
+ label so it reads as a quantity rather than part of the title. */}
89
+ <div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-[var(--editor-border)]">
50
90
  <span className="text-xs font-medium text-[var(--editor-text)]/60 uppercase tracking-wide">Versions</span>
51
- <span className="text-[var(--editor-text)]/50 text-[10px]">{open ? '▲' : '▼'}</span>
52
- </button>
53
- <div className="overflow-y-auto p-2 flex flex-col gap-1.5">
54
- {deduped.length === 0 ? (
55
- <p className="text-xs text-[var(--editor-text)]/55 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
56
- ) : deduped.map(v => {
57
- const { run, label } = parseVersion(v)
58
- const isDefault = label === 'draft' || label === 'final' || label === 'pending'
91
+ {rows.length > 0 && (
92
+ <span className="text-[10px] text-[var(--editor-text)] opacity-50">
93
+ {rows.length} {rows.length === 1 ? 'version' : 'versions'}
94
+ </span>
95
+ )}
96
+ </div>
97
+
98
+ {/* Save affordance — always visible (not gated on `open`) so saving a
99
+ version doesn't require expanding the list first. Disabled rather
100
+ than hidden when the host has no `onSaveVersion`, matching the
101
+ Restore buttons' pattern of degrading gracefully. */}
102
+ <div className="shrink-0 flex items-center gap-2 px-3 py-2.5 border-b border-[var(--editor-border)]">
103
+ <input
104
+ type="text"
105
+ value={nameInput}
106
+ onChange={e => setNameInput(e.target.value)}
107
+ placeholder="Name (optional)"
108
+ disabled={saving || !onSaveVersion}
109
+ className="min-w-0 flex-1 h-8 text-[11px] bg-[var(--editor-bg)] border border-[var(--editor-border)] rounded-md px-2 text-[var(--editor-text)] placeholder:text-[var(--editor-text)]/40 focus:outline-none focus:border-[var(--editor-accent)] disabled:opacity-40"
110
+ />
111
+ <button
112
+ onClick={handleSaveClick}
113
+ disabled={saving || !onSaveVersion}
114
+ className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-white text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
115
+ >
116
+ <Save size={13} />
117
+ {saving ? 'Saving…' : 'Save version'}
118
+ </button>
119
+ </div>
120
+
121
+ <div className="flex-1 min-h-0 overflow-y-auto">
122
+ <div className="p-2 flex flex-col gap-1.5">
123
+ {rows.length === 0 ? (
124
+ <p className="text-xs text-[var(--editor-text)] opacity-55 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
125
+ ) : rows.map(v => {
126
+ const { label } = parseVersion(v)
127
+ const name = humanizeLabel(label)
59
128
  return (
60
- <div key={v.hash} className="rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] p-2 flex flex-col gap-1">
129
+ <div key={v.hash} className="rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] p-2.5 flex flex-col gap-2">
130
+ <div className="flex flex-col gap-0.5">
131
+ <span className="text-xs font-medium text-[var(--editor-text)] truncate" title={name}>{name}</span>
132
+ <span className="text-[10px] text-[var(--editor-text)] opacity-55">{formatTime(v.timestamp)}</span>
133
+ </div>
61
134
  <div className="flex items-center gap-1.5">
62
- <span className="text-[10px] text-[var(--editor-text)]/50 shrink-0">Run {run}</span>
63
- {isDefault ? (
64
- <span className="text-[10px] text-[var(--editor-text)]/60 capitalize">{label}</span>
65
- ) : (
66
- <span className="text-xs font-medium text-[var(--editor-text)] truncate capitalize" title={label}>{label}</span>
135
+ <button
136
+ onClick={() => onRestore(v.hash)}
137
+ disabled={restoring === v.hash}
138
+ className={`flex items-center h-6 px-2 rounded border border-indigo-500/50 text-[10px] font-medium hover:bg-indigo-500/10 transition-colors disabled:opacity-40 ${mode === 'light' ? 'text-indigo-700' : 'text-indigo-300'}`}
139
+ >
140
+ {restoring === v.hash ? 'Restoring…' : 'Restore →'}
141
+ </button>
142
+ {onCompareVersion && (
143
+ <button
144
+ onClick={() => onCompareVersion(v.hash)}
145
+ className="flex items-center h-6 px-2 rounded border border-[var(--editor-border)] text-[10px] font-medium text-[var(--editor-text)] opacity-70 hover:opacity-100 transition-opacity"
146
+ >
147
+ Compare
148
+ </button>
67
149
  )}
68
150
  </div>
69
- <span className="text-[10px] text-[var(--editor-text)]/55">{formatTime(v.timestamp)}</span>
70
- <button
71
- onClick={() => onRestore(v.hash)}
72
- disabled={restoring === v.hash}
73
- className="text-[10px] text-[var(--editor-accent)] hover:opacity-80 text-left transition-colors disabled:opacity-40"
74
- >
75
- {restoring === v.hash ? 'Restoring…' : 'Restore →'}
76
- </button>
77
151
  </div>
78
152
  )
79
153
  })}
80
154
  </div>
155
+ </div>
81
156
  </div>
82
157
  )
83
158
  }