@bycrux/editor 0.8.8 → 0.8.9

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.8.8",
3
+ "version": "0.8.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/schema.ts CHANGED
@@ -43,13 +43,15 @@ export interface CaptionSegment {
43
43
  }
44
44
 
45
45
  export interface Captions {
46
- style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle'
46
+ style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
47
47
  segments: CaptionSegment[]
48
48
  // ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
49
49
  position?: 'center' | 'top-left' | 'bottom-left'
50
50
  color?: string
51
51
  fontsize?: number
52
52
  bgColor?: string
53
+ accentColor?: string // active-word/box accent color — highlight-box, outline
54
+ googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
53
55
  }
54
56
 
55
57
  export interface VisualItem {
@@ -58,8 +58,17 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
58
58
 
59
59
  const frame = Math.round(currentTime * fps)
60
60
  const lastSeg = track.segments[track.segments.length - 1]
61
+
62
+ // Theme props for the template: everything on the track except style/segments
63
+ // (handled separately) and googleFonts (a render-time font-loading hint, not
64
+ // a template prop). Normalize the legacy lowercase `fontsize` key to the
65
+ // camelCase `fontSize` templates actually read.
66
+ const { style: _style, segments: _segments, googleFonts: _googleFonts, fontsize, ...theme } = track
67
+ const themeProps: Record<string, unknown> = { ...theme }
68
+ if (fontsize != null) themeProps.fontSize = fontsize
69
+
61
70
  const element = (factory && scale !== null)
62
- ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
71
+ ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments, ...themeProps })
63
72
  : null
64
73
 
65
74
  return (
@@ -138,7 +138,12 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
138
138
  const fps = project.settings?.fps ?? 30
139
139
  const frame = 1 / fps
140
140
  const onKey = (e: globalThis.KeyboardEvent) => {
141
+ // While the transcript modal is open, or the caret is in editable text
142
+ // (caption segments are contentEditable), arrows move the text cursor —
143
+ // never the playhead.
144
+ if (transcriptModalOpen) return
141
145
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
146
+ if ((e.target as HTMLElement).isContentEditable) return
142
147
  if (e.key === 'Escape') { setMarkers([null, null]); return }
143
148
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
144
149
  e.preventDefault()
@@ -152,7 +157,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
152
157
  }
153
158
  document.addEventListener('keydown', onKey)
154
159
  return () => document.removeEventListener('keydown', onKey)
155
- }, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps])
160
+ }, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
156
161
 
157
162
  // Derive selection from two placed markers
158
163
  const selection = markers[0] !== null && markers[1] !== null
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef, useState } from 'react'
1
2
  import type { Project } from '../../types'
2
3
  import { formatTime } from './utils'
3
4
  import { EditableSegment } from './EditableSegment'
@@ -17,6 +18,21 @@ interface TranscriptPanelProps {
17
18
 
18
19
  export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
19
20
  const segs = captionTrack?.segments ?? []
21
+ const [confirmRemove, setConfirmRemove] = useState(false)
22
+ const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
23
+ // Live slider value during a drag — only committed (persisted) on
24
+ // pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
25
+ const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
26
+
27
+ useEffect(() => {
28
+ setFontsize(captionTrack?.fontsize ?? 46)
29
+ }, [captionTrack?.fontsize])
30
+
31
+ useEffect(() => {
32
+ return () => {
33
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
34
+ }
35
+ }, [])
20
36
  // Find active segment index
21
37
  const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
22
38
  const nearIdx = activeIdx !== -1 ? activeIdx
@@ -31,8 +47,8 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
31
47
  <div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
32
48
  <div className="flex items-center justify-between mb-2">
33
49
  <span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
34
- <div className="flex items-center gap-2">
35
- {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle'] as const).map(style => {
50
+ <div className="flex items-center gap-2 flex-wrap justify-end">
51
+ {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
36
52
  const active = captionTrack.style === style
37
53
  return (
38
54
  <button
@@ -52,6 +68,38 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
52
68
  </button>
53
69
  )
54
70
  })}
71
+ {captionTrack && (
72
+ <div className="flex items-center gap-1">
73
+ <input
74
+ type="range"
75
+ min={28}
76
+ max={120}
77
+ step={2}
78
+ value={fontsize}
79
+ className="w-20 sm:w-24 accent-purple-500"
80
+ onChange={e => {
81
+ if (!project.captions) return
82
+ const v = Number(e.target.value)
83
+ setFontsize(v)
84
+ // Live preview only — cheap local-state update, no save.
85
+ onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
86
+ }}
87
+ onPointerUp={e => {
88
+ if (!project.captions) return
89
+ const v = Number((e.target as HTMLInputElement).value)
90
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
91
+ }}
92
+ onKeyUp={e => {
93
+ if (!project.captions) return
94
+ const v = Number((e.target as HTMLInputElement).value)
95
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
96
+ }}
97
+ />
98
+ <span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
99
+ {fontsize}px
100
+ </span>
101
+ </div>
102
+ )}
55
103
  {onRegenerateCaptions && (
56
104
  <button
57
105
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
@@ -60,6 +108,30 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
60
108
  Regenerate
61
109
  </button>
62
110
  )}
111
+ {captionTrack && (
112
+ <button
113
+ className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
114
+ confirmRemove
115
+ ? 'bg-red-500/20 border-red-500/60 text-red-400'
116
+ : 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
117
+ }`}
118
+ onClick={() => {
119
+ if (!confirmRemove) {
120
+ setConfirmRemove(true)
121
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
122
+ removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
123
+ return
124
+ }
125
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
126
+ setConfirmRemove(false)
127
+ // Persistence needs an explicit `null` to clear the field server-side
128
+ // (a live PUT test confirmed `undefined` is dropped from the JSON body).
129
+ onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
130
+ }}
131
+ >
132
+ {confirmRemove ? 'Really remove?' : 'Remove'}
133
+ </button>
134
+ )}
63
135
  {segs.length > 0 && (
64
136
  <button
65
137
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"