@bycrux/editor 0.8.9 → 0.10.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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/carousel/CarouselEditor.tsx +34 -14
  3. package/src/carousel/SlidePropertyPanel.tsx +1 -35
  4. package/src/index.ts +4 -0
  5. package/src/schema.ts +11 -3
  6. package/src/ui/SwatchInput.tsx +57 -0
  7. package/src/ui/index.ts +2 -0
  8. package/src/video/VideoEditor.tsx +107 -14
  9. package/src/video/__tests__/VideoEditor.test.tsx +34 -0
  10. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  11. package/src/video/playback-clock.ts +31 -0
  12. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  13. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  14. package/src/video/preview/PreviewPlayer.tsx +14 -6
  15. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  16. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  17. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  18. package/src/video/preview/overlay-prop-fields.ts +39 -0
  19. package/src/video/preview/useDragOverlay.ts +21 -1
  20. package/src/video/preview/useVideoPlayback.ts +12 -3
  21. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  22. package/src/video/timeline/PlayheadLine.tsx +18 -0
  23. package/src/video/timeline/Scrubber.tsx +7 -5
  24. package/src/video/timeline/Timeline.tsx +64 -29
  25. package/src/video/timeline/TimelineContext.ts +2 -2
  26. package/src/video/timeline/TranscriptPanel.tsx +57 -0
  27. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  28. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  29. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
@@ -1,10 +1,10 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { EditorProject as Project, VisualItem } from '../../schema'
3
3
  import type { OverlayFactory } from '../../types'
4
4
  import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
5
5
  import { getOverlayDesignCanvas } from '../design-canvas'
6
6
  import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
7
- import type { Corner } from './useDragOverlay'
7
+ import type { Corner, OverlayChanges } from './useDragOverlay'
8
8
  import type { useDragOverlay } from './useDragOverlay'
9
9
 
10
10
  const VIDEO_PRELOAD_S = 0.4 // mount this many seconds before item.start so the frame is ready
@@ -171,6 +171,14 @@ function CustomOverlay({
171
171
  return () => unwatch()
172
172
  }, [src, watchFile, compile])
173
173
 
174
+ // Deep-clone/rewrite the props once per props change instead of every frame.
175
+ // Live prop edits (OverlayPropsModal → VideoEditor.withItemProps) always
176
+ // produce a new `props` object reference, so this recomputes on every edit.
177
+ const resolvedProps = useMemo(
178
+ () => resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>,
179
+ [props, fileUrl],
180
+ )
181
+
174
182
  if (error) {
175
183
  return (
176
184
  <div className="absolute bottom-4 left-4 right-4 pointer-events-none">
@@ -183,8 +191,6 @@ function CustomOverlay({
183
191
 
184
192
  if (!factory) return null
185
193
 
186
- const resolvedProps = resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>
187
-
188
194
  const element = factory(frame, fps, durationFrames, resolvedProps)
189
195
  if (!element) return null
190
196
 
@@ -288,7 +294,9 @@ interface OverlayItemsLayerProps {
288
294
  tracks0NonVideo: VisualItem[]
289
295
  renderScale: number
290
296
  selectedOverlayId?: string
291
- onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
297
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void
298
+ /** Open the props dialog for an overlay (owned by VideoEditor). */
299
+ onEditOverlay?: (id: string) => void
292
300
  containerRef: React.RefObject<HTMLDivElement | null>
293
301
  // from useDragOverlay
294
302
  dragState: ReturnType<typeof useDragOverlay>['dragState']
@@ -315,6 +323,7 @@ export default function OverlayItemsLayer({
315
323
  renderScale,
316
324
  selectedOverlayId,
317
325
  onOverlayChange,
326
+ onEditOverlay,
318
327
  containerRef,
319
328
  dragState,
320
329
  setDragState,
@@ -330,6 +339,9 @@ export default function OverlayItemsLayer({
330
339
  }: OverlayItemsLayerProps) {
331
340
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
332
341
 
342
+ // Interactive tracks — in canvas mode this includes track 0; otherwise overlays only.
343
+ const interactiveTracks = isCanvasProject ? project.tracks ?? [] : overlayTracks
344
+
333
345
  return (
334
346
  <>
335
347
  {/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
@@ -395,7 +407,7 @@ export default function OverlayItemsLayer({
395
407
  })}
396
408
 
397
409
  {/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
398
- {(isCanvasProject ? project.tracks ?? [] : overlayTracks).map((trackItems, trackIdx) =>
410
+ {interactiveTracks.map((trackItems, trackIdx) =>
399
411
  trackItems.map((item) => {
400
412
  const visible = currentTime >= item.start && currentTime < item.end
401
413
  // Pre-mount video items slightly before their start so the frame is ready (no flash)
@@ -433,6 +445,14 @@ export default function OverlayItemsLayer({
433
445
  setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
434
446
  }
435
447
 
448
+ // Double-click a selected JSX overlay opens the props dialog (owned by
449
+ // VideoEditor). stopPropagation keeps it from re-triggering startMove.
450
+ function handleDoubleClick(e: React.MouseEvent) {
451
+ if (!isSel) return
452
+ e.stopPropagation()
453
+ if (item.type === 'overlay' && item.src) onEditOverlay?.(item.id)
454
+ }
455
+
436
456
  // zIndex: canvas mode track 0 sits just above the play-toggle div (10), others stack above
437
457
  const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
438
458
 
@@ -501,14 +521,16 @@ export default function OverlayItemsLayer({
501
521
  const frame = Math.round((currentTime - item.start) * fps)
502
522
  const durationFrames = Math.round((item.end - item.start) * fps)
503
523
  return (
504
- <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
524
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove} onDoubleClick={handleDoubleClick}>
505
525
  {/* Render at native 1080×1920 then scale down to match container */}
506
- <div style={{
507
- position: 'absolute', top: 0, left: 0,
508
- width: RENDER_W, height: RENDER_H,
509
- transform: `scale(${renderScale})`, transformOrigin: 'top left',
510
- pointerEvents: 'none',
511
- }}>
526
+ <div
527
+ style={{
528
+ position: 'absolute', top: 0, left: 0,
529
+ width: RENDER_W, height: RENDER_H,
530
+ transform: `scale(${renderScale})`, transformOrigin: 'top left',
531
+ pointerEvents: 'none',
532
+ }}
533
+ >
512
534
  <OverlayErrorBoundary
513
535
  label={item.src.split('/').pop() ?? item.src}
514
536
  watchPath={item.src}
@@ -600,6 +622,7 @@ export default function OverlayItemsLayer({
600
622
  </svg>
601
623
  </div>
602
624
  )}
625
+
603
626
  </>
604
627
  )
605
628
  }
@@ -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,10 @@ 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'
8
9
  import OverlayItemsLayer from './OverlayItemsLayer'
9
10
  import { useVideoPlayback } from './useVideoPlayback'
11
+ import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
10
12
  import { sourceCropVideoStyle } from './sourceCropStyle'
11
13
  import CarouselPreview from './CarouselPreview'
12
14
 
@@ -14,10 +16,10 @@ import CarouselPreview from './CarouselPreview'
14
16
 
15
17
  interface PreviewPlayerProps {
16
18
  project: Project
17
- currentTime: number
18
- onTimeUpdate: (t: number) => void
19
+ clock: PlaybackClock
19
20
  selectedOverlayId?: string
20
- onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
21
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void
22
+ onEditOverlay?: (id: string) => void
21
23
  // Adapter-injected capabilities
22
24
  compileOverlay: (src: string) => Promise<OverlayFactory>
23
25
  clearOverlayCache?: (src?: string) => void
@@ -28,10 +30,10 @@ interface PreviewPlayerProps {
28
30
 
29
31
  export default function PreviewPlayer({
30
32
  project,
31
- currentTime,
32
- onTimeUpdate,
33
+ clock,
33
34
  selectedOverlayId,
34
35
  onOverlayChange,
36
+ onEditOverlay,
35
37
  compileOverlay,
36
38
  clearOverlayCache,
37
39
  watchFile,
@@ -40,6 +42,11 @@ export default function PreviewPlayer({
40
42
  }: PreviewPlayerProps) {
41
43
  if (project.projectType === 'carousel') return <CarouselPreview project={project} />
42
44
 
45
+ // Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
46
+ // tick — activeClip/cropStyle memos and the video/overlay/caption children all
47
+ // depend on the current time.
48
+ const currentTime = usePlaybackTime(clock)
49
+
43
50
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
44
51
 
45
52
  const containerRef = useRef<HTMLDivElement>(null)
@@ -86,7 +93,7 @@ export default function PreviewPlayer({
86
93
  clips,
87
94
  tracks0NonVideo,
88
95
  overlayTracks,
89
- } = useVideoPlayback(project, currentTime, onTimeUpdate, fileUrl)
96
+ } = useVideoPlayback(project, currentTime, clock.set, fileUrl)
90
97
 
91
98
  const captionTrack = useMemo(() => project.captions, [project])
92
99
 
@@ -302,6 +309,7 @@ export default function PreviewPlayer({
302
309
  renderScale={renderScale}
303
310
  selectedOverlayId={selectedOverlayId}
304
311
  onOverlayChange={onOverlayChange}
312
+ onEditOverlay={onEditOverlay}
305
313
  containerRef={containerRef}
306
314
  dragState={dragState}
307
315
  setDragState={setDragState}
@@ -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,44 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { inferOverlayPropFields } from '../overlay-prop-fields'
3
+
4
+ test('infers kinds from values', () => {
5
+ const fields = inferOverlayPropFields({
6
+ homeName: 'Colombia',
7
+ homeScore: 2,
8
+ accent: '#FCD116',
9
+ muted: false,
10
+ players: [{ name: 'x' }], // non-primitive: skipped
11
+ onClick: () => {}, // non-primitive: skipped
12
+ homeSrc: '/path/to/crest.png', // image path → image kind
13
+ })
14
+ expect(fields).toEqual([
15
+ { name: 'homeName', kind: 'text', value: 'Colombia' },
16
+ { name: 'homeScore', kind: 'number', value: 2 },
17
+ { name: 'accent', kind: 'color', value: '#FCD116' },
18
+ { name: 'muted', kind: 'boolean', value: false },
19
+ { name: 'homeSrc', kind: 'image', value: '/path/to/crest.png' },
20
+ ])
21
+ })
22
+
23
+ test('detects image paths (extensions, query/hash, data URLs) vs plain text', () => {
24
+ const f = inferOverlayPropFields({
25
+ a: '/assets/logo.PNG',
26
+ b: 'https://cdn.x/y.jpg?v=2',
27
+ c: 'photo.webp#frag',
28
+ d: 'inline.svg',
29
+ e: 'data:image/png;base64,AAAA',
30
+ f: '/notes/readme.txt', // not an image
31
+ g: 'just some words', // not an image
32
+ })
33
+ expect(f.map(x => x.kind)).toEqual(['image', 'image', 'image', 'image', 'image', 'text', 'text'])
34
+ })
35
+
36
+ test('preserves insertion order and handles empty/absent props', () => {
37
+ expect(inferOverlayPropFields({})).toEqual([])
38
+ expect(inferOverlayPropFields(undefined)).toEqual([])
39
+ })
40
+
41
+ test('color detection is strict hex only', () => {
42
+ const f = inferOverlayPropFields({ a: '#ff0', b: '#FFAA00CC', c: '#xyz', d: 'red' })
43
+ expect(f.map(x => x.kind)).toEqual(['color', 'color', 'text', 'text'])
44
+ })