@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.11.0",
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 {
@@ -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
- return { offsetX: drag.initOffsetX + dx, offsetY: drag.initOffsetY + dy, scale: drag.initScale }
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="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