@bycrux/editor 0.11.0 → 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 +1 -1
- package/src/schema.ts +7 -0
- package/src/video/__tests__/captionSnap.test.ts +87 -0
- package/src/video/preview/CaptionPreview.tsx +12 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +6 -1
- package/src/video/preview/captionDragState.ts +27 -1
- package/src/video/timeline/Timeline.tsx +2 -0
- package/src/video/timeline/TranscriptPanel.tsx +48 -7
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +79 -2
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 {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centre/middle snap for the caption move gesture.
|
|
3
|
+
*
|
|
4
|
+
* Regression: the first implementation of `captionDragGeometry` translated the
|
|
5
|
+
* segment with no snapping at all, so captions were the only draggable thing in
|
|
6
|
+
* the preview that would not settle on the frame's centre line — overlays did,
|
|
7
|
+
* captions did not. Edge snap remains deliberately absent (its geometry does
|
|
8
|
+
* not transfer to a content-sized anchor box); only centre/middle is expected.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
captionDragGeometry,
|
|
13
|
+
captionDragPatch,
|
|
14
|
+
CAPTION_SNAP_THRESHOLD,
|
|
15
|
+
type CaptionDragState,
|
|
16
|
+
type CaptionFrameMetrics,
|
|
17
|
+
} from '../preview/captionDragState'
|
|
18
|
+
|
|
19
|
+
// previewScale 1 with a 1080x1920 design frame: 1 screen px == 1 design px, so
|
|
20
|
+
// a dx of 10.8px is exactly 1% of frame width.
|
|
21
|
+
const METRICS: CaptionFrameMetrics = { previewScale: 1, renderW: 1080, renderH: 1920 }
|
|
22
|
+
const PX_PER_PCT_X = 1080 / 100
|
|
23
|
+
const PX_PER_PCT_Y = 1920 / 100
|
|
24
|
+
|
|
25
|
+
function move(initOffsetX: number, initOffsetY: number): CaptionDragState {
|
|
26
|
+
return {
|
|
27
|
+
id: 'cap-1', type: 'move',
|
|
28
|
+
initX: 0, initY: 0,
|
|
29
|
+
initOffsetX, initOffsetY, initScale: 1,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('captionDragGeometry — centre/middle snap', () => {
|
|
34
|
+
it('snaps offsetX to exactly 0 inside the threshold', () => {
|
|
35
|
+
// Start 5% left of centre, drag right to land 1% past centre (inside 2.5%).
|
|
36
|
+
const g = captionDragGeometry(move(-5, 40), 6 * PX_PER_PCT_X, 0, METRICS)
|
|
37
|
+
expect(g.offsetX).toBe(0)
|
|
38
|
+
expect(g.snapX).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('snaps offsetY to exactly 0 inside the threshold', () => {
|
|
42
|
+
const g = captionDragGeometry(move(40, -5), 0, 6 * PX_PER_PCT_Y, METRICS)
|
|
43
|
+
expect(g.offsetY).toBe(0)
|
|
44
|
+
expect(g.snapY).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('snaps both axes at once when dragged through the centre point', () => {
|
|
48
|
+
const g = captionDragGeometry(move(-1, -1), PX_PER_PCT_X, PX_PER_PCT_Y, METRICS)
|
|
49
|
+
expect(g).toMatchObject({ offsetX: 0, offsetY: 0, snapX: true, snapY: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does NOT snap outside the threshold — the raw offset is preserved', () => {
|
|
53
|
+
// Land 4% right of centre: outside 2.5%, so no snap and no rounding.
|
|
54
|
+
const g = captionDragGeometry(move(0, 40), 4 * PX_PER_PCT_X, 0, METRICS)
|
|
55
|
+
expect(g.offsetX).toBeCloseTo(4, 6)
|
|
56
|
+
expect(g.snapX).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('uses the same threshold as the overlay hook', () => {
|
|
60
|
+
// Just inside snaps, just outside does not — pinning the boundary so the
|
|
61
|
+
// two gestures cannot drift apart.
|
|
62
|
+
const inside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD - 0.1) * PX_PER_PCT_X, 0, METRICS)
|
|
63
|
+
const outside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD + 0.1) * PX_PER_PCT_X, 0, METRICS)
|
|
64
|
+
expect(inside.snapX).toBe(true)
|
|
65
|
+
expect(outside.snapX).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('commits the snapped value, not the raw pointer position', () => {
|
|
69
|
+
const d = move(-5, 40)
|
|
70
|
+
const g = captionDragGeometry(d, 6 * PX_PER_PCT_X, 0, METRICS)
|
|
71
|
+
expect(captionDragPatch(d, g)).toEqual({ offsetX: 0, offsetY: g.offsetY })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('leaves resize gestures unsnapped and untouched', () => {
|
|
75
|
+
const d: CaptionDragState = {
|
|
76
|
+
id: 'cap-1', type: 'resize-se',
|
|
77
|
+
initX: 0, initY: 0, initOffsetX: 1, initOffsetY: 1, initScale: 1,
|
|
78
|
+
}
|
|
79
|
+
const g = captionDragGeometry(d, 20, 20, METRICS)
|
|
80
|
+
// A resize must never move the segment, and must not report a snap.
|
|
81
|
+
expect(g.offsetX).toBe(1)
|
|
82
|
+
expect(g.offsetY).toBe(1)
|
|
83
|
+
expect(g.snapX).toBeUndefined()
|
|
84
|
+
expect(g.snapY).toBeUndefined()
|
|
85
|
+
expect(captionDragPatch(d, g)).toEqual({ scale: g.scale })
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -345,6 +345,18 @@ export default function CaptionPreview({
|
|
|
345
345
|
)}
|
|
346
346
|
</OverlayErrorBoundary>
|
|
347
347
|
|
|
348
|
+
{/* Centre/middle snap guides. Same amber hairlines, same z 50, and the
|
|
349
|
+
same `drag.type === 'move'` gate the overlay layer uses, so a snapped
|
|
350
|
+
caption reads identically to a snapped overlay. Only the centre pair
|
|
351
|
+
exists here — caption edge snap is deliberately not implemented (its
|
|
352
|
+
geometry does not transfer; see captionDragState.ts). */}
|
|
353
|
+
{drag?.type === 'move' && live?.snapX && (
|
|
354
|
+
<div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
|
|
355
|
+
)}
|
|
356
|
+
{drag?.type === 'move' && live?.snapY && (
|
|
357
|
+
<div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
|
|
358
|
+
)}
|
|
359
|
+
|
|
348
360
|
{/* Selection box — the only interactive part of the caption layer. z 50
|
|
349
361
|
matches the overlay selection handles (see the layering note above);
|
|
350
362
|
the wrapper stays click-through so only the box itself catches events. */}
|
|
@@ -93,9 +93,14 @@ describe('captionDragGeometry — move', () => {
|
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
it('is a no-op at the starting pointer position', () => {
|
|
96
|
+
// 12/34 are both well outside the centre-snap radius, so the geometry is
|
|
97
|
+
// the untouched starting offsets. The move branch also reports whether each
|
|
98
|
+
// axis is currently snapped (see captionSnap.test.ts) — asserted here as
|
|
99
|
+
// false so this stays a genuine no-op check rather than an assertion about
|
|
100
|
+
// the return shape.
|
|
96
101
|
const d = drag({ initOffsetX: 12, initOffsetY: 34, initScale: 1.5 })
|
|
97
102
|
expect(captionDragGeometry(d, d.initX, d.initY, metrics(540)))
|
|
98
|
-
.toEqual({ offsetX: 12, offsetY: 34, scale: 1.5 })
|
|
103
|
+
.toEqual({ offsetX: 12, offsetY: 34, scale: 1.5, snapX: false, snapY: false })
|
|
99
104
|
})
|
|
100
105
|
})
|
|
101
106
|
|
|
@@ -60,8 +60,18 @@ export interface CaptionGeometry {
|
|
|
60
60
|
offsetX: number
|
|
61
61
|
offsetY: number
|
|
62
62
|
scale: number
|
|
63
|
+
/** True while the move gesture is snapped to the frame's horizontal centre. */
|
|
64
|
+
snapX?: boolean
|
|
65
|
+
/** True while the move gesture is snapped to the frame's vertical middle. */
|
|
66
|
+
snapY?: boolean
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Centre/middle snap radius, in percent of frame. Matches `SNAP_THRESHOLD` in
|
|
71
|
+
* `useDragOverlay` so dragging a caption and dragging an overlay feel the same.
|
|
72
|
+
*/
|
|
73
|
+
export const CAPTION_SNAP_THRESHOLD = 2.5
|
|
74
|
+
|
|
65
75
|
/**
|
|
66
76
|
* The preview's design-resolution mapping.
|
|
67
77
|
*
|
|
@@ -145,7 +155,23 @@ export function captionDragGeometry(
|
|
|
145
155
|
const { dx, dy } = screenDeltaToFramePercent(clientX - drag.initX, clientY - drag.initY, metrics)
|
|
146
156
|
|
|
147
157
|
if (drag.type === 'move') {
|
|
148
|
-
|
|
158
|
+
// Centre/middle snap. Unlike the overlay hook's EDGE snap — whose
|
|
159
|
+
// `(0.5 - s/2) * 100` geometry genuinely does not transfer to a caption's
|
|
160
|
+
// content-sized anchor box (see the file header) — the centre snap is
|
|
161
|
+
// geometry-independent: offsetX 0 is the frame's horizontal centre and
|
|
162
|
+
// offsetY 0 its vertical middle, for a caption exactly as for an overlay.
|
|
163
|
+
// Same threshold as useDragOverlay so both gestures feel identical.
|
|
164
|
+
const rawX = drag.initOffsetX + dx
|
|
165
|
+
const rawY = drag.initOffsetY + dy
|
|
166
|
+
const snapX = Math.abs(rawX) < CAPTION_SNAP_THRESHOLD
|
|
167
|
+
const snapY = Math.abs(rawY) < CAPTION_SNAP_THRESHOLD
|
|
168
|
+
return {
|
|
169
|
+
offsetX: snapX ? 0 : rawX,
|
|
170
|
+
offsetY: snapY ? 0 : rawY,
|
|
171
|
+
scale: drag.initScale,
|
|
172
|
+
snapX,
|
|
173
|
+
snapY,
|
|
174
|
+
}
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
// Resize from a corner: project the pointer delta onto the corner's outward
|
|
@@ -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
|