@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
@@ -10,28 +10,156 @@
10
10
  * The caption layer is sized at the native render resolution (1080 × 1920) and
11
11
  * scaled down to fit the player via ResizeObserver so pixel values are 1:1 with
12
12
  * the render output.
13
+ *
14
+ * Per-segment positioning
15
+ * -----------------------
16
+ * The caption layer itself stays `pointer-events-none` at all times — the
17
+ * template DOM is arbitrary per style AND changes per frame (word-by-word swaps
18
+ * its content every few frames), so it is not something that can be hit-tested
19
+ * reliably. Instead the rendered caption's bounding box is *measured* and a
20
+ * separate interactive selection box is drawn over it (z 50, the same layer the
21
+ * overlay handles use). That box is the only interactive element: clicking it
22
+ * selects the segment active at the playhead, dragging it moves the segment
23
+ * (offsetX/offsetY), and dragging a corner scales it (scale).
13
24
  */
14
25
 
15
- import { useEffect, useRef, useState } from 'react'
26
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
16
27
  import type { Captions } from '../../schema'
17
28
  import type { OverlayFactory } from '../../types'
18
29
  import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
30
+ import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
31
+ import {
32
+ captionDragGeometry,
33
+ captionDragPatch,
34
+ hasEscapedClickSlop,
35
+ readCaptionGeometry,
36
+ type CaptionDragState,
37
+ type CaptionGeometry,
38
+ } from './captionDragState'
19
39
 
20
40
  const RENDER_W = 1080
21
41
  const RENDER_H = 1920
22
42
 
43
+ // Screen-px padding added around the measured text so the selection box is
44
+ // comfortably grabbable and its outline clears the templates' glyph shadows.
45
+ const BOX_PAD = 6
46
+
47
+ // Corner handles, as [corner, x fraction, y fraction] of the selection box.
48
+ const CORNERS = [['nw', 0, 0], ['ne', 1, 0], ['sw', 0, 1], ['se', 1, 1]] as const
49
+
50
+ // Screen px of the selection box that must stay inside the frame — see the
51
+ // clamp in the layout effect for why a fully-offscreen box is unrecoverable.
52
+ const BOX_KEEP_VISIBLE = 24
53
+
54
+ /**
55
+ * Clamp one axis of the selection box so at least `BOX_KEEP_VISIBLE` px of it
56
+ * overlaps the frame. `pos` is the box's leading edge relative to the frame,
57
+ * `size` its extent on that axis, `extent` the frame's.
58
+ *
59
+ * A box entirely inside the frame is returned untouched: the clamp range
60
+ * [KEEP - size, extent - KEEP] contains every such position, since a fitting
61
+ * box has 0 >= KEEP - size and pos <= extent - size <= extent - KEEP.
62
+ */
63
+ export function clampVisible(pos: number, size: number, extent: number): number {
64
+ const min = BOX_KEEP_VISIBLE - size
65
+ const max = extent - BOX_KEEP_VISIBLE
66
+ // A frame smaller than the keep-visible margin inverts the range; leave the
67
+ // position alone rather than snapping it to a meaningless bound.
68
+ if (max < min) return pos
69
+ return Math.max(min, Math.min(max, pos))
70
+ }
71
+
72
+ interface Rect { left: number; top: number; width: number; height: number }
73
+
74
+ /**
75
+ * Union of the client rects of every painted text run (and replaced element)
76
+ * under `root`.
77
+ *
78
+ * Deliberately NOT `root.getBoundingClientRect()`: the template's own outermost
79
+ * element is a frame-sized `position: fixed; inset: 0` box (captionOuterStyle),
80
+ * so its rect — and the rect of any wrapper we put around it — is the entire
81
+ * preview, which would put the selection box around the whole frame. Walking to
82
+ * the text nodes is the only markup-agnostic way to find where the caption
83
+ * actually paints, and it costs nothing on a subtree this small.
84
+ *
85
+ * Returns null when nothing is painted (e.g. `pop` renders no word between two
86
+ * word windows), which is the signal to hide the selection box entirely.
87
+ */
88
+ export function measureCaptionContentRect(root: HTMLElement): Rect | null {
89
+ const doc = root.ownerDocument
90
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
91
+ const range = doc.createRange()
92
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
93
+
94
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
95
+ let rect: DOMRect | null = null
96
+ if (node.nodeType === Node.TEXT_NODE) {
97
+ if (!node.nodeValue?.trim()) continue
98
+ range.selectNodeContents(node)
99
+ rect = range.getBoundingClientRect()
100
+ } else {
101
+ // Replaced elements paint without text nodes. Everything else is a
102
+ // container whose box is either the frame-sized outer wrapper or a
103
+ // full-width anchor box — including them would defeat the point.
104
+ const tag = (node as Element).tagName?.toUpperCase()
105
+ if (tag !== 'IMG' && tag !== 'SVG' && tag !== 'VIDEO' && tag !== 'CANVAS') continue
106
+ rect = (node as Element).getBoundingClientRect()
107
+ }
108
+ if (!rect || (!rect.width && !rect.height)) continue
109
+ left = Math.min(left, rect.left)
110
+ top = Math.min(top, rect.top)
111
+ right = Math.max(right, rect.right)
112
+ bottom = Math.max(bottom, rect.bottom)
113
+ }
114
+
115
+ if (!Number.isFinite(left)) return null
116
+ return { left, top, width: right - left, height: bottom - top }
117
+ }
118
+
23
119
  interface CaptionPreviewProps {
24
120
  track: Captions
25
121
  currentTime: number
26
122
  fps: number
27
123
  compileOverlay: (src: string) => Promise<OverlayFactory>
28
124
  resolveCaptionTemplate?: (style: string) => string
125
+ /** Currently-selected caption segment id (shared with the timeline row). */
126
+ selectedCaptionId?: string
127
+ /** Click on the selection box — selects the segment active at the playhead.
128
+ * Accepts null for symmetry with the timeline row's copy of this callback
129
+ * (VideoEditor supplies one handler to both); the preview only ever passes
130
+ * an id, since it has no deselect affordance of its own. */
131
+ onSelectCaption?: (id: string | null) => void
132
+ /** Commit a finished drag/resize onto one segment. */
133
+ onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
29
134
  }
30
135
 
31
- export default function CaptionPreview({ track, currentTime, fps, compileOverlay, resolveCaptionTemplate }: CaptionPreviewProps) {
136
+ export default function CaptionPreview({
137
+ track,
138
+ currentTime,
139
+ fps,
140
+ compileOverlay,
141
+ resolveCaptionTemplate,
142
+ selectedCaptionId,
143
+ onSelectCaption,
144
+ onCaptionSegmentChange,
145
+ }: CaptionPreviewProps) {
32
146
  const wrapRef = useRef<HTMLDivElement>(null)
147
+ const contentRef = useRef<HTMLDivElement>(null)
148
+ const boxRef = useRef<HTMLDivElement>(null)
33
149
  const [scale, setScale] = useState<number | null>(null)
34
150
  const [factory, setFactory] = useState<OverlayFactory | null>(null)
151
+ const [hovered, setHovered] = useState(false)
152
+
153
+ // In-flight gesture + its live geometry. `live` is applied to the segment we
154
+ // hand the template (not to the project) so the caption moves under the
155
+ // cursor; the project is written once, on mouseup.
156
+ const [drag, setDrag] = useState<CaptionDragState | null>(null)
157
+ const [live, setLive] = useState<({ id: string } & CaptionGeometry) | null>(null)
158
+ const liveRef = useRef<typeof live>(null)
159
+ // Read through a ref so a host that passes a fresh callback identity per
160
+ // render can't re-subscribe the document listeners mid-gesture.
161
+ const changeRef = useRef(onCaptionSegmentChange)
162
+ changeRef.current = onCaptionSegmentChange
35
163
 
36
164
  // Scale the 1080×1920 render layer to fit the actual player size
37
165
  useEffect(() => {
@@ -59,6 +187,25 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
59
187
  const frame = Math.round(currentTime * fps)
60
188
  const lastSeg = track.segments[track.segments.length - 1]
61
189
 
190
+ // The segment the template is showing. Computed from `frame / fps` (not from
191
+ // currentTime) with the templates' own `>= start && < end` predicate, so the
192
+ // selection box can never address a different segment than the one on screen.
193
+ const t = fps > 0 ? frame / fps : 0
194
+ const activeSeg = track.segments.find(s => t >= s.start && t < s.end) ?? null
195
+
196
+ // Interactivity requires a stable segment id (backfilled by VideoEditor) and a
197
+ // host that wired at least one caption callback. Without both, the layer stays
198
+ // exactly as it was: a passive, click-through caption overlay.
199
+ const activeId = activeSeg?.id
200
+ const canInteract = !!activeId && !!(onSelectCaption || onCaptionSegmentChange)
201
+ const isSelected = canInteract && selectedCaptionId === activeId
202
+
203
+ // The box unmounts when the playhead leaves a segment, so a pending
204
+ // mouseleave never arrives and the hover hairline would carry over onto the
205
+ // next segment's box. Drop it whenever the target segment changes; a pointer
206
+ // that really is over the new box gets its mouseenter back on the next move.
207
+ useEffect(() => { setHovered(false) }, [activeId])
208
+
62
209
  // Theme props for the template: everything on the track except style/segments
63
210
  // (handled separately) and googleFonts (a render-time font-loading hint, not
64
211
  // a template prop). Normalize the legacy lowercase `fontsize` key to the
@@ -67,10 +214,114 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
67
214
  const themeProps: Record<string, unknown> = { ...theme }
68
215
  if (fontsize != null) themeProps.fontSize = fontsize
69
216
 
217
+ // Live drag preview: overlay the in-flight geometry onto the dragged segment
218
+ // only. The templates read offsetX/offsetY/scale straight off the segment, so
219
+ // this is all it takes for the caption to follow the cursor.
220
+ const segments = live
221
+ ? track.segments.map(s => (s.id === live.id ? { ...s, offsetX: live.offsetX, offsetY: live.offsetY, scale: live.scale } : s))
222
+ : track.segments
223
+
70
224
  const element = (factory && scale !== null)
71
- ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments, ...themeProps })
225
+ ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments, ...themeProps })
72
226
  : null
73
227
 
228
+ // ── Gesture lifecycle ──────────────────────────────────────────────────────
229
+ // Mirrors useDragOverlay's shape (document-level listeners for the life of the
230
+ // gesture, live state during, one commit on mouseup) — see captionDragState.ts
231
+ // for why the maths is duplicated rather than shared.
232
+ useEffect(() => {
233
+ const d = drag
234
+ // `scale === null` is defensive only — a gesture can start only from the
235
+ // box, which renders only when `element` exists, which requires a measured
236
+ // scale. Kept so the metrics below can never be built from a null scale.
237
+ if (!d || scale === null) return
238
+ const metrics = { previewScale: scale, renderW: RENDER_W, renderH: RENDER_H }
239
+
240
+ function onMove(e: MouseEvent) {
241
+ // Inside the click slop the press is still just a selection click, so
242
+ // clear any live geometry — that also makes "drag out and back" cancel.
243
+ const next = hasEscapedClickSlop(d!, e.clientX, e.clientY)
244
+ ? { id: d!.id, ...captionDragGeometry(d!, e.clientX, e.clientY, metrics) }
245
+ : null
246
+ setLive(next)
247
+ liveRef.current = next
248
+ }
249
+ function onUp() {
250
+ // Only commit if the pointer actually moved — a bare click selects and
251
+ // must not push an undo step or queue a save.
252
+ const l = liveRef.current
253
+ if (l) changeRef.current?.(l.id, captionDragPatch(d!, l))
254
+ setDrag(null)
255
+ setLive(null)
256
+ liveRef.current = null
257
+ }
258
+
259
+ document.addEventListener('mousemove', onMove)
260
+ document.addEventListener('mouseup', onUp)
261
+ return () => {
262
+ document.removeEventListener('mousemove', onMove)
263
+ document.removeEventListener('mouseup', onUp)
264
+ }
265
+ }, [drag, scale])
266
+
267
+ function startGesture(type: CaptionDragState['type'], e: React.MouseEvent) {
268
+ if (!activeId) return
269
+ // Keep the mousedown from starting a text selection and from reaching the
270
+ // player's play/pause layer underneath.
271
+ e.preventDefault()
272
+ e.stopPropagation()
273
+ // Select and begin the gesture in the same press: unlike overlays (selected
274
+ // from the timeline) the preview box IS the caption's selection affordance,
275
+ // so requiring a separate click first would just cost a click.
276
+ if (selectedCaptionId !== activeId) onSelectCaption?.(activeId)
277
+ if (!onCaptionSegmentChange) return
278
+ const g = readCaptionGeometry(activeSeg)
279
+ setDrag({
280
+ id: activeId, type,
281
+ initX: e.clientX, initY: e.clientY,
282
+ initOffsetX: g.offsetX, initOffsetY: g.offsetY, initScale: g.scale,
283
+ })
284
+ }
285
+
286
+ // ── Selection box placement ────────────────────────────────────────────────
287
+ // Positioned imperatively (style writes on a ref) rather than through state:
288
+ // the caption content is re-measured after every render — which for
289
+ // word-by-word means every frame of playback — and routing that through
290
+ // setState would cost an extra render per frame and risk a measure→render→
291
+ // measure loop. No dependency array on purpose: it must run after *every*
292
+ // commit, since any of them (new frame, resize, live drag) can move the text.
293
+ useLayoutEffect(() => {
294
+ const box = boxRef.current
295
+ const root = wrapRef.current
296
+ const content = contentRef.current
297
+ if (!box || !root || !content) return
298
+ const rect = measureCaptionContentRect(content)
299
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
300
+ box.style.display = 'none'
301
+ return
302
+ }
303
+ // Both rects are in client coords; the root is inset-0 of the player frame,
304
+ // so subtracting it gives offsets in the box's own positioning context.
305
+ const rootRect = root.getBoundingClientRect()
306
+ const w = rect.width + BOX_PAD * 2
307
+ const h = rect.height + BOX_PAD * 2
308
+ // Keep a grabbable sliver of the box inside the frame. offsetX/offsetY are
309
+ // unbounded and there is no numeric reset control for captions (unlike
310
+ // overlays, which have OverlayPropsModal), so a segment dragged fully past
311
+ // the frame edge would have BOTH its text and its selection box clipped
312
+ // away by this wrapper's `overflow-hidden` — leaving no way to drag it back
313
+ // short of hand-editing project.json. Dragging is delta-based (see
314
+ // captionDragState.ts), so a clamped box still moves the segment correctly.
315
+ // Captions inside the frame are never clamped, so nothing else changes.
316
+ const left = clampVisible(rect.left - rootRect.left - BOX_PAD, w, rootRect.width)
317
+ const top = clampVisible(rect.top - rootRect.top - BOX_PAD, h, rootRect.height)
318
+ box.style.display = 'block'
319
+ box.style.left = `${left}px`
320
+ box.style.top = `${top}px`
321
+ box.style.width = `${w}px`
322
+ box.style.height = `${h}px`
323
+ })
324
+
74
325
  return (
75
326
  // zIndex 45 keeps captions above the active <video> (z 1) and overlay items
76
327
  // (z `trackIdx + 12`, ≈12–20) — mirroring the final render, where the caption
@@ -81,7 +332,7 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
81
332
  <div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden" style={{ zIndex: 45 }}>
82
333
  <OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
83
334
  {element && scale !== null && (
84
- <div style={{
335
+ <div ref={contentRef} style={{
85
336
  position: 'absolute',
86
337
  top: 0, left: 0,
87
338
  width: RENDER_W,
@@ -93,6 +344,49 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
93
344
  </div>
94
345
  )}
95
346
  </OverlayErrorBoundary>
347
+
348
+ {/* Selection box — the only interactive part of the caption layer. z 50
349
+ matches the overlay selection handles (see the layering note above);
350
+ the wrapper stays click-through so only the box itself catches events. */}
351
+ {element && canInteract && (
352
+ <div className="absolute inset-0" style={{ zIndex: 50, pointerEvents: 'none' }}>
353
+ <div
354
+ ref={boxRef}
355
+ onMouseDown={(e) => startGesture('move', e)}
356
+ onMouseEnter={() => setHovered(true)}
357
+ onMouseLeave={() => setHovered(false)}
358
+ style={{
359
+ // left/top/width/height are written by the layout effect above.
360
+ position: 'absolute',
361
+ display: 'none',
362
+ pointerEvents: 'auto',
363
+ touchAction: 'none',
364
+ cursor: isSelected ? (drag?.type === 'move' ? 'grabbing' : 'grab') : 'pointer',
365
+ // Unselected captions show a hairline on hover only — a permanent
366
+ // outline around every caption would be noise during review.
367
+ outline: isSelected ? '2px solid var(--editor-selection)'
368
+ : hovered ? '1px dashed var(--editor-selection)'
369
+ : 'none',
370
+ }}
371
+ >
372
+ {isSelected && onCaptionSegmentChange && CORNERS.map(([corner, fx, fy]) => (
373
+ <div
374
+ key={corner}
375
+ onMouseDown={(e) => startGesture(`resize-${corner}`, e)}
376
+ style={{
377
+ position: 'absolute',
378
+ left: `calc(${fx * 100}% - 6px)`,
379
+ top: `calc(${fy * 100}% - 6px)`,
380
+ width: 12, height: 12, backgroundColor: '#fff',
381
+ border: '1.5px solid var(--editor-selection)', borderRadius: 2,
382
+ cursor: corner === 'nw' || corner === 'se' ? 'nwse-resize' : 'nesw-resize',
383
+ pointerEvents: 'auto', touchAction: 'none',
384
+ }}
385
+ />
386
+ ))}
387
+ </div>
388
+ </div>
389
+ )}
96
390
  </div>
97
391
  )
98
392
  }
@@ -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
  }