@bycrux/editor 0.11.1 → 0.11.2
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
CHANGED
package/src/schema.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface CaptionSegment {
|
|
|
46
46
|
// transform, not a font-size change, so it scales the background box and
|
|
47
47
|
// text stroke too and does NOT re-wrap the text. Default 1.
|
|
48
48
|
scale?: number
|
|
49
|
+
// Per-segment override of the track-level `Captions.color` (base text color
|
|
50
|
+
// only — not the per-style accent fields below). CSS color (hex or named).
|
|
51
|
+
// Absent → inherits the track-level color → the template's own default.
|
|
52
|
+
// Honored only by the JSX browser preview / Puppeteer render path; the
|
|
53
|
+
// ffmpeg `drawtext` render branch has no per-segment concept and keeps
|
|
54
|
+
// reading only the track-level `color`.
|
|
55
|
+
color?: string
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface Captions {
|
|
@@ -410,6 +410,8 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
|
|
|
410
410
|
onProjectChange={onProjectChange}
|
|
411
411
|
onExpand={() => setTranscriptModalOpen(true)}
|
|
412
412
|
onRegenerateCaptions={onRegenerateCaptions}
|
|
413
|
+
selectedCaptionId={selectedCaptionId}
|
|
414
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
413
415
|
/>
|
|
414
416
|
|
|
415
417
|
{/* ── Transcript modal ── */}
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import type { Project } from '../../types'
|
|
3
3
|
import { formatTime } from './utils'
|
|
4
4
|
import { EditableSegment } from './EditableSegment'
|
|
5
|
-
import { makeCaptionEdit } from './makeCaptionEdit'
|
|
5
|
+
import { makeCaptionEdit, type CaptionEditPatch } from './makeCaptionEdit'
|
|
6
6
|
import { SwatchInput } from '../../ui'
|
|
7
7
|
|
|
8
8
|
// Each caption style reads a different accent-color prop in its render template
|
|
@@ -35,9 +35,18 @@ interface TranscriptPanelProps {
|
|
|
35
35
|
/** Opens the caption-regeneration modal. Provided only when the host adapter
|
|
36
36
|
* supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
|
|
37
37
|
onRegenerateCaptions?: () => void
|
|
38
|
+
/** Selected caption segment id (shared with the preview's selection box —
|
|
39
|
+
* see CaptionPreview/CaptionTrackRow). When set and it resolves to a real
|
|
40
|
+
* segment, the base color swatch below targets that segment's `color`
|
|
41
|
+
* instead of the track-level `color`. */
|
|
42
|
+
selectedCaptionId?: string | null
|
|
43
|
+
/** Commit a single-segment patch — the same channel CaptionTrackRow's
|
|
44
|
+
* edge-drag and text edits use (see makeCaptionEdit.ts). Used here as the
|
|
45
|
+
* per-segment color swatch's commit path. */
|
|
46
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
|
|
49
|
+
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions, selectedCaptionId, onCaptionSegmentChange }: TranscriptPanelProps) {
|
|
41
50
|
const segs = captionTrack?.segments ?? []
|
|
42
51
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
43
52
|
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -132,16 +141,48 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
132
141
|
if (!project.captions) return
|
|
133
142
|
onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
134
143
|
}
|
|
144
|
+
|
|
145
|
+
// Per-segment color: when a real segment is selected, the base
|
|
146
|
+
// swatch below targets ITS color instead of the track's. Live
|
|
147
|
+
// preview still flows through `onProjectChange` — a locally
|
|
148
|
+
// patched project, exactly the channel every other control on
|
|
149
|
+
// this panel already uses for live preview — but the commit
|
|
150
|
+
// goes through `onCaptionSegmentChange`, the single-segment
|
|
151
|
+
// patch channel (see makeCaptionEdit.ts), instead of rewriting
|
|
152
|
+
// the whole captions object through `onCaptionEdit`. Each is
|
|
153
|
+
// fired from exactly one of SwatchInput's onChange/onCommit, so
|
|
154
|
+
// (as with the track-level swatch above) one gesture produces
|
|
155
|
+
// exactly one commit — never both channels for the same value.
|
|
156
|
+
const selectedSeg = selectedCaptionId
|
|
157
|
+
? segs.find(s => s.id === selectedCaptionId)
|
|
158
|
+
: undefined
|
|
159
|
+
const liveSegColor = (v: string) => {
|
|
160
|
+
if (!project.captions || !selectedSeg) return
|
|
161
|
+
onProjectChange?.({
|
|
162
|
+
...project,
|
|
163
|
+
captions: {
|
|
164
|
+
...project.captions,
|
|
165
|
+
segments: project.captions.segments.map(s =>
|
|
166
|
+
s.id === selectedSeg.id ? { ...s, color: v } : s,
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
const commitSegColor = (v: string) => {
|
|
172
|
+
if (!selectedSeg) return
|
|
173
|
+
onCaptionSegmentChange?.(selectedSeg.id!, { color: v })
|
|
174
|
+
}
|
|
175
|
+
|
|
135
176
|
return (
|
|
136
177
|
<div className="flex items-center gap-1.5">
|
|
137
178
|
<SwatchInput
|
|
138
179
|
size="sm"
|
|
139
180
|
showValue={false}
|
|
140
|
-
title=
|
|
141
|
-
ariaLabel=
|
|
142
|
-
value={toHex(captionTrack.color, '#ffffff')}
|
|
143
|
-
onChange={v => live({ color: v })}
|
|
144
|
-
onCommit={v => commit({ color: v })}
|
|
181
|
+
title={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
182
|
+
ariaLabel={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
183
|
+
value={toHex(selectedSeg ? (selectedSeg.color ?? captionTrack.color) : captionTrack.color, '#ffffff')}
|
|
184
|
+
onChange={v => selectedSeg ? liveSegColor(v) : live({ color: v })}
|
|
185
|
+
onCommit={v => selectedSeg ? commitSegColor(v) : commit({ color: v })}
|
|
145
186
|
/>
|
|
146
187
|
{accent && (
|
|
147
188
|
<SwatchInput
|
|
@@ -10,9 +10,14 @@ function makeProject(style: Captions['style'], extra: Partial<Captions> = {}): P
|
|
|
10
10
|
return { id: 'p1', captions: { style, segments: [], ...extra } } as unknown as Project
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function renderPanel(
|
|
13
|
+
function renderPanel(
|
|
14
|
+
style: Captions['style'],
|
|
15
|
+
extra: Partial<Captions> = {},
|
|
16
|
+
opts: { selectedCaptionId?: string | null } = {},
|
|
17
|
+
) {
|
|
14
18
|
const onCaptionEdit = vi.fn()
|
|
15
19
|
const onProjectChange = vi.fn()
|
|
20
|
+
const onCaptionSegmentChange = vi.fn()
|
|
16
21
|
const project = makeProject(style, extra)
|
|
17
22
|
render(
|
|
18
23
|
<TranscriptPanel
|
|
@@ -21,10 +26,12 @@ function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
|
|
|
21
26
|
currentTime={0}
|
|
22
27
|
onCaptionEdit={onCaptionEdit}
|
|
23
28
|
onProjectChange={onProjectChange}
|
|
29
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
30
|
+
selectedCaptionId={opts.selectedCaptionId ?? null}
|
|
24
31
|
onExpand={() => {}}
|
|
25
32
|
/>,
|
|
26
33
|
)
|
|
27
|
-
return { onCaptionEdit, onProjectChange }
|
|
34
|
+
return { onCaptionEdit, onProjectChange, onCaptionSegmentChange }
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
describe('TranscriptPanel caption color controls', () => {
|
|
@@ -84,6 +91,76 @@ describe('TranscriptPanel caption color controls', () => {
|
|
|
84
91
|
})
|
|
85
92
|
})
|
|
86
93
|
|
|
94
|
+
describe('TranscriptPanel per-segment caption color', () => {
|
|
95
|
+
it('no selection: base swatch is unchanged — still previews/commits the track-level color', () => {
|
|
96
|
+
const { onCaptionEdit, onProjectChange } = renderPanel('karaoke', {
|
|
97
|
+
segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
|
|
98
|
+
})
|
|
99
|
+
const input = screen.getByLabelText('Caption text color') as HTMLInputElement
|
|
100
|
+
|
|
101
|
+
fireEvent.change(input, { target: { value: '#76b900' } })
|
|
102
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
103
|
+
expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
|
|
104
|
+
|
|
105
|
+
fireEvent.blur(input, { target: { value: '#76b900' } })
|
|
106
|
+
expect(onCaptionEdit).toHaveBeenCalledTimes(1)
|
|
107
|
+
expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a selected segment: swatch previews live via onProjectChange (only that segment patched) and commits via onCaptionSegmentChange — never onCaptionEdit', () => {
|
|
111
|
+
const { onCaptionEdit, onProjectChange, onCaptionSegmentChange } = renderPanel('karaoke', {
|
|
112
|
+
color: '#ffffff',
|
|
113
|
+
segments: [
|
|
114
|
+
{ id: 'cap-0', text: 'hi', start: 0, end: 2 },
|
|
115
|
+
{ id: 'cap-1', text: 'there', start: 2, end: 4 },
|
|
116
|
+
],
|
|
117
|
+
}, { selectedCaptionId: 'cap-1' })
|
|
118
|
+
|
|
119
|
+
const input = screen.getByLabelText('Selected segment text color') as HTMLInputElement
|
|
120
|
+
|
|
121
|
+
// Live preview — onChange fires on every pick.
|
|
122
|
+
fireEvent.change(input, { target: { value: '#123456' } })
|
|
123
|
+
expect(onProjectChange).toHaveBeenCalledTimes(1)
|
|
124
|
+
const previewed = onProjectChange.mock.calls[0][0]
|
|
125
|
+
expect(previewed.captions.segments[1].color).toBe('#123456')
|
|
126
|
+
expect(previewed.captions.segments[0].color).toBeUndefined() // only the selected segment changes
|
|
127
|
+
expect(previewed.captions.color).toBe('#ffffff') // track-level color untouched
|
|
128
|
+
expect(onCaptionSegmentChange).not.toHaveBeenCalled()
|
|
129
|
+
expect(onCaptionEdit).not.toHaveBeenCalled()
|
|
130
|
+
|
|
131
|
+
// Commit — onBlur fires once, through the segment-patch channel, not
|
|
132
|
+
// the whole-track onCaptionEdit channel. One commit per gesture.
|
|
133
|
+
fireEvent.blur(input, { target: { value: '#123456' } })
|
|
134
|
+
expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
|
|
135
|
+
expect(onCaptionSegmentChange).toHaveBeenCalledWith('cap-1', { color: '#123456' })
|
|
136
|
+
expect(onCaptionEdit).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("the swatch reflects the selected segment's own color when it has one", () => {
|
|
140
|
+
renderPanel('karaoke', {
|
|
141
|
+
color: '#abcdef',
|
|
142
|
+
segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2, color: '#00ff00' }],
|
|
143
|
+
}, { selectedCaptionId: 'cap-0' })
|
|
144
|
+
expect((screen.getByLabelText('Selected segment text color') as HTMLInputElement).value).toBe('#00ff00')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('the swatch falls back to the track color when the selected segment has none of its own', () => {
|
|
148
|
+
renderPanel('karaoke', {
|
|
149
|
+
color: '#abcdef',
|
|
150
|
+
segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
|
|
151
|
+
}, { selectedCaptionId: 'cap-0' })
|
|
152
|
+
expect((screen.getByLabelText('Selected segment text color') as HTMLInputElement).value).toBe('#abcdef')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a selectedCaptionId that matches no segment falls back to track-level behavior', () => {
|
|
156
|
+
renderPanel('karaoke', {
|
|
157
|
+
segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
|
|
158
|
+
}, { selectedCaptionId: 'cap-does-not-exist' })
|
|
159
|
+
expect(screen.getByLabelText('Caption text color')).toBeTruthy()
|
|
160
|
+
expect(screen.queryByLabelText('Selected segment text color')).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
87
164
|
describe('TranscriptPanel caption text edits', () => {
|
|
88
165
|
// Regression: makeCaptionEdit fires every callback it's given with the same
|
|
89
166
|
// updated project. TranscriptPanel used to pass BOTH onProjectChange and
|