@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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- 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
|
-
|
|
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
|
-
|
|
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 [
|
|
42
|
-
const
|
|
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="
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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-
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
}
|