@bycrux/editor 0.9.0 → 0.11.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. package/src/video/timeline/makeCaptionEdit.ts +33 -10
@@ -0,0 +1,292 @@
1
+ import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { cn, inspectorInputClass, SwatchInput } from '../../ui'
4
+ import { inferOverlayPropFields, type PropField } from './overlay-prop-fields'
5
+
6
+ // Remember the dialog's dragged position for the browser session, so it re-opens
7
+ // where the operator left it instead of always at the default corner. Scoped to
8
+ // sessionStorage (per-tab, survives reloads, clears when the tab closes) and
9
+ // guarded so a non-browser/blocked-storage host degrades to the default.
10
+ const POS_KEY = 'montaj.overlayPropsModal.pos'
11
+
12
+ function loadSavedPos(): { x: number; y: number } | null {
13
+ try {
14
+ const raw = sessionStorage.getItem(POS_KEY)
15
+ if (!raw) return null
16
+ const p = JSON.parse(raw) as { x?: unknown; y?: unknown }
17
+ if (typeof p.x !== 'number' || typeof p.y !== 'number') return null
18
+ // Clamp into the current viewport so a saved spot never lands off-screen
19
+ // (e.g. after the window shrank), keeping the header grabbable.
20
+ return {
21
+ x: Math.min(Math.max(0, p.x), window.innerWidth - 120),
22
+ y: Math.min(Math.max(0, p.y), window.innerHeight - 60),
23
+ }
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ function saveSavedPos(p: { x: number; y: number }) {
30
+ try {
31
+ sessionStorage.setItem(POS_KEY, JSON.stringify(p))
32
+ } catch {
33
+ /* storage blocked/unavailable — position just won't persist */
34
+ }
35
+ }
36
+
37
+ interface Props {
38
+ itemProps: Record<string, unknown>
39
+ onSave: (next: Record<string, unknown>) => void
40
+ onClose: () => void
41
+ /** Live-preview every in-progress edit (no history/save) so the overlay
42
+ * updates as the operator tweaks. Commit happens on Save; Cancel reverts. */
43
+ onPreview?: (next: Record<string, unknown>) => void
44
+ /** Resolve a workspace path to a servable URL (for image thumbnails). */
45
+ fileUrl?: (path: string) => string
46
+ /** Upload a picked file, returning its new workspace path (for image "change"). */
47
+ uploadFile?: (file: File) => Promise<string>
48
+ }
49
+
50
+ // Image prop control: a thumbnail preview plus a file picker that uploads the
51
+ // chosen file (via the host adapter) and swaps the prop to the returned path.
52
+ // When no uploadFile is available (non-Montaj host), it degrades to an editable
53
+ // path text field so the value is still reachable.
54
+ function ImageField({
55
+ name,
56
+ value,
57
+ fileUrl,
58
+ uploadFile,
59
+ onChange,
60
+ }: {
61
+ name: string
62
+ value: string
63
+ fileUrl?: (path: string) => string
64
+ uploadFile?: (file: File) => Promise<string>
65
+ onChange: (v: string) => void
66
+ }) {
67
+ const [busy, setBusy] = useState(false)
68
+ const [err, setErr] = useState<string | null>(null)
69
+ const preview = value ? (fileUrl ? fileUrl(value) : value) : ''
70
+
71
+ async function onPick(e: ChangeEvent<HTMLInputElement>) {
72
+ const file = e.target.files?.[0]
73
+ e.target.value = '' // let the same file be re-picked later
74
+ if (!file || !uploadFile) return
75
+ setBusy(true)
76
+ setErr(null)
77
+ try {
78
+ onChange(await uploadFile(file))
79
+ } catch (x) {
80
+ setErr(String(x))
81
+ } finally {
82
+ setBusy(false)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="flex items-center gap-3">
88
+ {preview ? (
89
+ <img
90
+ src={preview}
91
+ alt={name}
92
+ className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border,#1f2937)] object-cover bg-black/20"
93
+ />
94
+ ) : (
95
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border,#1f2937)] text-[10px] text-[var(--editor-text)]/40">
96
+ none
97
+ </div>
98
+ )}
99
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
100
+ <span className="truncate font-mono text-xs text-[var(--editor-text)]/70" title={value}>
101
+ {value ? value.split('/').pop() : '—'}
102
+ </span>
103
+ {uploadFile ? (
104
+ <label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border,#1f2937)] px-2.5 py-1 text-xs text-[var(--editor-text)]/80 hover:bg-white/5">
105
+ {busy ? 'Uploading…' : 'Change…'}
106
+ <input
107
+ type="file"
108
+ accept="image/*"
109
+ aria-label={name}
110
+ className="hidden"
111
+ onChange={onPick}
112
+ disabled={busy}
113
+ />
114
+ </label>
115
+ ) : (
116
+ <input
117
+ type="text"
118
+ aria-label={name}
119
+ value={value}
120
+ onChange={e => onChange(e.target.value)}
121
+ className={cn(inspectorInputClass, 'font-mono')}
122
+ />
123
+ )}
124
+ {err && <span className="text-[11px] text-red-400">{err}</span>}
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ export default function OverlayPropsModal({ itemProps, onSave, onClose, onPreview, fileUrl, uploadFile }: Props) {
131
+ const [draft, setDraft] = useState<PropField[]>(() => inferOverlayPropFields(itemProps))
132
+
133
+ // Floating, draggable panel (no dimming backdrop) so the overlay stays visible
134
+ // and updates live while editing. `pos` is null until first drag, then a
135
+ // fixed viewport position; the default anchors it top-right, off the centered
136
+ // preview.
137
+ const panelRef = useRef<HTMLDivElement>(null)
138
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadSavedPos())
139
+ const [dragging, setDragging] = useState(false)
140
+ const grabRef = useRef({ dx: 0, dy: 0 })
141
+ const posRef = useRef<{ x: number; y: number } | null>(pos)
142
+
143
+ useEffect(() => {
144
+ const onKey = (e: KeyboardEvent) => {
145
+ if (e.key === 'Escape') onClose()
146
+ }
147
+ document.addEventListener('keydown', onKey)
148
+ return () => document.removeEventListener('keydown', onKey)
149
+ }, [onClose])
150
+
151
+ useEffect(() => {
152
+ if (!dragging) return
153
+ function onMove(e: MouseEvent) {
154
+ const np = { x: e.clientX - grabRef.current.dx, y: e.clientY - grabRef.current.dy }
155
+ posRef.current = np
156
+ setPos(np)
157
+ }
158
+ function onUp() {
159
+ setDragging(false)
160
+ if (posRef.current) saveSavedPos(posRef.current) // persist once, on drop
161
+ }
162
+ window.addEventListener('mousemove', onMove)
163
+ window.addEventListener('mouseup', onUp)
164
+ return () => {
165
+ window.removeEventListener('mousemove', onMove)
166
+ window.removeEventListener('mouseup', onUp)
167
+ }
168
+ }, [dragging])
169
+
170
+ function startDrag(e: ReactMouseEvent) {
171
+ const rect = panelRef.current?.getBoundingClientRect()
172
+ if (!rect) return
173
+ grabRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }
174
+ setPos({ x: rect.left, y: rect.top })
175
+ setDragging(true)
176
+ }
177
+
178
+ function mergeProps(fields: PropField[]): Record<string, unknown> {
179
+ const next: Record<string, unknown> = { ...itemProps }
180
+ for (const f of fields) next[f.name] = f.value
181
+ return next
182
+ }
183
+
184
+ // Update one field and immediately preview the merged props (live) so the
185
+ // overlay reflects the edit without waiting for Save.
186
+ function setField(name: string, value: string | number | boolean) {
187
+ const nd = draft.map(f => (f.name === name ? { ...f, value } : f))
188
+ setDraft(nd)
189
+ onPreview?.(mergeProps(nd))
190
+ }
191
+
192
+ const panelStyle: CSSProperties = {
193
+ position: 'fixed',
194
+ ...(pos ? { left: pos.x, top: pos.y } : { top: 16, right: 16 }),
195
+ // The panel portals to <body>, outside the VideoEditor element that carries
196
+ // the --editor-* theme vars, so those are undefined here — give opaque
197
+ // fallbacks so the panel is solid, not see-through.
198
+ background: 'var(--editor-surface, #111827)',
199
+ borderColor: 'var(--editor-border, #1f2937)',
200
+ color: 'var(--editor-text, #f3f4f6)',
201
+ }
202
+
203
+ return createPortal(
204
+ <div
205
+ ref={panelRef}
206
+ className="z-50 w-96 max-h-[85vh] border rounded-xl shadow-2xl flex flex-col overflow-hidden"
207
+ style={panelStyle}
208
+ >
209
+ {/* Header — drag handle */}
210
+ <div
211
+ onMouseDown={startDrag}
212
+ className="flex cursor-move select-none items-center justify-between px-5 py-4 border-b border-[var(--editor-border,#1f2937)]"
213
+ >
214
+ <h2 className="text-sm font-semibold">Edit overlay</h2>
215
+ <button
216
+ onMouseDown={e => e.stopPropagation()}
217
+ onClick={onClose}
218
+ aria-label="Close"
219
+ className="opacity-55 hover:opacity-100 transition-opacity text-lg leading-none"
220
+ >
221
+ ×
222
+ </button>
223
+ </div>
224
+
225
+ {/* Fields */}
226
+ <div className="flex flex-col gap-3 px-5 py-4 overflow-y-auto">
227
+ {draft.map(f => (
228
+ <div key={f.name} className="flex flex-col gap-1">
229
+ <span className="text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55">
230
+ {f.name}
231
+ </span>
232
+ {f.kind === 'boolean' ? (
233
+ <input
234
+ type="checkbox"
235
+ aria-label={f.name}
236
+ checked={f.value as boolean}
237
+ onChange={e => setField(f.name, e.target.checked)}
238
+ className="h-4 w-4 accent-[var(--editor-accent)]"
239
+ />
240
+ ) : f.kind === 'number' ? (
241
+ <input
242
+ type="number"
243
+ aria-label={f.name}
244
+ value={f.value as number}
245
+ onChange={e => setField(f.name, e.target.value === '' ? 0 : Number(e.target.value))}
246
+ className={cn(inspectorInputClass, 'text-right')}
247
+ />
248
+ ) : f.kind === 'color' ? (
249
+ <SwatchInput value={String(f.value)} ariaLabel={f.name} onChange={v => setField(f.name, v)} />
250
+ ) : f.kind === 'image' ? (
251
+ <ImageField
252
+ name={f.name}
253
+ value={String(f.value)}
254
+ fileUrl={fileUrl}
255
+ uploadFile={uploadFile}
256
+ onChange={v => setField(f.name, v)}
257
+ />
258
+ ) : (
259
+ <textarea
260
+ aria-label={f.name}
261
+ value={f.value as string}
262
+ rows={1}
263
+ onChange={e => setField(f.name, e.target.value)}
264
+ className={cn(inspectorInputClass, 'h-auto resize-y')}
265
+ />
266
+ )}
267
+ </div>
268
+ ))}
269
+ {draft.length === 0 && (
270
+ <p className="text-sm text-[var(--editor-text)]/55">This overlay has no editable props.</p>
271
+ )}
272
+ </div>
273
+
274
+ {/* Footer */}
275
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border,#1f2937)]">
276
+ <button
277
+ onClick={onClose}
278
+ className="text-sm px-4 py-1.5 rounded-md border border-[var(--editor-border,#1f2937)] opacity-80 hover:opacity-100 hover:bg-white/5 transition-colors"
279
+ >
280
+ Cancel
281
+ </button>
282
+ <button
283
+ onClick={() => onSave(mergeProps(draft))}
284
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent,#6366f1)] text-[var(--editor-accent-foreground,#ffffff)] hover:opacity-90 transition-colors"
285
+ >
286
+ Save
287
+ </button>
288
+ </div>
289
+ </div>,
290
+ document.body,
291
+ )
292
+ }
@@ -5,8 +5,11 @@ import type { OverlayFactory } from '../../types'
5
5
  import CaptionPreview from './CaptionPreview'
6
6
  import { getOverlayDesignCanvas } from '../design-canvas'
7
7
  import { useDragOverlay } from './useDragOverlay'
8
+ import type { OverlayChanges } from './useDragOverlay'
9
+ import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
8
10
  import OverlayItemsLayer from './OverlayItemsLayer'
9
11
  import { useVideoPlayback } from './useVideoPlayback'
12
+ import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
10
13
  import { sourceCropVideoStyle } from './sourceCropStyle'
11
14
  import CarouselPreview from './CarouselPreview'
12
15
 
@@ -14,32 +17,46 @@ import CarouselPreview from './CarouselPreview'
14
17
 
15
18
  interface PreviewPlayerProps {
16
19
  project: Project
17
- currentTime: number
18
- onTimeUpdate: (t: number) => void
20
+ clock: PlaybackClock
19
21
  selectedOverlayId?: string
20
- onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
22
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void
23
+ onEditOverlay?: (id: string) => void
21
24
  // Adapter-injected capabilities
22
25
  compileOverlay: (src: string) => Promise<OverlayFactory>
23
26
  clearOverlayCache?: (src?: string) => void
24
27
  watchFile?: (path: string, onChange: () => void) => () => void
25
28
  fileUrl: (path: string) => string
26
29
  resolveCaptionTemplate?: (style: string) => string
30
+ // Caption selection/positioning — pass-through to CaptionPreview. All optional:
31
+ // a host that omits them (PendingSurface) gets the historical passive,
32
+ // click-through caption layer.
33
+ selectedCaptionId?: string
34
+ onSelectCaption?: (id: string | null) => void
35
+ onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
27
36
  }
28
37
 
29
38
  export default function PreviewPlayer({
30
39
  project,
31
- currentTime,
32
- onTimeUpdate,
40
+ clock,
33
41
  selectedOverlayId,
34
42
  onOverlayChange,
43
+ onEditOverlay,
35
44
  compileOverlay,
36
45
  clearOverlayCache,
37
46
  watchFile,
38
47
  fileUrl,
39
48
  resolveCaptionTemplate,
49
+ selectedCaptionId,
50
+ onSelectCaption,
51
+ onCaptionSegmentChange,
40
52
  }: PreviewPlayerProps) {
41
53
  if (project.projectType === 'carousel') return <CarouselPreview project={project} />
42
54
 
55
+ // Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
56
+ // tick — activeClip/cropStyle memos and the video/overlay/caption children all
57
+ // depend on the current time.
58
+ const currentTime = usePlaybackTime(clock)
59
+
43
60
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
44
61
 
45
62
  const containerRef = useRef<HTMLDivElement>(null)
@@ -86,7 +103,7 @@ export default function PreviewPlayer({
86
103
  clips,
87
104
  tracks0NonVideo,
88
105
  overlayTracks,
89
- } = useVideoPlayback(project, currentTime, onTimeUpdate, fileUrl)
106
+ } = useVideoPlayback(project, currentTime, clock.set, fileUrl)
90
107
 
91
108
  const captionTrack = useMemo(() => project.captions, [project])
92
109
 
@@ -302,6 +319,7 @@ export default function PreviewPlayer({
302
319
  renderScale={renderScale}
303
320
  selectedOverlayId={selectedOverlayId}
304
321
  onOverlayChange={onOverlayChange}
322
+ onEditOverlay={onEditOverlay}
305
323
  containerRef={containerRef}
306
324
  dragState={dragState}
307
325
  setDragState={setDragState}
@@ -326,6 +344,9 @@ export default function PreviewPlayer({
326
344
  fps={project.settings?.fps ?? 30}
327
345
  compileOverlay={compileOverlay}
328
346
  resolveCaptionTemplate={resolveCaptionTemplate}
347
+ selectedCaptionId={selectedCaptionId}
348
+ onSelectCaption={onSelectCaption}
349
+ onCaptionSegmentChange={onCaptionSegmentChange}
329
350
  />
330
351
  )}
331
352
  </div>
@@ -0,0 +1,106 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import type { EditorProject, VisualItem } from '../../../schema'
4
+ import type { OverlayFactory } from '../../../types'
5
+ import OverlayItemsLayer from '../OverlayItemsLayer'
6
+
7
+ // Mounts OverlayItemsLayer in isolation with the minimal prop surface it needs.
8
+ // Mirrors the fake-adapter capability shims from VideoEditor.test.tsx
9
+ // (compileOverlay resolves to a no-op factory), driving the preview layer
10
+ // directly so the double-click → onEditOverlay routing is exercisable. The
11
+ // props dialog itself now lives in VideoEditor, so the layer only reports the
12
+ // intent via onEditOverlay.
13
+ const emptySnap = { x: false, y: false, left: false, right: false, top: false, bottom: false }
14
+
15
+ function makeProject(): EditorProject {
16
+ return {
17
+ id: 'p',
18
+ status: 'draft',
19
+ settings: { resolution: [1080, 1920], fps: 30 },
20
+ tracks: [[]],
21
+ } as unknown as EditorProject
22
+ }
23
+
24
+ function renderLayer(item: VisualItem, opts: { selected?: boolean } = {}) {
25
+ const onEditOverlay = vi.fn()
26
+ const onOverlayChange = vi.fn()
27
+ const containerRef = { current: document.createElement('div') }
28
+ const utils = render(
29
+ <OverlayItemsLayer
30
+ project={makeProject()}
31
+ currentTime={1}
32
+ isPlaying={false}
33
+ isCanvasProject={false}
34
+ overlayTracks={[[item]]}
35
+ tracks0NonVideo={[]}
36
+ renderScale={0.2}
37
+ selectedOverlayId={opts.selected === false ? undefined : item.id}
38
+ onOverlayChange={onOverlayChange}
39
+ onEditOverlay={onEditOverlay}
40
+ containerRef={containerRef}
41
+ dragState={null}
42
+ setDragState={vi.fn()}
43
+ liveOffset={null}
44
+ liveScale={null}
45
+ liveRotation={null}
46
+ snapGuides={emptySnap}
47
+ snapRotation={null}
48
+ compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
49
+ fileUrl={(pth: string) => pth}
50
+ />,
51
+ )
52
+ return { ...utils, onEditOverlay, onOverlayChange }
53
+ }
54
+
55
+ // The overlay's async compileOverlay resolves after each test's assertions,
56
+ // flushing a CustomOverlay state update outside act(). Harmless fake-compiler
57
+ // artifact; silence the console noise the way VideoEditor.test does.
58
+ beforeEach(() => {
59
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
60
+ vi.spyOn(console, 'error').mockImplementation(() => {})
61
+ })
62
+ afterEach(() => vi.restoreAllMocks())
63
+
64
+ const overlayItem = (over: Partial<VisualItem> = {}): VisualItem => ({
65
+ id: 'overlay-1',
66
+ type: 'overlay',
67
+ src: 'o.jsx',
68
+ start: 0,
69
+ end: 10,
70
+ props: { homeName: 'x' },
71
+ ...over,
72
+ } as VisualItem)
73
+
74
+ describe('OverlayItemsLayer — double-click to edit overlay', () => {
75
+ it('double-clicking a selected JSX overlay requests the props dialog', () => {
76
+ const { container, onEditOverlay } = renderLayer(overlayItem())
77
+
78
+ // The overlay wrapper (outermost div) carries the onDoubleClick handler.
79
+ const wrapper = container.querySelector('div') as HTMLDivElement
80
+ fireEvent.dblClick(wrapper)
81
+
82
+ expect(onEditOverlay).toHaveBeenCalledWith('overlay-1')
83
+ })
84
+
85
+ it('does not request editing when the overlay is not selected', () => {
86
+ const { container, onEditOverlay } = renderLayer(overlayItem(), { selected: false })
87
+
88
+ const wrapper = container.querySelector('div') as HTMLDivElement
89
+ fireEvent.dblClick(wrapper)
90
+
91
+ expect(onEditOverlay).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('no longer renders an inline text editor or an in-layer pencil / modal', () => {
95
+ const { container } = renderLayer(overlayItem({ props: { text: 'Hi' } }))
96
+
97
+ const wrapper = container.querySelector('div') as HTMLDivElement
98
+ fireEvent.dblClick(wrapper)
99
+
100
+ // The dialog is owned by VideoEditor now: the layer neither inline-edits nor
101
+ // renders the modal or a floating pencil itself.
102
+ expect(screen.queryByRole('textbox')).toBeNull()
103
+ expect(screen.queryByRole('heading', { name: 'Edit overlay' })).toBeNull()
104
+ expect(screen.queryByRole('button', { name: 'Edit overlay props' })).toBeNull()
105
+ })
106
+ })
@@ -0,0 +1,32 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import OverlayPropsModal from '../OverlayPropsModal'
4
+
5
+ const props = { homeName: 'Colombia', homeScore: 2, accent: '#FCD116', players: [{ n: 1 }] }
6
+
7
+ test('renders a field per primitive prop and commits merged props', () => {
8
+ const onSave = vi.fn()
9
+ render(<OverlayPropsModal itemProps={props} onSave={onSave} onClose={() => {}} />)
10
+
11
+ const name = screen.getByLabelText('homeName')
12
+ fireEvent.change(name, { target: { value: 'Argentina' } })
13
+ const score = screen.getByLabelText('homeScore')
14
+ fireEvent.change(score, { target: { value: '3' } })
15
+ fireEvent.click(screen.getByRole('button', { name: /save/i }))
16
+
17
+ expect(onSave).toHaveBeenCalledWith({
18
+ homeName: 'Argentina',
19
+ homeScore: 3, // number kind round-trips as number
20
+ accent: '#FCD116',
21
+ players: [{ n: 1 }], // non-primitive prop preserved untouched
22
+ })
23
+ })
24
+
25
+ test('escape closes without saving', () => {
26
+ const onSave = vi.fn()
27
+ const onClose = vi.fn()
28
+ render(<OverlayPropsModal itemProps={props} onSave={onSave} onClose={onClose} />)
29
+ fireEvent.keyDown(document, { key: 'Escape' })
30
+ expect(onClose).toHaveBeenCalled()
31
+ expect(onSave).not.toHaveBeenCalled()
32
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ CAPTION_DRAG_SLOP_PX,
4
+ CAPTION_MAX_SCALE,
5
+ CAPTION_MIN_SCALE,
6
+ captionDragGeometry,
7
+ captionDragPatch,
8
+ hasEscapedClickSlop,
9
+ readCaptionGeometry,
10
+ screenDeltaToFramePercent,
11
+ type CaptionDragState,
12
+ type CaptionFrameMetrics,
13
+ } from '../captionDragState'
14
+
15
+ const RENDER_W = 1080
16
+ const RENDER_H = 1920
17
+
18
+ /** Preview scale for a player rendered `px` wide (the ResizeObserver value). */
19
+ function metrics(onScreenWidth: number): CaptionFrameMetrics {
20
+ return { previewScale: onScreenWidth / RENDER_W, renderW: RENDER_W, renderH: RENDER_H }
21
+ }
22
+
23
+ function drag(over: Partial<CaptionDragState> = {}): CaptionDragState {
24
+ return {
25
+ id: 'cap-0', type: 'move',
26
+ initX: 100, initY: 200,
27
+ initOffsetX: 0, initOffsetY: 0, initScale: 1,
28
+ ...over,
29
+ }
30
+ }
31
+
32
+ describe('readCaptionGeometry', () => {
33
+ it('applies the schema defaults', () => {
34
+ expect(readCaptionGeometry(undefined)).toEqual({ offsetX: 0, offsetY: 0, scale: 1 })
35
+ expect(readCaptionGeometry({})).toEqual({ offsetX: 0, offsetY: 0, scale: 1 })
36
+ })
37
+
38
+ it('preserves explicit zeros rather than falling back to the default', () => {
39
+ expect(readCaptionGeometry({ offsetX: 0, offsetY: -12, scale: 0.5 }))
40
+ .toEqual({ offsetX: 0, offsetY: -12, scale: 0.5 })
41
+ })
42
+ })
43
+
44
+ describe('screenDeltaToFramePercent', () => {
45
+ it('divides by the preview scale then converts to percent of frame', () => {
46
+ // Half-size preview: 54 screen px = 108 design px = 10% of 1080.
47
+ const { dx, dy } = screenDeltaToFramePercent(54, 96, metrics(540))
48
+ expect(dx).toBeCloseTo(10, 10)
49
+ expect(dy).toBeCloseTo(10, 10)
50
+ })
51
+
52
+ it('is independent of window size — the same fraction of the player is the same percent', () => {
53
+ // A drag of 25% of the player width must read 25% at any preview size.
54
+ for (const w of [270, 540, 1080, 1600]) {
55
+ const { dx } = screenDeltaToFramePercent(w * 0.25, 0, metrics(w))
56
+ expect(dx).toBeCloseTo(25, 10)
57
+ }
58
+ })
59
+
60
+ it('uses the frame HEIGHT for the vertical axis (non-square frames)', () => {
61
+ // 192 screen px at 1:1 = 192 design px = 10% of 1920, not 17.8% of 1080.
62
+ const { dy } = screenDeltaToFramePercent(0, 192, metrics(RENDER_W))
63
+ expect(dy).toBeCloseTo(10, 10)
64
+ })
65
+
66
+ it('returns a zero delta for degenerate metrics instead of NaN/Infinity', () => {
67
+ expect(screenDeltaToFramePercent(50, 50, { previewScale: 0, renderW: RENDER_W, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
68
+ expect(screenDeltaToFramePercent(50, 50, { previewScale: 1, renderW: 0, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
69
+ expect(screenDeltaToFramePercent(50, 50, { previewScale: NaN, renderW: RENDER_W, renderH: RENDER_H })).toEqual({ dx: 0, dy: 0 })
70
+ })
71
+ })
72
+
73
+ describe('captionDragGeometry — move', () => {
74
+ it('adds the total delta to the offset captured at drag start', () => {
75
+ const d = drag({ initOffsetX: 5, initOffsetY: -10 })
76
+ // 270 screen px right at half scale = 540 design px = 50% of 1080.
77
+ const g = captionDragGeometry(d, d.initX + 270, d.initY + 192, metrics(540))
78
+ expect(g.offsetX).toBeCloseTo(5 + 50, 10)
79
+ expect(g.offsetY).toBeCloseTo(-10 + 20, 10)
80
+ expect(g.scale).toBe(1)
81
+ })
82
+
83
+ it('accumulates from the gesture start, not from the previous sample (no drift)', () => {
84
+ const d = drag({ initOffsetX: 5 })
85
+ const m = metrics(540)
86
+ // Sampling the same pointer position twice, and sampling it after a detour,
87
+ // must both give the same answer — the function is a pure map from pointer
88
+ // position to geometry.
89
+ const a = captionDragGeometry(d, 400, 200, m)
90
+ captionDragGeometry(d, 999, 999, m)
91
+ const b = captionDragGeometry(d, 400, 200, m)
92
+ expect(b).toEqual(a)
93
+ })
94
+
95
+ it('is a no-op at the starting pointer position', () => {
96
+ const d = drag({ initOffsetX: 12, initOffsetY: 34, initScale: 1.5 })
97
+ expect(captionDragGeometry(d, d.initX, d.initY, metrics(540)))
98
+ .toEqual({ offsetX: 12, offsetY: 34, scale: 1.5 })
99
+ })
100
+ })
101
+
102
+ describe('captionDragGeometry — corner resize', () => {
103
+ const m = metrics(RENDER_W) // 1:1, so screen px == design px
104
+
105
+ it('grows when dragging a corner outward and shrinks when dragging inward', () => {
106
+ const se = drag({ type: 'resize-se' })
107
+ expect(captionDragGeometry(se, se.initX + 108, se.initY, m).scale).toBeCloseTo(1.1, 10)
108
+ expect(captionDragGeometry(se, se.initX - 108, se.initY, m).scale).toBeCloseTo(0.9, 10)
109
+
110
+ // The NW corner's outward direction is up-left, so the signs invert.
111
+ const nw = drag({ type: 'resize-nw' })
112
+ expect(captionDragGeometry(nw, nw.initX - 108, nw.initY, m).scale).toBeCloseTo(1.1, 10)
113
+ expect(captionDragGeometry(nw, nw.initX + 108, nw.initY, m).scale).toBeCloseTo(0.9, 10)
114
+ })
115
+
116
+ it('sums both axes and scales proportionally from the starting scale', () => {
117
+ const d = drag({ type: 'resize-se', initScale: 2 })
118
+ // +10% of width and +10% of height → delta 0.2 → 2 * 1.2.
119
+ const g = captionDragGeometry(d, d.initX + 108, d.initY + 192, m)
120
+ expect(g.scale).toBeCloseTo(2.4, 10)
121
+ })
122
+
123
+ it('leaves the offsets untouched', () => {
124
+ const d = drag({ type: 'resize-ne', initOffsetX: 7, initOffsetY: -3 })
125
+ const g = captionDragGeometry(d, d.initX + 500, d.initY - 500, m)
126
+ expect(g.offsetX).toBe(7)
127
+ expect(g.offsetY).toBe(-3)
128
+ })
129
+
130
+ it('clamps to the scale bounds', () => {
131
+ const shrink = drag({ type: 'resize-se', initScale: 1 })
132
+ expect(captionDragGeometry(shrink, shrink.initX - 100_000, shrink.initY, m).scale).toBe(CAPTION_MIN_SCALE)
133
+ const grow = drag({ type: 'resize-se', initScale: 1 })
134
+ expect(captionDragGeometry(grow, grow.initX + 100_000, grow.initY, m).scale).toBe(CAPTION_MAX_SCALE)
135
+ })
136
+ })
137
+
138
+ describe('hasEscapedClickSlop', () => {
139
+ it('treats a small wobble as a click, not a drag', () => {
140
+ const d = drag()
141
+ expect(hasEscapedClickSlop(d, d.initX, d.initY)).toBe(false)
142
+ expect(hasEscapedClickSlop(d, d.initX + 2, d.initY + 2)).toBe(false) // 2.83px
143
+ expect(hasEscapedClickSlop(d, d.initX + 3, d.initY + 3)).toBe(true) // 4.24px
144
+ })
145
+
146
+ it('measures radially, not per axis', () => {
147
+ const d = drag()
148
+ expect(hasEscapedClickSlop(d, d.initX, d.initY + CAPTION_DRAG_SLOP_PX + 1)).toBe(true)
149
+ expect(hasEscapedClickSlop(d, d.initX - CAPTION_DRAG_SLOP_PX - 1, d.initY)).toBe(true)
150
+ })
151
+ })
152
+
153
+ describe('captionDragPatch', () => {
154
+ it('a move writes only the offsets', () => {
155
+ expect(captionDragPatch(drag(), { offsetX: 3, offsetY: 4, scale: 2 }))
156
+ .toEqual({ offsetX: 3, offsetY: 4 })
157
+ })
158
+
159
+ it('a resize writes only the scale', () => {
160
+ expect(captionDragPatch(drag({ type: 'resize-sw' }), { offsetX: 3, offsetY: 4, scale: 2 }))
161
+ .toEqual({ scale: 2 })
162
+ })
163
+ })