@bycrux/editor 0.8.9 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { RefreshCw, AlertCircle, Download, Info } from 'lucide-react'
2
+ import { RefreshCw, AlertCircle, Download, Info, Undo2, Redo2 } from 'lucide-react'
3
3
  import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
4
4
  import { applyTheme, defaultMontajTheme } from '../theme'
5
5
  import { useProjectState } from '../state/use-project-state'
@@ -409,19 +409,39 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
409
409
  {/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
410
410
  right. Pinned (shrink-0) above the scrolling canvas area. */}
411
411
  <div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
412
- <button
413
- onClick={handleRefresh}
414
- disabled={refreshing}
415
- className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
416
- refreshState === 'err'
417
- ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
418
- : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
419
- }`}
420
- title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
421
- >
422
- {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
423
- <span className="text-xs font-medium">Refresh</span>
424
- </button>
412
+ <div className="flex items-center gap-2">
413
+ <button
414
+ onClick={handleRefresh}
415
+ disabled={refreshing}
416
+ className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
417
+ refreshState === 'err'
418
+ ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
419
+ : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
420
+ }`}
421
+ title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
422
+ >
423
+ {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
424
+ <span className="text-xs font-medium">Refresh</span>
425
+ </button>
426
+ <button
427
+ onClick={() => state.undo()}
428
+ disabled={!state.canUndo}
429
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
430
+ title="Undo (Cmd/Ctrl+Z)"
431
+ aria-label="Undo"
432
+ >
433
+ <Undo2 size={18} />
434
+ </button>
435
+ <button
436
+ onClick={() => state.redo()}
437
+ disabled={!state.canRedo}
438
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
439
+ title="Redo (Cmd/Ctrl+Shift+Z)"
440
+ aria-label="Redo"
441
+ >
442
+ <Redo2 size={18} />
443
+ </button>
444
+ </div>
425
445
 
426
446
  <div className="flex items-center gap-2">
427
447
  {slots?.toolbarActions}
@@ -9,7 +9,7 @@ import type {
9
9
  GlobalOverlayProp,
10
10
  EditorAdapter,
11
11
  } from '../types'
12
- import { Button, cn, inspectorInputClass } from '../ui'
12
+ import { Button, cn, inspectorInputClass, SwatchInput } from '../ui'
13
13
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
14
14
 
15
15
  function parseNumber(v: string): number | null {
@@ -173,40 +173,6 @@ function PropEditor({
173
173
  )
174
174
  }
175
175
 
176
- // Color control that actually reads as one: a filled rounded swatch (the live
177
- // value) sitting next to the hex string, the whole row clickable to open the
178
- // native color picker. The transparent <input type="color"> overlays the
179
- // swatch so the OS picker anchors there.
180
- function SwatchInput({
181
- value,
182
- onChange,
183
- ariaLabel,
184
- }: {
185
- value: string
186
- onChange: (v: string) => void
187
- ariaLabel: string
188
- }) {
189
- return (
190
- <div className="flex items-center gap-2.5">
191
- <label className="relative h-7 w-7 shrink-0 cursor-pointer">
192
- <span
193
- className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
194
- style={{ backgroundColor: value }}
195
- aria-hidden
196
- />
197
- <input
198
- type="color"
199
- value={value}
200
- onChange={e => onChange(e.target.value)}
201
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
202
- aria-label={ariaLabel}
203
- />
204
- </label>
205
- <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
206
- </div>
207
- )
208
- }
209
-
210
176
  export default function SlidePropertyPanel({
211
177
  project,
212
178
  slide,
package/src/schema.ts CHANGED
@@ -45,12 +45,20 @@ export interface CaptionSegment {
45
45
  export interface Captions {
46
46
  style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
47
47
  segments: CaptionSegment[]
48
- // ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
48
+ // `color` and `fontsize` are read by BOTH paths: the JSX browser preview /
49
+ // Puppeteer render (spread into the caption template props; `fontsize`→`fontSize`)
50
+ // and the ffmpeg-drawtext render branch. `position` and `bgColor` are consumed
51
+ // only by the ffmpeg-drawtext branch and ignored by the JSX preview.
49
52
  position?: 'center' | 'top-left' | 'bottom-left'
50
- color?: string
53
+ color?: string // base caption text color
51
54
  fontsize?: number
52
55
  bgColor?: string
53
- accentColor?: string // active-word/box accent color — highlight-box, outline
56
+ // Per-style accent color fields, each read by the matching JSX caption template.
57
+ // A single UI control writes whichever one the active style uses (see TranscriptPanel).
58
+ accentColor?: string // active-word/box accent — highlight-box, outline
59
+ highlightColor?: string // active word — karaoke
60
+ activeColor?: string // active word — pop
61
+ backgroundColor?: string// text box background — subtitle
54
62
  googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
55
63
  }
56
64
 
@@ -0,0 +1,57 @@
1
+ import { cn } from './utils'
2
+
3
+ // Color control that actually reads as one: a filled rounded swatch (the live
4
+ // value) with the native <input type="color"> overlaid transparently so the OS
5
+ // picker anchors on the swatch. Optionally shows the hex string beside it.
6
+ //
7
+ // `onChange` fires live on every pick (drive a cheap preview). `onCommit`, if
8
+ // given, fires on blur (when the OS picker closes) — use it to persist once
9
+ // instead of on every intermediate value, mirroring a slider's drag→commit.
10
+ export interface SwatchInputProps {
11
+ value: string
12
+ onChange: (v: string) => void
13
+ /** Fired on blur (picker closed) — for callers that preview live via onChange
14
+ * and persist only on commit. Omit to treat every onChange as final. */
15
+ onCommit?: (v: string) => void
16
+ ariaLabel: string
17
+ /** Render the hex string next to the swatch. Default true. */
18
+ showValue?: boolean
19
+ /** Swatch size. `sm` (h-5) suits compact toolbars; `md` (h-7) is the default. */
20
+ size?: 'sm' | 'md'
21
+ /** Tooltip on the row. */
22
+ title?: string
23
+ }
24
+
25
+ export function SwatchInput({
26
+ value,
27
+ onChange,
28
+ onCommit,
29
+ ariaLabel,
30
+ showValue = true,
31
+ size = 'md',
32
+ title,
33
+ }: SwatchInputProps) {
34
+ const box = size === 'sm' ? 'h-5 w-5' : 'h-7 w-7'
35
+ return (
36
+ <div className="flex items-center gap-2.5" title={title}>
37
+ <label className={cn('relative shrink-0 cursor-pointer', box)}>
38
+ <span
39
+ className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
40
+ style={{ backgroundColor: value }}
41
+ aria-hidden
42
+ />
43
+ <input
44
+ type="color"
45
+ value={value}
46
+ onChange={e => onChange(e.target.value)}
47
+ onBlur={e => onCommit?.(e.target.value)}
48
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
49
+ aria-label={ariaLabel}
50
+ />
51
+ </label>
52
+ {showValue && (
53
+ <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
54
+ )}
55
+ </div>
56
+ )
57
+ }
package/src/ui/index.ts CHANGED
@@ -9,6 +9,8 @@ export type { InputProps } from './input'
9
9
  export { Label } from './label'
10
10
  export { Select } from './select'
11
11
  export type { SelectProps } from './select'
12
+ export { SwatchInput } from './SwatchInput'
13
+ export type { SwatchInputProps } from './SwatchInput'
12
14
  export { Switch } from './switch'
13
15
  export { Textarea } from './textarea'
14
16
  export type { TextareaProps } from './textarea'
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet } from 'lucide-react'
2
+ import { Crop, Info, Magnet, Undo2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
4
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
5
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
@@ -475,6 +475,15 @@ function ReviewSurface<P extends Project>({
475
475
  >
476
476
  <Info size={12} />
477
477
  </button>
478
+ <button
479
+ onClick={handleUndo}
480
+ disabled={!canUndo}
481
+ title="Undo (Cmd/Ctrl+Z)"
482
+ aria-label="Undo"
483
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
484
+ >
485
+ <Undo2 size={12} />
486
+ </button>
478
487
  <button
479
488
  onClick={() => handleSplit()}
480
489
  title="Split at playhead (S) — selected item or all clips"
@@ -3,6 +3,27 @@ import type { Project } from '../../types'
3
3
  import { formatTime } from './utils'
4
4
  import { EditableSegment } from './EditableSegment'
5
5
  import { makeCaptionEdit } from './makeCaptionEdit'
6
+ import { SwatchInput } from '../../ui'
7
+
8
+ // Each caption style reads a different accent-color prop in its render template
9
+ // (see render/templates/captions/*.jsx). The color control writes whichever field
10
+ // the active style uses; `clean` and `word-by-word` have no accent (omitted here).
11
+ type CaptionStyle = NonNullable<Project['captions']>['style']
12
+ type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
13
+ const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
14
+ karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
15
+ pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
16
+ 'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
17
+ outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
18
+ subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
19
+ }
20
+
21
+ // The native <input type="color"> only accepts #rrggbb. Stored values are always
22
+ // hex once picked; fall back to the style default for unset / non-hex (e.g. an
23
+ // rgba() template default) so the swatch never gets an invalid value.
24
+ const HEX = /^#[0-9a-f]{6}$/i
25
+ const toHex = (v: unknown, fallback: string): string =>
26
+ typeof v === 'string' && HEX.test(v) ? v : fallback
6
27
 
7
28
  interface TranscriptPanelProps {
8
29
  project: Project
@@ -100,6 +121,42 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
100
121
  </span>
101
122
  </div>
102
123
  )}
124
+ {captionTrack && (() => {
125
+ const accent = ACCENT[captionTrack.style]
126
+ // onChange = live preview only (cheap, no PUT); onBlur commits one save.
127
+ const live = (patch: Record<string, string>) => {
128
+ if (!project.captions) return
129
+ onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
130
+ }
131
+ const commit = (patch: Record<string, string>) => {
132
+ if (!project.captions) return
133
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
134
+ }
135
+ return (
136
+ <div className="flex items-center gap-1.5">
137
+ <SwatchInput
138
+ size="sm"
139
+ 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 })}
145
+ />
146
+ {accent && (
147
+ <SwatchInput
148
+ size="sm"
149
+ showValue={false}
150
+ title={`Caption ${accent.label.toLowerCase()} color`}
151
+ ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
152
+ value={toHex(captionTrack[accent.field], accent.def)}
153
+ onChange={v => live({ [accent.field]: v })}
154
+ onCommit={v => commit({ [accent.field]: v })}
155
+ />
156
+ )}
157
+ </div>
158
+ )
159
+ })()}
103
160
  {onRegenerateCaptions && (
104
161
  <button
105
162
  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"
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import type { Project } from '../../../types'
4
+ import type { Captions } from '../../../schema'
5
+ import TranscriptPanel from '../TranscriptPanel'
6
+
7
+ afterEach(() => cleanup())
8
+
9
+ function makeProject(style: Captions['style'], extra: Partial<Captions> = {}): Project {
10
+ return { id: 'p1', captions: { style, segments: [], ...extra } } as unknown as Project
11
+ }
12
+
13
+ function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
14
+ const onCaptionEdit = vi.fn()
15
+ const onProjectChange = vi.fn()
16
+ const project = makeProject(style, extra)
17
+ render(
18
+ <TranscriptPanel
19
+ project={project}
20
+ captionTrack={project.captions}
21
+ currentTime={0}
22
+ onCaptionEdit={onCaptionEdit}
23
+ onProjectChange={onProjectChange}
24
+ onExpand={() => {}}
25
+ />,
26
+ )
27
+ return { onCaptionEdit, onProjectChange }
28
+ }
29
+
30
+ describe('TranscriptPanel caption color controls', () => {
31
+ it('previews live on change and commits on blur, writing captions.color', () => {
32
+ const { onCaptionEdit, onProjectChange } = renderPanel('karaoke')
33
+ const input = screen.getByLabelText('Caption text color') as HTMLInputElement
34
+
35
+ fireEvent.change(input, { target: { value: '#76b900' } })
36
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
37
+ expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
38
+ expect(onCaptionEdit).not.toHaveBeenCalled() // live preview must not persist
39
+
40
+ fireEvent.blur(input, { target: { value: '#76b900' } })
41
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
42
+ expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
43
+ })
44
+
45
+ it('writes the accent to the field the active style actually reads', () => {
46
+ // karaoke → highlightColor
47
+ let h = renderPanel('karaoke')
48
+ fireEvent.blur(screen.getByLabelText('Caption highlight color'), { target: { value: '#111111' } })
49
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.highlightColor).toBe('#111111')
50
+ cleanup()
51
+
52
+ // pop → activeColor
53
+ h = renderPanel('pop')
54
+ fireEvent.blur(screen.getByLabelText('Caption active color'), { target: { value: '#222222' } })
55
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.activeColor).toBe('#222222')
56
+ cleanup()
57
+
58
+ // subtitle → backgroundColor
59
+ h = renderPanel('subtitle')
60
+ fireEvent.blur(screen.getByLabelText('Caption box color'), { target: { value: '#333333' } })
61
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.backgroundColor).toBe('#333333')
62
+ cleanup()
63
+
64
+ // highlight-box → accentColor
65
+ h = renderPanel('highlight-box')
66
+ fireEvent.blur(screen.getByLabelText('Caption accent color'), { target: { value: '#444444' } })
67
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.accentColor).toBe('#444444')
68
+ })
69
+
70
+ it('hides the accent swatch for styles with no accent', () => {
71
+ renderPanel('clean')
72
+ expect(screen.getByLabelText('Caption text color')).toBeTruthy()
73
+ expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
74
+ cleanup()
75
+
76
+ renderPanel('word-by-word')
77
+ expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
78
+ })
79
+
80
+ it('reflects the stored hex value on the swatch input', () => {
81
+ renderPanel('karaoke', { color: '#abcdef', highlightColor: '#00ff00' })
82
+ expect((screen.getByLabelText('Caption text color') as HTMLInputElement).value).toBe('#abcdef')
83
+ expect((screen.getByLabelText('Caption highlight color') as HTMLInputElement).value).toBe('#00ff00')
84
+ })
85
+ })