@bycrux/editor 0.8.8 → 0.9.0
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 +1 -1
- package/src/carousel/CarouselEditor.tsx +34 -14
- package/src/carousel/SlidePropertyPanel.tsx +1 -35
- package/src/schema.ts +13 -3
- package/src/ui/SwatchInput.tsx +57 -0
- package/src/ui/index.ts +2 -0
- package/src/video/VideoEditor.tsx +10 -1
- package/src/video/preview/CaptionPreview.tsx +10 -1
- package/src/video/timeline/Timeline.tsx +6 -1
- package/src/video/timeline/TranscriptPanel.tsx +131 -2
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { RefreshCw, AlertCircle, Download, Info } from 'lucide-react'
|
|
2
|
+
import { RefreshCw, AlertCircle, Download, Info, Undo2, Redo2 } from 'lucide-react'
|
|
3
3
|
import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
|
|
4
4
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
5
5
|
import { useProjectState } from '../state/use-project-state'
|
|
@@ -409,19 +409,39 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
409
409
|
{/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
|
|
410
410
|
right. Pinned (shrink-0) above the scrolling canvas area. */}
|
|
411
411
|
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
412
|
+
<div className="flex items-center gap-2">
|
|
413
|
+
<button
|
|
414
|
+
onClick={handleRefresh}
|
|
415
|
+
disabled={refreshing}
|
|
416
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
|
|
417
|
+
refreshState === 'err'
|
|
418
|
+
? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
|
|
419
|
+
: 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
|
|
420
|
+
}`}
|
|
421
|
+
title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
|
|
422
|
+
>
|
|
423
|
+
{refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
|
|
424
|
+
<span className="text-xs font-medium">Refresh</span>
|
|
425
|
+
</button>
|
|
426
|
+
<button
|
|
427
|
+
onClick={() => state.undo()}
|
|
428
|
+
disabled={!state.canUndo}
|
|
429
|
+
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
430
|
+
title="Undo (Cmd/Ctrl+Z)"
|
|
431
|
+
aria-label="Undo"
|
|
432
|
+
>
|
|
433
|
+
<Undo2 size={18} />
|
|
434
|
+
</button>
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => state.redo()}
|
|
437
|
+
disabled={!state.canRedo}
|
|
438
|
+
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
439
|
+
title="Redo (Cmd/Ctrl+Shift+Z)"
|
|
440
|
+
aria-label="Redo"
|
|
441
|
+
>
|
|
442
|
+
<Redo2 size={18} />
|
|
443
|
+
</button>
|
|
444
|
+
</div>
|
|
425
445
|
|
|
426
446
|
<div className="flex items-center gap-2">
|
|
427
447
|
{slots?.toolbarActions}
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
GlobalOverlayProp,
|
|
10
10
|
EditorAdapter,
|
|
11
11
|
} from '../types'
|
|
12
|
-
import { Button, cn, inspectorInputClass } from '../ui'
|
|
12
|
+
import { Button, cn, inspectorInputClass, SwatchInput } from '../ui'
|
|
13
13
|
import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
|
|
14
14
|
|
|
15
15
|
function parseNumber(v: string): number | null {
|
|
@@ -173,40 +173,6 @@ function PropEditor({
|
|
|
173
173
|
)
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// Color control that actually reads as one: a filled rounded swatch (the live
|
|
177
|
-
// value) sitting next to the hex string, the whole row clickable to open the
|
|
178
|
-
// native color picker. The transparent <input type="color"> overlays the
|
|
179
|
-
// swatch so the OS picker anchors there.
|
|
180
|
-
function SwatchInput({
|
|
181
|
-
value,
|
|
182
|
-
onChange,
|
|
183
|
-
ariaLabel,
|
|
184
|
-
}: {
|
|
185
|
-
value: string
|
|
186
|
-
onChange: (v: string) => void
|
|
187
|
-
ariaLabel: string
|
|
188
|
-
}) {
|
|
189
|
-
return (
|
|
190
|
-
<div className="flex items-center gap-2.5">
|
|
191
|
-
<label className="relative h-7 w-7 shrink-0 cursor-pointer">
|
|
192
|
-
<span
|
|
193
|
-
className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
|
|
194
|
-
style={{ backgroundColor: value }}
|
|
195
|
-
aria-hidden
|
|
196
|
-
/>
|
|
197
|
-
<input
|
|
198
|
-
type="color"
|
|
199
|
-
value={value}
|
|
200
|
-
onChange={e => onChange(e.target.value)}
|
|
201
|
-
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
202
|
-
aria-label={ariaLabel}
|
|
203
|
-
/>
|
|
204
|
-
</label>
|
|
205
|
-
<span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
|
|
206
|
-
</div>
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
176
|
export default function SlidePropertyPanel({
|
|
211
177
|
project,
|
|
212
178
|
slide,
|
package/src/schema.ts
CHANGED
|
@@ -43,13 +43,23 @@ export interface CaptionSegment {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Captions {
|
|
46
|
-
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle'
|
|
46
|
+
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
|
|
47
47
|
segments: CaptionSegment[]
|
|
48
|
-
//
|
|
48
|
+
// `color` and `fontsize` are read by BOTH paths: the JSX browser preview /
|
|
49
|
+
// Puppeteer render (spread into the caption template props; `fontsize`→`fontSize`)
|
|
50
|
+
// and the ffmpeg-drawtext render branch. `position` and `bgColor` are consumed
|
|
51
|
+
// only by the ffmpeg-drawtext branch and ignored by the JSX preview.
|
|
49
52
|
position?: 'center' | 'top-left' | 'bottom-left'
|
|
50
|
-
color?: string
|
|
53
|
+
color?: string // base caption text color
|
|
51
54
|
fontsize?: number
|
|
52
55
|
bgColor?: string
|
|
56
|
+
// Per-style accent color fields, each read by the matching JSX caption template.
|
|
57
|
+
// A single UI control writes whichever one the active style uses (see TranscriptPanel).
|
|
58
|
+
accentColor?: string // active-word/box accent — highlight-box, outline
|
|
59
|
+
highlightColor?: string // active word — karaoke
|
|
60
|
+
activeColor?: string // active word — pop
|
|
61
|
+
backgroundColor?: string// text box background — subtitle
|
|
62
|
+
googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
export interface VisualItem {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cn } from './utils'
|
|
2
|
+
|
|
3
|
+
// Color control that actually reads as one: a filled rounded swatch (the live
|
|
4
|
+
// value) with the native <input type="color"> overlaid transparently so the OS
|
|
5
|
+
// picker anchors on the swatch. Optionally shows the hex string beside it.
|
|
6
|
+
//
|
|
7
|
+
// `onChange` fires live on every pick (drive a cheap preview). `onCommit`, if
|
|
8
|
+
// given, fires on blur (when the OS picker closes) — use it to persist once
|
|
9
|
+
// instead of on every intermediate value, mirroring a slider's drag→commit.
|
|
10
|
+
export interface SwatchInputProps {
|
|
11
|
+
value: string
|
|
12
|
+
onChange: (v: string) => void
|
|
13
|
+
/** Fired on blur (picker closed) — for callers that preview live via onChange
|
|
14
|
+
* and persist only on commit. Omit to treat every onChange as final. */
|
|
15
|
+
onCommit?: (v: string) => void
|
|
16
|
+
ariaLabel: string
|
|
17
|
+
/** Render the hex string next to the swatch. Default true. */
|
|
18
|
+
showValue?: boolean
|
|
19
|
+
/** Swatch size. `sm` (h-5) suits compact toolbars; `md` (h-7) is the default. */
|
|
20
|
+
size?: 'sm' | 'md'
|
|
21
|
+
/** Tooltip on the row. */
|
|
22
|
+
title?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SwatchInput({
|
|
26
|
+
value,
|
|
27
|
+
onChange,
|
|
28
|
+
onCommit,
|
|
29
|
+
ariaLabel,
|
|
30
|
+
showValue = true,
|
|
31
|
+
size = 'md',
|
|
32
|
+
title,
|
|
33
|
+
}: SwatchInputProps) {
|
|
34
|
+
const box = size === 'sm' ? 'h-5 w-5' : 'h-7 w-7'
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-center gap-2.5" title={title}>
|
|
37
|
+
<label className={cn('relative shrink-0 cursor-pointer', box)}>
|
|
38
|
+
<span
|
|
39
|
+
className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
|
|
40
|
+
style={{ backgroundColor: value }}
|
|
41
|
+
aria-hidden
|
|
42
|
+
/>
|
|
43
|
+
<input
|
|
44
|
+
type="color"
|
|
45
|
+
value={value}
|
|
46
|
+
onChange={e => onChange(e.target.value)}
|
|
47
|
+
onBlur={e => onCommit?.(e.target.value)}
|
|
48
|
+
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
/>
|
|
51
|
+
</label>
|
|
52
|
+
{showValue && (
|
|
53
|
+
<span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { InputProps } from './input'
|
|
|
9
9
|
export { Label } from './label'
|
|
10
10
|
export { Select } from './select'
|
|
11
11
|
export type { SelectProps } from './select'
|
|
12
|
+
export { SwatchInput } from './SwatchInput'
|
|
13
|
+
export type { SwatchInputProps } from './SwatchInput'
|
|
12
14
|
export { Switch } from './switch'
|
|
13
15
|
export { Textarea } from './textarea'
|
|
14
16
|
export type { TextareaProps } from './textarea'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Crop, Info, Magnet } from 'lucide-react'
|
|
2
|
+
import { Crop, Info, Magnet, Undo2 } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
4
|
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
5
5
|
import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
|
|
@@ -475,6 +475,15 @@ function ReviewSurface<P extends Project>({
|
|
|
475
475
|
>
|
|
476
476
|
<Info size={12} />
|
|
477
477
|
</button>
|
|
478
|
+
<button
|
|
479
|
+
onClick={handleUndo}
|
|
480
|
+
disabled={!canUndo}
|
|
481
|
+
title="Undo (Cmd/Ctrl+Z)"
|
|
482
|
+
aria-label="Undo"
|
|
483
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
484
|
+
>
|
|
485
|
+
<Undo2 size={12} />
|
|
486
|
+
</button>
|
|
478
487
|
<button
|
|
479
488
|
onClick={() => handleSplit()}
|
|
480
489
|
title="Split at playhead (S) — selected item or all clips"
|
|
@@ -58,8 +58,17 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
58
58
|
|
|
59
59
|
const frame = Math.round(currentTime * fps)
|
|
60
60
|
const lastSeg = track.segments[track.segments.length - 1]
|
|
61
|
+
|
|
62
|
+
// Theme props for the template: everything on the track except style/segments
|
|
63
|
+
// (handled separately) and googleFonts (a render-time font-loading hint, not
|
|
64
|
+
// a template prop). Normalize the legacy lowercase `fontsize` key to the
|
|
65
|
+
// camelCase `fontSize` templates actually read.
|
|
66
|
+
const { style: _style, segments: _segments, googleFonts: _googleFonts, fontsize, ...theme } = track
|
|
67
|
+
const themeProps: Record<string, unknown> = { ...theme }
|
|
68
|
+
if (fontsize != null) themeProps.fontSize = fontsize
|
|
69
|
+
|
|
61
70
|
const element = (factory && scale !== null)
|
|
62
|
-
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
|
|
71
|
+
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments, ...themeProps })
|
|
63
72
|
: null
|
|
64
73
|
|
|
65
74
|
return (
|
|
@@ -138,7 +138,12 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
138
138
|
const fps = project.settings?.fps ?? 30
|
|
139
139
|
const frame = 1 / fps
|
|
140
140
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
141
|
+
// While the transcript modal is open, or the caret is in editable text
|
|
142
|
+
// (caption segments are contentEditable), arrows move the text cursor —
|
|
143
|
+
// never the playhead.
|
|
144
|
+
if (transcriptModalOpen) return
|
|
141
145
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
146
|
+
if ((e.target as HTMLElement).isContentEditable) return
|
|
142
147
|
if (e.key === 'Escape') { setMarkers([null, null]); return }
|
|
143
148
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
144
149
|
e.preventDefault()
|
|
@@ -152,7 +157,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
152
157
|
}
|
|
153
158
|
document.addEventListener('keydown', onKey)
|
|
154
159
|
return () => document.removeEventListener('keydown', onKey)
|
|
155
|
-
}, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps])
|
|
160
|
+
}, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
|
|
156
161
|
|
|
157
162
|
// Derive selection from two placed markers
|
|
158
163
|
const selection = markers[0] !== null && markers[1] !== null
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
1
2
|
import type { Project } from '../../types'
|
|
2
3
|
import { formatTime } from './utils'
|
|
3
4
|
import { EditableSegment } from './EditableSegment'
|
|
4
5
|
import { makeCaptionEdit } from './makeCaptionEdit'
|
|
6
|
+
import { SwatchInput } from '../../ui'
|
|
7
|
+
|
|
8
|
+
// Each caption style reads a different accent-color prop in its render template
|
|
9
|
+
// (see render/templates/captions/*.jsx). The color control writes whichever field
|
|
10
|
+
// the active style uses; `clean` and `word-by-word` have no accent (omitted here).
|
|
11
|
+
type CaptionStyle = NonNullable<Project['captions']>['style']
|
|
12
|
+
type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
|
|
13
|
+
const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
|
|
14
|
+
karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
|
|
15
|
+
pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
|
|
16
|
+
'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
17
|
+
outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
18
|
+
subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The native <input type="color"> only accepts #rrggbb. Stored values are always
|
|
22
|
+
// hex once picked; fall back to the style default for unset / non-hex (e.g. an
|
|
23
|
+
// rgba() template default) so the swatch never gets an invalid value.
|
|
24
|
+
const HEX = /^#[0-9a-f]{6}$/i
|
|
25
|
+
const toHex = (v: unknown, fallback: string): string =>
|
|
26
|
+
typeof v === 'string' && HEX.test(v) ? v : fallback
|
|
5
27
|
|
|
6
28
|
interface TranscriptPanelProps {
|
|
7
29
|
project: Project
|
|
@@ -17,6 +39,21 @@ interface TranscriptPanelProps {
|
|
|
17
39
|
|
|
18
40
|
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
|
|
19
41
|
const segs = captionTrack?.segments ?? []
|
|
42
|
+
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
43
|
+
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
// Live slider value during a drag — only committed (persisted) on
|
|
45
|
+
// pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
|
|
46
|
+
const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setFontsize(captionTrack?.fontsize ?? 46)
|
|
50
|
+
}, [captionTrack?.fontsize])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
return () => {
|
|
54
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
55
|
+
}
|
|
56
|
+
}, [])
|
|
20
57
|
// Find active segment index
|
|
21
58
|
const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
|
|
22
59
|
const nearIdx = activeIdx !== -1 ? activeIdx
|
|
@@ -31,8 +68,8 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
31
68
|
<div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
|
|
32
69
|
<div className="flex items-center justify-between mb-2">
|
|
33
70
|
<span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
|
|
34
|
-
<div className="flex items-center gap-2">
|
|
35
|
-
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle'] as const).map(style => {
|
|
71
|
+
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
72
|
+
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
|
|
36
73
|
const active = captionTrack.style === style
|
|
37
74
|
return (
|
|
38
75
|
<button
|
|
@@ -52,6 +89,74 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
52
89
|
</button>
|
|
53
90
|
)
|
|
54
91
|
})}
|
|
92
|
+
{captionTrack && (
|
|
93
|
+
<div className="flex items-center gap-1">
|
|
94
|
+
<input
|
|
95
|
+
type="range"
|
|
96
|
+
min={28}
|
|
97
|
+
max={120}
|
|
98
|
+
step={2}
|
|
99
|
+
value={fontsize}
|
|
100
|
+
className="w-20 sm:w-24 accent-purple-500"
|
|
101
|
+
onChange={e => {
|
|
102
|
+
if (!project.captions) return
|
|
103
|
+
const v = Number(e.target.value)
|
|
104
|
+
setFontsize(v)
|
|
105
|
+
// Live preview only — cheap local-state update, no save.
|
|
106
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
107
|
+
}}
|
|
108
|
+
onPointerUp={e => {
|
|
109
|
+
if (!project.captions) return
|
|
110
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
111
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
112
|
+
}}
|
|
113
|
+
onKeyUp={e => {
|
|
114
|
+
if (!project.captions) return
|
|
115
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
116
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
|
|
120
|
+
{fontsize}px
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
{captionTrack && (() => {
|
|
125
|
+
const accent = ACCENT[captionTrack.style]
|
|
126
|
+
// onChange = live preview only (cheap, no PUT); onBlur commits one save.
|
|
127
|
+
const live = (patch: Record<string, string>) => {
|
|
128
|
+
if (!project.captions) return
|
|
129
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
130
|
+
}
|
|
131
|
+
const commit = (patch: Record<string, string>) => {
|
|
132
|
+
if (!project.captions) return
|
|
133
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex items-center gap-1.5">
|
|
137
|
+
<SwatchInput
|
|
138
|
+
size="sm"
|
|
139
|
+
showValue={false}
|
|
140
|
+
title="Caption text color"
|
|
141
|
+
ariaLabel="Caption text color"
|
|
142
|
+
value={toHex(captionTrack.color, '#ffffff')}
|
|
143
|
+
onChange={v => live({ color: v })}
|
|
144
|
+
onCommit={v => commit({ color: v })}
|
|
145
|
+
/>
|
|
146
|
+
{accent && (
|
|
147
|
+
<SwatchInput
|
|
148
|
+
size="sm"
|
|
149
|
+
showValue={false}
|
|
150
|
+
title={`Caption ${accent.label.toLowerCase()} color`}
|
|
151
|
+
ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
|
|
152
|
+
value={toHex(captionTrack[accent.field], accent.def)}
|
|
153
|
+
onChange={v => live({ [accent.field]: v })}
|
|
154
|
+
onCommit={v => commit({ [accent.field]: v })}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
})()}
|
|
55
160
|
{onRegenerateCaptions && (
|
|
56
161
|
<button
|
|
57
162
|
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|
|
@@ -60,6 +165,30 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
60
165
|
Regenerate
|
|
61
166
|
</button>
|
|
62
167
|
)}
|
|
168
|
+
{captionTrack && (
|
|
169
|
+
<button
|
|
170
|
+
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
171
|
+
confirmRemove
|
|
172
|
+
? 'bg-red-500/20 border-red-500/60 text-red-400'
|
|
173
|
+
: 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
|
|
174
|
+
}`}
|
|
175
|
+
onClick={() => {
|
|
176
|
+
if (!confirmRemove) {
|
|
177
|
+
setConfirmRemove(true)
|
|
178
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
179
|
+
removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
183
|
+
setConfirmRemove(false)
|
|
184
|
+
// Persistence needs an explicit `null` to clear the field server-side
|
|
185
|
+
// (a live PUT test confirmed `undefined` is dropped from the JSON body).
|
|
186
|
+
onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{confirmRemove ? 'Really remove?' : 'Remove'}
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
63
192
|
{segs.length > 0 && (
|
|
64
193
|
<button
|
|
65
194
|
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
+
import type { Project } from '../../../types'
|
|
4
|
+
import type { Captions } from '../../../schema'
|
|
5
|
+
import TranscriptPanel from '../TranscriptPanel'
|
|
6
|
+
|
|
7
|
+
afterEach(() => cleanup())
|
|
8
|
+
|
|
9
|
+
function makeProject(style: Captions['style'], extra: Partial<Captions> = {}): Project {
|
|
10
|
+
return { id: 'p1', captions: { style, segments: [], ...extra } } as unknown as Project
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
|
|
14
|
+
const onCaptionEdit = vi.fn()
|
|
15
|
+
const onProjectChange = vi.fn()
|
|
16
|
+
const project = makeProject(style, extra)
|
|
17
|
+
render(
|
|
18
|
+
<TranscriptPanel
|
|
19
|
+
project={project}
|
|
20
|
+
captionTrack={project.captions}
|
|
21
|
+
currentTime={0}
|
|
22
|
+
onCaptionEdit={onCaptionEdit}
|
|
23
|
+
onProjectChange={onProjectChange}
|
|
24
|
+
onExpand={() => {}}
|
|
25
|
+
/>,
|
|
26
|
+
)
|
|
27
|
+
return { onCaptionEdit, onProjectChange }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('TranscriptPanel caption color controls', () => {
|
|
31
|
+
it('previews live on change and commits on blur, writing captions.color', () => {
|
|
32
|
+
const { onCaptionEdit, onProjectChange } = renderPanel('karaoke')
|
|
33
|
+
const input = screen.getByLabelText('Caption text color') as HTMLInputElement
|
|
34
|
+
|
|
35
|
+
fireEvent.change(input, { target: { value: '#76b900' } })
|
|
36
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
37
|
+
expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
|
|
38
|
+
expect(onCaptionEdit).not.toHaveBeenCalled() // live preview must not persist
|
|
39
|
+
|
|
40
|
+
fireEvent.blur(input, { target: { value: '#76b900' } })
|
|
41
|
+
expect(onCaptionEdit).toHaveBeenCalledTimes(1)
|
|
42
|
+
expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('writes the accent to the field the active style actually reads', () => {
|
|
46
|
+
// karaoke → highlightColor
|
|
47
|
+
let h = renderPanel('karaoke')
|
|
48
|
+
fireEvent.blur(screen.getByLabelText('Caption highlight color'), { target: { value: '#111111' } })
|
|
49
|
+
expect(h.onCaptionEdit.mock.calls[0][0].captions.highlightColor).toBe('#111111')
|
|
50
|
+
cleanup()
|
|
51
|
+
|
|
52
|
+
// pop → activeColor
|
|
53
|
+
h = renderPanel('pop')
|
|
54
|
+
fireEvent.blur(screen.getByLabelText('Caption active color'), { target: { value: '#222222' } })
|
|
55
|
+
expect(h.onCaptionEdit.mock.calls[0][0].captions.activeColor).toBe('#222222')
|
|
56
|
+
cleanup()
|
|
57
|
+
|
|
58
|
+
// subtitle → backgroundColor
|
|
59
|
+
h = renderPanel('subtitle')
|
|
60
|
+
fireEvent.blur(screen.getByLabelText('Caption box color'), { target: { value: '#333333' } })
|
|
61
|
+
expect(h.onCaptionEdit.mock.calls[0][0].captions.backgroundColor).toBe('#333333')
|
|
62
|
+
cleanup()
|
|
63
|
+
|
|
64
|
+
// highlight-box → accentColor
|
|
65
|
+
h = renderPanel('highlight-box')
|
|
66
|
+
fireEvent.blur(screen.getByLabelText('Caption accent color'), { target: { value: '#444444' } })
|
|
67
|
+
expect(h.onCaptionEdit.mock.calls[0][0].captions.accentColor).toBe('#444444')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('hides the accent swatch for styles with no accent', () => {
|
|
71
|
+
renderPanel('clean')
|
|
72
|
+
expect(screen.getByLabelText('Caption text color')).toBeTruthy()
|
|
73
|
+
expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
|
|
74
|
+
cleanup()
|
|
75
|
+
|
|
76
|
+
renderPanel('word-by-word')
|
|
77
|
+
expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('reflects the stored hex value on the swatch input', () => {
|
|
81
|
+
renderPanel('karaoke', { color: '#abcdef', highlightColor: '#00ff00' })
|
|
82
|
+
expect((screen.getByLabelText('Caption text color') as HTMLInputElement).value).toBe('#abcdef')
|
|
83
|
+
expect((screen.getByLabelText('Caption highlight color') as HTMLInputElement).value).toBe('#00ff00')
|
|
84
|
+
})
|
|
85
|
+
})
|