@bycrux/editor 0.8.7 → 0.8.9
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/ControlsInfoModal.tsx +157 -0
- package/src/__tests__/ControlsInfoModal.test.tsx +62 -0
- package/src/carousel/CarouselEditor.tsx +25 -4
- package/src/schema.ts +3 -1
- package/src/video/VideoEditor.tsx +21 -2
- package/src/video/preview/CaptionPreview.tsx +10 -1
- package/src/video/timeline/Timeline.tsx +6 -1
- package/src/video/timeline/TranscriptPanel.tsx +74 -2
package/package.json
CHANGED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
/** A single control/shortcut row. `keys` renders as <kbd> chips; omit for a pure gesture. */
|
|
5
|
+
export interface ControlEntry {
|
|
6
|
+
keys?: string[]
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ControlSection {
|
|
11
|
+
heading: string
|
|
12
|
+
entries: ControlEntry[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ControlsInfoModalProps {
|
|
16
|
+
title: string
|
|
17
|
+
sections: ControlSection[]
|
|
18
|
+
onClose: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read-only "how to drive this editor" modal. Content is passed in per editor
|
|
23
|
+
* (video vs carousel) so this component stays a dumb, themed renderer. Matches
|
|
24
|
+
* the backdrop/Esc/close conventions of the other editor modals (TranscriptModal
|
|
25
|
+
* et al.) and uses the shared `--editor-*` theme tokens.
|
|
26
|
+
*/
|
|
27
|
+
export default function ControlsInfoModal({ title, sections, onClose }: ControlsInfoModalProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
30
|
+
if (e.key === 'Escape') onClose()
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('keydown', onKey)
|
|
33
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
34
|
+
}, [onClose])
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
role="dialog"
|
|
39
|
+
aria-modal="true"
|
|
40
|
+
aria-label={title}
|
|
41
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
42
|
+
onClick={onClose}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
className="relative mx-4 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-lg border shadow-2xl"
|
|
46
|
+
style={{
|
|
47
|
+
background: 'var(--editor-surface)',
|
|
48
|
+
borderColor: 'var(--editor-border)',
|
|
49
|
+
color: 'var(--editor-text)',
|
|
50
|
+
}}
|
|
51
|
+
onClick={(e) => e.stopPropagation()}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
className="flex shrink-0 items-center justify-between border-b px-4 py-3"
|
|
55
|
+
style={{ borderColor: 'var(--editor-border)' }}
|
|
56
|
+
>
|
|
57
|
+
<h2 className="text-sm font-semibold">{title}</h2>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={onClose}
|
|
61
|
+
aria-label="Close"
|
|
62
|
+
className="cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
63
|
+
>
|
|
64
|
+
<X size={16} />
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="space-y-4 overflow-y-auto px-4 py-3">
|
|
69
|
+
{sections.map((section) => (
|
|
70
|
+
<div key={section.heading}>
|
|
71
|
+
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide opacity-50">
|
|
72
|
+
{section.heading}
|
|
73
|
+
</p>
|
|
74
|
+
<ul className="space-y-1.5">
|
|
75
|
+
{section.entries.map((entry, i) => (
|
|
76
|
+
<li key={i} className="flex items-start justify-between gap-3 text-xs">
|
|
77
|
+
<span className="opacity-80">{entry.label}</span>
|
|
78
|
+
{entry.keys && entry.keys.length > 0 && (
|
|
79
|
+
<span className="flex shrink-0 items-center gap-1">
|
|
80
|
+
{entry.keys.map((k, j) => (
|
|
81
|
+
<kbd
|
|
82
|
+
key={j}
|
|
83
|
+
className="rounded border px-1.5 py-0.5 font-mono text-[10px] leading-none"
|
|
84
|
+
style={{ borderColor: 'var(--editor-border)', background: 'var(--editor-bg)' }}
|
|
85
|
+
>
|
|
86
|
+
{k}
|
|
87
|
+
</kbd>
|
|
88
|
+
))}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Controls reference for the video/timeline editor. Sourced from VideoEditor's
|
|
103
|
+
* key handler (S / ⌘Z), the track-controls toolbar, and PreviewPlayer's
|
|
104
|
+
* on-canvas transform (drag / corner-drag / scroll / rotate). */
|
|
105
|
+
export const VIDEO_CONTROLS: ControlSection[] = [
|
|
106
|
+
{
|
|
107
|
+
heading: 'Preview',
|
|
108
|
+
entries: [
|
|
109
|
+
{ label: 'Drag to move the selected clip or overlay' },
|
|
110
|
+
{ label: 'Corner-drag or scroll to scale it' },
|
|
111
|
+
{ label: 'Drag the rotate handle to turn an overlay' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
heading: 'Timeline',
|
|
116
|
+
entries: [
|
|
117
|
+
{ label: 'Drag a clip to reposition it' },
|
|
118
|
+
{ label: "Drag a clip's edge to trim it" },
|
|
119
|
+
{ label: 'Select a clip, then click ⓘ to inspect it' },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
heading: 'Toolbar',
|
|
124
|
+
entries: [
|
|
125
|
+
{ label: 'Ripple: edits close the gap instead of leaving it' },
|
|
126
|
+
{ label: 'Crop source: non-destructively crop the selected clip' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
heading: 'Keyboard',
|
|
131
|
+
entries: [
|
|
132
|
+
{ keys: ['S'], label: 'Split at the playhead' },
|
|
133
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
/** Controls reference for the carousel editor. Sourced from CarouselEditor's key
|
|
139
|
+
* handlers (undo/redo, Delete) and the canvas gesture hints. */
|
|
140
|
+
export const CAROUSEL_CONTROLS: ControlSection[] = [
|
|
141
|
+
{
|
|
142
|
+
heading: 'Canvas',
|
|
143
|
+
entries: [
|
|
144
|
+
{ label: 'Drag an element to reposition it' },
|
|
145
|
+
{ label: 'Resize or rotate it via the handles' },
|
|
146
|
+
{ label: 'Double-click text to edit it' },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
heading: 'Keyboard',
|
|
151
|
+
entries: [
|
|
152
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
153
|
+
{ keys: ['⌘/Ctrl', '⇧', 'Z'], label: 'Redo' },
|
|
154
|
+
{ keys: ['Delete'], label: 'Remove the selected element' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
+
import ControlsInfoModal, {
|
|
4
|
+
VIDEO_CONTROLS,
|
|
5
|
+
CAROUSEL_CONTROLS,
|
|
6
|
+
type ControlSection,
|
|
7
|
+
} from '../ControlsInfoModal'
|
|
8
|
+
|
|
9
|
+
afterEach(() => cleanup())
|
|
10
|
+
|
|
11
|
+
const SECTIONS: ControlSection[] = [
|
|
12
|
+
{
|
|
13
|
+
heading: 'Canvas',
|
|
14
|
+
entries: [
|
|
15
|
+
{ label: 'Drag an element to reposition it' },
|
|
16
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('ControlsInfoModal', () => {
|
|
22
|
+
it('renders the title, section headings, entry labels, and kbd chips', () => {
|
|
23
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={vi.fn()} />)
|
|
24
|
+
|
|
25
|
+
expect(screen.getByRole('dialog', { name: 'Editor controls' })).toBeTruthy()
|
|
26
|
+
expect(screen.getByText('Canvas')).toBeTruthy()
|
|
27
|
+
expect(screen.getByText('Drag an element to reposition it')).toBeTruthy()
|
|
28
|
+
// Keys render as individual <kbd> chips.
|
|
29
|
+
expect(screen.getByText('⌘/Ctrl').tagName).toBe('KBD')
|
|
30
|
+
expect(screen.getByText('Z').tagName).toBe('KBD')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('closes on Escape', () => {
|
|
34
|
+
const onClose = vi.fn()
|
|
35
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={onClose} />)
|
|
36
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
37
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('closes on backdrop click but not on panel click', () => {
|
|
41
|
+
const onClose = vi.fn()
|
|
42
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={onClose} />)
|
|
43
|
+
|
|
44
|
+
// Click inside the panel — should NOT close.
|
|
45
|
+
fireEvent.click(screen.getByText('Canvas'))
|
|
46
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
47
|
+
|
|
48
|
+
// Click the backdrop (the dialog root) — should close.
|
|
49
|
+
fireEvent.click(screen.getByRole('dialog'))
|
|
50
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ships real control content for both editors', () => {
|
|
54
|
+
// Guards against an empty/placeholder content regression.
|
|
55
|
+
const videoLabels = VIDEO_CONTROLS.flatMap((s) => s.entries.map((e) => e.label))
|
|
56
|
+
const carouselLabels = CAROUSEL_CONTROLS.flatMap((s) => s.entries.map((e) => e.label))
|
|
57
|
+
expect(videoLabels).toEqual(expect.arrayContaining(['Split at the playhead']))
|
|
58
|
+
expect(carouselLabels).toEqual(expect.arrayContaining(['Double-click text to edit it']))
|
|
59
|
+
expect(VIDEO_CONTROLS.length).toBeGreaterThan(0)
|
|
60
|
+
expect(CAROUSEL_CONTROLS.length).toBeGreaterThan(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { RefreshCw, AlertCircle, Download } from 'lucide-react'
|
|
2
|
+
import { RefreshCw, AlertCircle, Download, Info } 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'
|
|
@@ -7,6 +7,7 @@ import SlideCanvas from './SlideCanvas'
|
|
|
7
7
|
import SlidePropertyPanel from './SlidePropertyPanel'
|
|
8
8
|
import AddElementMenu from './AddElementMenu'
|
|
9
9
|
import CarouselRenderModal from './CarouselRenderModal'
|
|
10
|
+
import ControlsInfoModal, { CAROUSEL_CONTROLS } from '../ControlsInfoModal'
|
|
10
11
|
import { Button } from '../ui'
|
|
11
12
|
|
|
12
13
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
@@ -167,6 +168,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
167
168
|
|
|
168
169
|
const [skillPath, setSkillPath] = useState<string | null>(null)
|
|
169
170
|
const [copied, setCopied] = useState(false)
|
|
171
|
+
const [showControls, setShowControls] = useState(false)
|
|
170
172
|
const [refreshing, setRefreshing] = useState(false)
|
|
171
173
|
const [refreshState, setRefreshState] = useState<'idle' | 'err'>('idle')
|
|
172
174
|
const [rendering, setRendering] = useState(false)
|
|
@@ -505,9 +507,20 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
505
507
|
hiddenElementIds={hiddenElementIds}
|
|
506
508
|
/>
|
|
507
509
|
</div>
|
|
508
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
510
|
+
<div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[var(--editor-text)]/60 max-w-md">
|
|
511
|
+
<span className="text-center">
|
|
512
|
+
Drag to reposition, resize/rotate via handles, double-click text to edit. Cmd/Ctrl+Z to undo.
|
|
513
|
+
</span>
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
onClick={() => setShowControls(true)}
|
|
517
|
+
title="Editor controls & shortcuts"
|
|
518
|
+
aria-label="Editor controls & shortcuts"
|
|
519
|
+
className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
520
|
+
>
|
|
521
|
+
<Info size={13} />
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
511
524
|
</>
|
|
512
525
|
) : (
|
|
513
526
|
<div className="text-[var(--editor-text)]/40 text-sm">No slides yet. Add one in the left panel.</div>
|
|
@@ -566,6 +579,14 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
566
579
|
</div>
|
|
567
580
|
)}
|
|
568
581
|
|
|
582
|
+
{showControls && (
|
|
583
|
+
<ControlsInfoModal
|
|
584
|
+
title="Editor controls"
|
|
585
|
+
sections={CAROUSEL_CONTROLS}
|
|
586
|
+
onClose={() => setShowControls(false)}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
589
|
+
|
|
569
590
|
{renderOpen && (
|
|
570
591
|
<CarouselRenderModal
|
|
571
592
|
projectId={project.id}
|
package/src/schema.ts
CHANGED
|
@@ -43,13 +43,15 @@ 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
|
// ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
|
|
49
49
|
position?: 'center' | 'top-left' | 'bottom-left'
|
|
50
50
|
color?: string
|
|
51
51
|
fontsize?: number
|
|
52
52
|
bgColor?: string
|
|
53
|
+
accentColor?: string // active-word/box accent color — highlight-box, outline
|
|
54
|
+
googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface VisualItem {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Crop, Magnet } from 'lucide-react'
|
|
2
|
+
import { Crop, Info, Magnet } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
4
|
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
5
|
+
import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
|
|
5
6
|
import { getOverlayDesignCanvas } from './design-canvas'
|
|
6
7
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
7
8
|
import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
|
|
@@ -287,6 +288,7 @@ function ReviewSurface<P extends Project>({
|
|
|
287
288
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
288
289
|
const primarySelectedId = selectedIds[0] ?? null
|
|
289
290
|
const [rippleMode, setRippleMode] = useState(false)
|
|
291
|
+
const [showControls, setShowControls] = useState(false)
|
|
290
292
|
// Source-crop mode: when on, the VideoSourceCropModal opens for the selected
|
|
291
293
|
// tracks[0] video item. Cleared when selection changes.
|
|
292
294
|
const [cropMode, setCropMode] = useState(false)
|
|
@@ -463,8 +465,16 @@ function ReviewSurface<P extends Project>({
|
|
|
463
465
|
)}
|
|
464
466
|
</div>
|
|
465
467
|
|
|
466
|
-
{/* Track controls bar — split + ripple + render */}
|
|
468
|
+
{/* Track controls bar — info + split + ripple + render */}
|
|
467
469
|
<div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
470
|
+
<button
|
|
471
|
+
onClick={() => setShowControls(true)}
|
|
472
|
+
title="Editor controls & shortcuts"
|
|
473
|
+
aria-label="Editor controls & shortcuts"
|
|
474
|
+
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)] mr-auto"
|
|
475
|
+
>
|
|
476
|
+
<Info size={12} />
|
|
477
|
+
</button>
|
|
468
478
|
<button
|
|
469
479
|
onClick={() => handleSplit()}
|
|
470
480
|
title="Split at playhead (S) — selected item or all clips"
|
|
@@ -613,6 +623,15 @@ function ReviewSurface<P extends Project>({
|
|
|
613
623
|
/>
|
|
614
624
|
)}
|
|
615
625
|
|
|
626
|
+
{/* Controls & shortcuts reference */}
|
|
627
|
+
{showControls && (
|
|
628
|
+
<ControlsInfoModal
|
|
629
|
+
title="Editor controls"
|
|
630
|
+
sections={VIDEO_CONTROLS}
|
|
631
|
+
onClose={() => setShowControls(false)}
|
|
632
|
+
/>
|
|
633
|
+
)}
|
|
634
|
+
|
|
616
635
|
{/* Render modal — adapter.render stream + host export controls */}
|
|
617
636
|
{renderOpen && (
|
|
618
637
|
<RenderModal
|
|
@@ -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,3 +1,4 @@
|
|
|
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'
|
|
@@ -17,6 +18,21 @@ interface TranscriptPanelProps {
|
|
|
17
18
|
|
|
18
19
|
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
|
|
19
20
|
const segs = captionTrack?.segments ?? []
|
|
21
|
+
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
22
|
+
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
23
|
+
// Live slider value during a drag — only committed (persisted) on
|
|
24
|
+
// pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
|
|
25
|
+
const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setFontsize(captionTrack?.fontsize ?? 46)
|
|
29
|
+
}, [captionTrack?.fontsize])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return () => {
|
|
33
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
34
|
+
}
|
|
35
|
+
}, [])
|
|
20
36
|
// Find active segment index
|
|
21
37
|
const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
|
|
22
38
|
const nearIdx = activeIdx !== -1 ? activeIdx
|
|
@@ -31,8 +47,8 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
31
47
|
<div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
|
|
32
48
|
<div className="flex items-center justify-between mb-2">
|
|
33
49
|
<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 => {
|
|
50
|
+
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
51
|
+
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
|
|
36
52
|
const active = captionTrack.style === style
|
|
37
53
|
return (
|
|
38
54
|
<button
|
|
@@ -52,6 +68,38 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
52
68
|
</button>
|
|
53
69
|
)
|
|
54
70
|
})}
|
|
71
|
+
{captionTrack && (
|
|
72
|
+
<div className="flex items-center gap-1">
|
|
73
|
+
<input
|
|
74
|
+
type="range"
|
|
75
|
+
min={28}
|
|
76
|
+
max={120}
|
|
77
|
+
step={2}
|
|
78
|
+
value={fontsize}
|
|
79
|
+
className="w-20 sm:w-24 accent-purple-500"
|
|
80
|
+
onChange={e => {
|
|
81
|
+
if (!project.captions) return
|
|
82
|
+
const v = Number(e.target.value)
|
|
83
|
+
setFontsize(v)
|
|
84
|
+
// Live preview only — cheap local-state update, no save.
|
|
85
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
86
|
+
}}
|
|
87
|
+
onPointerUp={e => {
|
|
88
|
+
if (!project.captions) return
|
|
89
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
90
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
91
|
+
}}
|
|
92
|
+
onKeyUp={e => {
|
|
93
|
+
if (!project.captions) return
|
|
94
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
95
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
|
|
99
|
+
{fontsize}px
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
55
103
|
{onRegenerateCaptions && (
|
|
56
104
|
<button
|
|
57
105
|
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 +108,30 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
60
108
|
Regenerate
|
|
61
109
|
</button>
|
|
62
110
|
)}
|
|
111
|
+
{captionTrack && (
|
|
112
|
+
<button
|
|
113
|
+
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
114
|
+
confirmRemove
|
|
115
|
+
? 'bg-red-500/20 border-red-500/60 text-red-400'
|
|
116
|
+
: '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'
|
|
117
|
+
}`}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
if (!confirmRemove) {
|
|
120
|
+
setConfirmRemove(true)
|
|
121
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
122
|
+
removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
126
|
+
setConfirmRemove(false)
|
|
127
|
+
// Persistence needs an explicit `null` to clear the field server-side
|
|
128
|
+
// (a live PUT test confirmed `undefined` is dropped from the JSON body).
|
|
129
|
+
onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{confirmRemove ? 'Really remove?' : 'Remove'}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
63
135
|
{segs.length > 0 && (
|
|
64
136
|
<button
|
|
65
137
|
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"
|