@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
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="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 })}
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(style: Captions['style'], extra: Partial<Captions> = {}) {
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