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