@devbycrux/editor 0.1.0 → 0.3.0

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,406 @@
1
+ // AudioTrackRow — renders one audio lane on the timeline.
2
+ //
3
+ // A lane can contain one or more AudioTrack items (e.g. two voiceover segments
4
+ // at different times sharing the same row). Supports: drag-to-reposition,
5
+ // cross-lane drag, edge trim, mute toggle, inline volume, delete,
6
+ // click-to-select, and inspect button.
7
+
8
+ import { useEffect, useRef, useState } from 'react'
9
+ import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
10
+ import type { AudioTrack } from '../../schema'
11
+ import type { Project } from '../../types'
12
+ import { pct } from './utils'
13
+ import { useTimelineContext } from './TimelineContext'
14
+ import { useItemDragDrop } from './useItemDragDrop'
15
+ import type { Draggable, DragEventContext } from './useItemDragDrop'
16
+ import AudioWaveformLayer from './AudioWaveformLayer'
17
+ import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
18
+ import { applyMuteToSelection, applyResizeDeltaToSelection } from './multiSelectOps'
19
+
20
+ interface AudioTrackRowProps {
21
+ tracks: AudioTrack[]
22
+ laneIndex: number
23
+ laneCount: number
24
+ project: Project
25
+ onProjectChange?: (p: Project) => void
26
+ onOverlayEdit?: (p: Project) => void
27
+ /** Unified multi-selection — same array shared across visual + audio rows. */
28
+ selectedIds: string[]
29
+ onSelectItem: (id: string | null, additive: boolean) => void
30
+ onInspect?: (id: string) => void
31
+ getWaveformChunks?: GetWaveformChunks
32
+ resolveFilePath?: ResolveFilePath
33
+ }
34
+
35
+ const LANE_HEIGHT_PX = 40 // must match the h-10 (2.5rem = 40px) on the row container
36
+
37
+ function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
38
+ return {
39
+ ...project,
40
+ audio: {
41
+ ...project.audio,
42
+ tracks: (project.audio?.tracks ?? []).map(t =>
43
+ t.id === trackId ? { ...t, ...changes } : t,
44
+ ),
45
+ },
46
+ }
47
+ }
48
+
49
+ export default function AudioTrackRow({
50
+ tracks,
51
+ laneIndex,
52
+ laneCount,
53
+ project,
54
+ onProjectChange,
55
+ onOverlayEdit,
56
+ selectedIds,
57
+ onSelectItem,
58
+ onInspect,
59
+ getWaveformChunks,
60
+ resolveFilePath,
61
+ }: AudioTrackRowProps) {
62
+ const {
63
+ totalDuration,
64
+ currentTime,
65
+ snapBoundaries,
66
+ scrollRef,
67
+ overlayDraggedRef,
68
+ zoomRef,
69
+ } = useTimelineContext()
70
+
71
+ const { beginDrag, beginResize } = useItemDragDrop({
72
+ totalDuration,
73
+ snapBoundaries,
74
+ scrollRef,
75
+ zoomRef,
76
+ draggedFlagRef: overlayDraggedRef,
77
+ })
78
+
79
+ return (
80
+ <div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
81
+ {/* Playhead line */}
82
+ <div
83
+ className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
84
+ style={{ left: `${pct(currentTime, totalDuration)}%` }}
85
+ />
86
+ {tracks.map(track => (
87
+ <AudioTrackItem
88
+ key={track.id}
89
+ track={track}
90
+ laneIndex={laneIndex}
91
+ laneCount={laneCount}
92
+ project={project}
93
+ totalDuration={totalDuration}
94
+ selected={selectedIds.includes(track.id)}
95
+ selectedIds={selectedIds}
96
+ onSelectItem={onSelectItem}
97
+ onInspect={onInspect}
98
+ onProjectChange={onProjectChange}
99
+ onOverlayEdit={onOverlayEdit}
100
+ beginDrag={beginDrag}
101
+ beginResize={beginResize}
102
+ overlayDraggedRef={overlayDraggedRef}
103
+ getWaveformChunks={getWaveformChunks}
104
+ resolveFilePath={resolveFilePath}
105
+ />
106
+ ))}
107
+ {/* Crossfade indicators — shown in the overlap zone between two tracks */}
108
+ {(() => {
109
+ const sorted = [...tracks].filter(t => !t.muted).sort((a, b) => a.start - b.start)
110
+ const indicators = []
111
+ for (let i = 0; i < sorted.length - 1; i++) {
112
+ const a = sorted[i], b = sorted[i + 1]
113
+ if (a.end > b.start) {
114
+ const overlapStart = b.start
115
+ const overlapEnd = Math.min(a.end, b.end)
116
+ indicators.push(
117
+ <div
118
+ key={`xfade-${a.id}-${b.id}`}
119
+ className="absolute top-0 bottom-0 pointer-events-none z-[5] flex items-center justify-center"
120
+ style={{
121
+ left: `${pct(overlapStart, totalDuration)}%`,
122
+ width: `${pct(overlapEnd - overlapStart, totalDuration)}%`,
123
+ }}
124
+ >
125
+ {/* Crossfade background highlight */}
126
+ <div className="absolute inset-0 bg-amber-400/15 border-x border-amber-400/30" />
127
+ {/* Crossfade icon — two crossing lines */}
128
+ <svg width="16" height="16" viewBox="0 0 16 16" className="relative text-amber-300/70 drop-shadow-sm">
129
+ <line x1="2" y1="12" x2="14" y2="4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
130
+ <line x1="2" y1="4" x2="14" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
131
+ </svg>
132
+ </div>
133
+ )
134
+ }
135
+ }
136
+ return indicators
137
+ })()}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ // ── Single audio item within a lane ──────────────────────────────────────────
143
+
144
+ interface AudioTrackItemProps {
145
+ track: AudioTrack
146
+ laneIndex: number
147
+ laneCount: number
148
+ project: Project
149
+ totalDuration: number
150
+ selected: boolean
151
+ selectedIds: string[]
152
+ onSelectItem: (id: string | null, additive: boolean) => void
153
+ onInspect?: (id: string) => void
154
+ onProjectChange?: (p: Project) => void
155
+ onOverlayEdit?: (p: Project) => void
156
+ beginDrag: ReturnType<typeof useItemDragDrop>['beginDrag']
157
+ beginResize: ReturnType<typeof useItemDragDrop>['beginResize']
158
+ overlayDraggedRef: React.RefObject<boolean>
159
+ getWaveformChunks?: GetWaveformChunks
160
+ resolveFilePath?: ResolveFilePath
161
+ }
162
+
163
+ function AudioTrackItem({
164
+ track,
165
+ laneIndex,
166
+ laneCount: _laneCount,
167
+ project,
168
+ totalDuration,
169
+ selected,
170
+ selectedIds,
171
+ onSelectItem,
172
+ onInspect,
173
+ onProjectChange,
174
+ onOverlayEdit,
175
+ beginDrag,
176
+ beginResize,
177
+ overlayDraggedRef,
178
+ getWaveformChunks,
179
+ resolveFilePath,
180
+ }: AudioTrackItemProps) {
181
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
182
+ const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
183
+ useEffect(() => () => { if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current) }, [])
184
+
185
+ const left = pct(track.start, totalDuration)
186
+ const width = pct(track.end - track.start, totalDuration)
187
+ const label = track.label ?? track.src.split('/').pop() ?? 'audio'
188
+
189
+ // ── Drag to reposition (horizontal + cross-lane) ──
190
+ function handleDragStart(e: React.MouseEvent) {
191
+ if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
192
+ if (!onProjectChange) return
193
+ let lastUpdated = project
194
+
195
+ beginDrag(e, track as Draggable, {
196
+ onLivePreview: ({ item: moved, dy }: DragEventContext) => {
197
+ // Cross-lane: compute target lane from vertical drag.
198
+ // Positive dy = dragged down = higher lane index.
199
+ const laneDelta = Math.round(dy / LANE_HEIGHT_PX)
200
+ const targetLane = Math.max(0, laneIndex + laneDelta)
201
+
202
+ const next = updateAudioTrack(project, track.id, {
203
+ start: moved.start,
204
+ end: moved.end,
205
+ lane: targetLane,
206
+ })
207
+ lastUpdated = next
208
+ onProjectChange!(next)
209
+ },
210
+ onCommit: () => {
211
+ onOverlayEdit?.(lastUpdated)
212
+ },
213
+ })
214
+ }
215
+
216
+ // ── Edge trim ──
217
+ function handleResizeStart(e: React.MouseEvent, edge: 'start' | 'end') {
218
+ if (!onProjectChange) return
219
+ let lastUpdated = project
220
+
221
+ const origStart = track.start
222
+ const origEnd = track.end
223
+ const origInPoint = track.inPoint ?? 0
224
+ const origOutPoint = track.outPoint ?? (origInPoint + (origEnd - origStart))
225
+ const srcDur = track.sourceDuration ?? Infinity
226
+ const multiTargets = selectedIds.length > 1 && selectedIds.includes(track.id)
227
+
228
+ beginResize(e, track as Draggable, edge, {
229
+ onLivePreview: ({ item: resized }: DragEventContext) => {
230
+ let newInPoint = origInPoint
231
+ let newOutPoint = origOutPoint
232
+
233
+ if (edge === 'start') {
234
+ const dt = resized.start - origStart
235
+ newInPoint = Math.max(0, Math.min(origInPoint + dt, origOutPoint - 0.1))
236
+ } else {
237
+ const dt = resized.end - origEnd
238
+ newOutPoint = Math.max(origInPoint + 0.1, Math.min(origOutPoint + dt, srcDur))
239
+ }
240
+
241
+ let next = updateAudioTrack(project, track.id, {
242
+ start: resized.start,
243
+ end: resized.end,
244
+ inPoint: newInPoint,
245
+ outPoint: newOutPoint,
246
+ })
247
+ if (multiTargets) {
248
+ const dStart = edge === 'start' ? resized.start - origStart : 0
249
+ const dEnd = edge === 'end' ? resized.end - origEnd : 0
250
+ next = applyResizeDeltaToSelection(next, track.id, selectedIds, edge, { dStart, dEnd })
251
+ }
252
+ lastUpdated = next
253
+ onProjectChange!(next)
254
+ },
255
+ onCommit: () => {
256
+ onOverlayEdit?.(lastUpdated)
257
+ },
258
+ })
259
+ }
260
+
261
+ // ── Mute toggle ──
262
+ function handleToggleMute(e: React.MouseEvent) {
263
+ e.stopPropagation()
264
+ if (!onProjectChange) return
265
+ const newMuted = !track.muted
266
+ // Multi-selection: set every selected item to the new state. Solo: just this.
267
+ const targetIds = selectedIds.length > 1 && selectedIds.includes(track.id) ? selectedIds : [track.id]
268
+ const updated = applyMuteToSelection(project, targetIds, newMuted)
269
+ onProjectChange(updated)
270
+ onOverlayEdit?.(updated)
271
+ }
272
+
273
+ // ── Delete (two-step confirm) ──
274
+ function handleDeleteClick(e: React.MouseEvent) {
275
+ e.stopPropagation()
276
+ if (confirmingDelete) {
277
+ if (!onProjectChange) return
278
+ const updated: Project = {
279
+ ...project,
280
+ audio: {
281
+ ...project.audio,
282
+ tracks: (project.audio?.tracks ?? []).filter(t => t.id !== track.id),
283
+ },
284
+ }
285
+ onProjectChange(updated)
286
+ onOverlayEdit?.(updated)
287
+ onSelectItem(null, false)
288
+ setConfirmingDelete(false)
289
+ } else {
290
+ setConfirmingDelete(true)
291
+ if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current)
292
+ deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 3000)
293
+ }
294
+ }
295
+
296
+ // ── Click to select ──
297
+ function handleBarClick(e: React.MouseEvent) {
298
+ e.stopPropagation()
299
+ if (overlayDraggedRef.current) return
300
+ const additive = e.shiftKey || e.metaKey || e.ctrlKey
301
+ onSelectItem(track.id, additive)
302
+ }
303
+
304
+ // ── Double-click to open inspector ──
305
+ function handleBarDoubleClick(e: React.MouseEvent) {
306
+ e.stopPropagation()
307
+ if (overlayDraggedRef.current) return
308
+ onInspect?.(track.id)
309
+ }
310
+
311
+ return (
312
+ <div
313
+ className={`absolute top-1 bottom-1 rounded cursor-grab active:cursor-grabbing flex items-center overflow-hidden
314
+ ${track.muted ? 'bg-white/10' : 'bg-emerald-500/40 border border-emerald-500/60'}
315
+ ${selected ? 'ring-1 ring-inset ring-emerald-300/80' : ''}`}
316
+ style={{ left: `${left}%`, width: `${width}%` }}
317
+ title={label}
318
+ onClick={handleBarClick}
319
+ onDoubleClick={handleBarDoubleClick}
320
+ onMouseDown={handleDragStart}
321
+ >
322
+ {/* Waveform layer */}
323
+ <AudioWaveformLayer
324
+ track={track}
325
+ projectId={project.id}
326
+ getWaveformChunks={getWaveformChunks}
327
+ resolveFilePath={resolveFilePath}
328
+ />
329
+
330
+ {/* Fade-in gradient */}
331
+ {(track.fadeIn ?? 0) > 0 && (
332
+ <div
333
+ className="absolute top-0 bottom-0 left-0 pointer-events-none z-[2]"
334
+ style={{
335
+ width: `${Math.min(100, ((track.fadeIn ?? 0) / (track.end - track.start)) * 100)}%`,
336
+ background: 'linear-gradient(to right, rgba(0,0,0,0.6), transparent)',
337
+ }}
338
+ />
339
+ )}
340
+ {/* Fade-out gradient */}
341
+ {(track.fadeOut ?? 0) > 0 && (
342
+ <div
343
+ className="absolute top-0 bottom-0 right-0 pointer-events-none z-[2]"
344
+ style={{
345
+ width: `${Math.min(100, ((track.fadeOut ?? 0) / (track.end - track.start)) * 100)}%`,
346
+ background: 'linear-gradient(to left, rgba(0,0,0,0.6), transparent)',
347
+ }}
348
+ />
349
+ )}
350
+
351
+ {/* Left resize handle */}
352
+ <div
353
+ className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
354
+ onMouseDown={(e) => handleResizeStart(e, 'start')}
355
+ />
356
+
357
+ {/* Mute toggle */}
358
+ <button
359
+ className="shrink-0 ml-2 z-10 cursor-pointer"
360
+ onClick={handleToggleMute}
361
+ title={track.muted ? 'Unmute' : 'Mute'}
362
+ >
363
+ {track.muted
364
+ ? <VolumeX className="w-3.5 h-3.5 text-white/30" />
365
+ : <Volume2 className="w-3.5 h-3.5 text-emerald-200" />}
366
+ </button>
367
+
368
+ {/* Track type label */}
369
+ <span className="text-[10px] text-emerald-200 truncate flex-1 min-w-0 ml-1.5 pointer-events-none z-[1] drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">
370
+ {track.type === 'voiceover' ? 'Voiceover' : track.type === 'music' ? 'Music' : label}
371
+ </span>
372
+
373
+ {/* Info button when selected */}
374
+ {selected && onInspect && (
375
+ <button
376
+ className="shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 text-emerald-200"
377
+ onClick={(e) => { e.stopPropagation(); onInspect(track.id) }}
378
+ title="Inspect audio track"
379
+ >
380
+ <Info size={12} />
381
+ </button>
382
+ )}
383
+
384
+ {/* Delete button when selected (two-step confirm) */}
385
+ {selected && (
386
+ <button
387
+ className={`shrink-0 ml-1 mr-2 z-10 cursor-pointer transition-colors ${
388
+ confirmingDelete
389
+ ? 'opacity-100 text-red-400'
390
+ : 'opacity-60 hover:opacity-100 text-emerald-200'
391
+ }`}
392
+ onClick={handleDeleteClick}
393
+ title={confirmingDelete ? 'Click again to confirm delete' : 'Delete audio track'}
394
+ >
395
+ <Trash2 size={12} />
396
+ </button>
397
+ )}
398
+
399
+ {/* Right resize handle */}
400
+ <div
401
+ className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
402
+ onMouseDown={(e) => handleResizeStart(e, 'end')}
403
+ />
404
+ </div>
405
+ )
406
+ }
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { WaveformChunk } from '../../types'
3
+ import type { AudioTrack } from '../../schema'
4
+
5
+ /**
6
+ * Fetches the waveform chunks for one audio track. Threaded from the timeline
7
+ * root (VideoEditor supplies `adapter.getWaveformChunks` in V4). The package is
8
+ * host-agnostic: it takes the fn as a prop rather than reaching for an adapter.
9
+ */
10
+ export type GetWaveformChunks = (
11
+ projectId: string,
12
+ trackId: string,
13
+ trackSrc: string,
14
+ chunkDurationS?: number,
15
+ ) => Promise<WaveformChunk[]>
16
+
17
+ /** Resolves a host-internal chunk path into a displayable URL (e.g. Montaj's
18
+ * `/api/files?path=…`). Defaults to identity when the host omits it. */
19
+ export type ResolveFilePath = (path: string) => string
20
+
21
+ interface Props {
22
+ track: AudioTrack
23
+ projectId: string
24
+ /** When absent/undefined, no waveform is rendered (graceful fallback). */
25
+ getWaveformChunks?: GetWaveformChunks
26
+ resolveFilePath?: ResolveFilePath
27
+ }
28
+
29
+ function LoadingBar() {
30
+ return (
31
+ <div className="relative w-full h-full overflow-hidden rounded bg-emerald-500/20">
32
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function PlainFallback() {
38
+ return <div className="w-full h-full rounded bg-emerald-500/40 border border-emerald-500/60" />
39
+ }
40
+
41
+ export default function AudioWaveformLayer({ track, projectId, getWaveformChunks, resolveFilePath }: Props) {
42
+ const [chunks, setChunks] = useState<WaveformChunk[] | null>(null)
43
+ const [error, setError] = useState(false)
44
+
45
+ useEffect(() => {
46
+ if (!getWaveformChunks) return
47
+ let cancelled = false
48
+ setChunks(null)
49
+ setError(false)
50
+
51
+ getWaveformChunks(projectId, track.id, track.src)
52
+ .then((result) => {
53
+ if (!cancelled) setChunks(result)
54
+ })
55
+ .catch(() => {
56
+ if (!cancelled) setError(true)
57
+ })
58
+
59
+ return () => {
60
+ cancelled = true
61
+ }
62
+ }, [track.id, track.src, projectId, getWaveformChunks])
63
+
64
+ const isMuted = track.muted ?? false
65
+
66
+ // No fetcher supplied → render nothing (graceful: the bar shows its base color).
67
+ if (!getWaveformChunks) return null
68
+
69
+ const resolvePath = resolveFilePath ?? ((p: string) => p)
70
+
71
+ const content = (() => {
72
+ if (error) return <PlainFallback />
73
+ if (!chunks) return <LoadingBar />
74
+
75
+ const sourceDur = track.sourceDuration ?? (track.end - track.start)
76
+ const inPt = track.inPoint ?? 0
77
+ const outPt = track.outPoint ?? sourceDur
78
+ const visibleSpan = outPt - inPt
79
+
80
+ if (visibleSpan <= 0) return <PlainFallback />
81
+
82
+ // Filter to chunks that overlap [inPt, outPt]
83
+ const visible = chunks.filter((c) => c.end > inPt && c.start < outPt)
84
+
85
+ return (
86
+ <div className="relative w-full h-full overflow-hidden rounded">
87
+ {visible.map((chunk) => {
88
+ const leftPct = ((chunk.start - inPt) / visibleSpan) * 100
89
+ const widthPct = ((chunk.end - chunk.start) / visibleSpan) * 100
90
+
91
+ return (
92
+ <img
93
+ key={chunk.path}
94
+ src={resolvePath(chunk.path)}
95
+ alt=""
96
+ draggable={false}
97
+ className="absolute top-0 h-full object-fill"
98
+ style={{
99
+ left: `${leftPct}%`,
100
+ width: `${widthPct}%`,
101
+ }}
102
+ />
103
+ )
104
+ })}
105
+ </div>
106
+ )
107
+ })()
108
+
109
+ return (
110
+ <div
111
+ className="absolute inset-0 pointer-events-none"
112
+ style={{ opacity: isMuted ? 0.3 : 1 }}
113
+ >
114
+ {content}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,30 @@
1
+ import { useRef, type KeyboardEvent } from 'react'
2
+ import type { CaptionSegment } from '../../schema'
3
+
4
+ export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit: (text: string) => void }) {
5
+ const spanRef = useRef<HTMLSpanElement>(null)
6
+
7
+ function handleBlur() {
8
+ const text = spanRef.current?.textContent?.trim() ?? ''
9
+ if (!text) { if (spanRef.current) spanRef.current.textContent = seg.text; return }
10
+ if (text !== seg.text) onEdit(text)
11
+ }
12
+
13
+ function handleKeyDown(e: KeyboardEvent<HTMLSpanElement>) {
14
+ if (e.key === 'Enter') { e.preventDefault(); spanRef.current?.blur() }
15
+ if (e.key === 'Escape') { if (spanRef.current) spanRef.current.textContent = seg.text; spanRef.current?.blur() }
16
+ }
17
+
18
+ return (
19
+ <span
20
+ ref={spanRef}
21
+ contentEditable
22
+ suppressContentEditableWarning
23
+ onBlur={handleBlur}
24
+ onKeyDown={handleKeyDown}
25
+ className="cursor-text rounded px-0.5 hover:bg-white/5 focus:bg-white/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
26
+ >
27
+ {seg.text}
28
+ </span>
29
+ )
30
+ }