@bycrux/editor 0.10.0 → 0.11.1

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.
@@ -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,61 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
93
344
  </div>
94
345
  )}
95
346
  </OverlayErrorBoundary>
347
+
348
+ {/* Centre/middle snap guides. Same amber hairlines, same z 50, and the
349
+ same `drag.type === 'move'` gate the overlay layer uses, so a snapped
350
+ caption reads identically to a snapped overlay. Only the centre pair
351
+ exists here — caption edge snap is deliberately not implemented (its
352
+ geometry does not transfer; see captionDragState.ts). */}
353
+ {drag?.type === 'move' && live?.snapX && (
354
+ <div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
355
+ )}
356
+ {drag?.type === 'move' && live?.snapY && (
357
+ <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
358
+ )}
359
+
360
+ {/* Selection box — the only interactive part of the caption layer. z 50
361
+ matches the overlay selection handles (see the layering note above);
362
+ the wrapper stays click-through so only the box itself catches events. */}
363
+ {element && canInteract && (
364
+ <div className="absolute inset-0" style={{ zIndex: 50, pointerEvents: 'none' }}>
365
+ <div
366
+ ref={boxRef}
367
+ onMouseDown={(e) => startGesture('move', e)}
368
+ onMouseEnter={() => setHovered(true)}
369
+ onMouseLeave={() => setHovered(false)}
370
+ style={{
371
+ // left/top/width/height are written by the layout effect above.
372
+ position: 'absolute',
373
+ display: 'none',
374
+ pointerEvents: 'auto',
375
+ touchAction: 'none',
376
+ cursor: isSelected ? (drag?.type === 'move' ? 'grabbing' : 'grab') : 'pointer',
377
+ // Unselected captions show a hairline on hover only — a permanent
378
+ // outline around every caption would be noise during review.
379
+ outline: isSelected ? '2px solid var(--editor-selection)'
380
+ : hovered ? '1px dashed var(--editor-selection)'
381
+ : 'none',
382
+ }}
383
+ >
384
+ {isSelected && onCaptionSegmentChange && CORNERS.map(([corner, fx, fy]) => (
385
+ <div
386
+ key={corner}
387
+ onMouseDown={(e) => startGesture(`resize-${corner}`, e)}
388
+ style={{
389
+ position: 'absolute',
390
+ left: `calc(${fx * 100}% - 6px)`,
391
+ top: `calc(${fy * 100}% - 6px)`,
392
+ width: 12, height: 12, backgroundColor: '#fff',
393
+ border: '1.5px solid var(--editor-selection)', borderRadius: 2,
394
+ cursor: corner === 'nw' || corner === 'se' ? 'nwse-resize' : 'nesw-resize',
395
+ pointerEvents: 'auto', touchAction: 'none',
396
+ }}
397
+ />
398
+ ))}
399
+ </div>
400
+ </div>
401
+ )}
96
402
  </div>
97
403
  )
98
404
  }
@@ -6,6 +6,7 @@ import CaptionPreview from './CaptionPreview'
6
6
  import { getOverlayDesignCanvas } from '../design-canvas'
7
7
  import { useDragOverlay } from './useDragOverlay'
8
8
  import type { OverlayChanges } from './useDragOverlay'
9
+ import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
9
10
  import OverlayItemsLayer from './OverlayItemsLayer'
10
11
  import { useVideoPlayback } from './useVideoPlayback'
11
12
  import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
@@ -26,6 +27,12 @@ interface PreviewPlayerProps {
26
27
  watchFile?: (path: string, onChange: () => void) => () => void
27
28
  fileUrl: (path: string) => string
28
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
29
36
  }
30
37
 
31
38
  export default function PreviewPlayer({
@@ -39,6 +46,9 @@ export default function PreviewPlayer({
39
46
  watchFile,
40
47
  fileUrl,
41
48
  resolveCaptionTemplate,
49
+ selectedCaptionId,
50
+ onSelectCaption,
51
+ onCaptionSegmentChange,
42
52
  }: PreviewPlayerProps) {
43
53
  if (project.projectType === 'carousel') return <CarouselPreview project={project} />
44
54
 
@@ -334,6 +344,9 @@ export default function PreviewPlayer({
334
344
  fps={project.settings?.fps ?? 30}
335
345
  compileOverlay={compileOverlay}
336
346
  resolveCaptionTemplate={resolveCaptionTemplate}
347
+ selectedCaptionId={selectedCaptionId}
348
+ onSelectCaption={onSelectCaption}
349
+ onCaptionSegmentChange={onCaptionSegmentChange}
337
350
  />
338
351
  )}
339
352
  </div>
@@ -0,0 +1,168 @@
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
+ // 12/34 are both well outside the centre-snap radius, so the geometry is
97
+ // the untouched starting offsets. The move branch also reports whether each
98
+ // axis is currently snapped (see captionSnap.test.ts) — asserted here as
99
+ // false so this stays a genuine no-op check rather than an assertion about
100
+ // the return shape.
101
+ const d = drag({ initOffsetX: 12, initOffsetY: 34, initScale: 1.5 })
102
+ expect(captionDragGeometry(d, d.initX, d.initY, metrics(540)))
103
+ .toEqual({ offsetX: 12, offsetY: 34, scale: 1.5, snapX: false, snapY: false })
104
+ })
105
+ })
106
+
107
+ describe('captionDragGeometry — corner resize', () => {
108
+ const m = metrics(RENDER_W) // 1:1, so screen px == design px
109
+
110
+ it('grows when dragging a corner outward and shrinks when dragging inward', () => {
111
+ const se = drag({ type: 'resize-se' })
112
+ expect(captionDragGeometry(se, se.initX + 108, se.initY, m).scale).toBeCloseTo(1.1, 10)
113
+ expect(captionDragGeometry(se, se.initX - 108, se.initY, m).scale).toBeCloseTo(0.9, 10)
114
+
115
+ // The NW corner's outward direction is up-left, so the signs invert.
116
+ const nw = drag({ type: 'resize-nw' })
117
+ expect(captionDragGeometry(nw, nw.initX - 108, nw.initY, m).scale).toBeCloseTo(1.1, 10)
118
+ expect(captionDragGeometry(nw, nw.initX + 108, nw.initY, m).scale).toBeCloseTo(0.9, 10)
119
+ })
120
+
121
+ it('sums both axes and scales proportionally from the starting scale', () => {
122
+ const d = drag({ type: 'resize-se', initScale: 2 })
123
+ // +10% of width and +10% of height → delta 0.2 → 2 * 1.2.
124
+ const g = captionDragGeometry(d, d.initX + 108, d.initY + 192, m)
125
+ expect(g.scale).toBeCloseTo(2.4, 10)
126
+ })
127
+
128
+ it('leaves the offsets untouched', () => {
129
+ const d = drag({ type: 'resize-ne', initOffsetX: 7, initOffsetY: -3 })
130
+ const g = captionDragGeometry(d, d.initX + 500, d.initY - 500, m)
131
+ expect(g.offsetX).toBe(7)
132
+ expect(g.offsetY).toBe(-3)
133
+ })
134
+
135
+ it('clamps to the scale bounds', () => {
136
+ const shrink = drag({ type: 'resize-se', initScale: 1 })
137
+ expect(captionDragGeometry(shrink, shrink.initX - 100_000, shrink.initY, m).scale).toBe(CAPTION_MIN_SCALE)
138
+ const grow = drag({ type: 'resize-se', initScale: 1 })
139
+ expect(captionDragGeometry(grow, grow.initX + 100_000, grow.initY, m).scale).toBe(CAPTION_MAX_SCALE)
140
+ })
141
+ })
142
+
143
+ describe('hasEscapedClickSlop', () => {
144
+ it('treats a small wobble as a click, not a drag', () => {
145
+ const d = drag()
146
+ expect(hasEscapedClickSlop(d, d.initX, d.initY)).toBe(false)
147
+ expect(hasEscapedClickSlop(d, d.initX + 2, d.initY + 2)).toBe(false) // 2.83px
148
+ expect(hasEscapedClickSlop(d, d.initX + 3, d.initY + 3)).toBe(true) // 4.24px
149
+ })
150
+
151
+ it('measures radially, not per axis', () => {
152
+ const d = drag()
153
+ expect(hasEscapedClickSlop(d, d.initX, d.initY + CAPTION_DRAG_SLOP_PX + 1)).toBe(true)
154
+ expect(hasEscapedClickSlop(d, d.initX - CAPTION_DRAG_SLOP_PX - 1, d.initY)).toBe(true)
155
+ })
156
+ })
157
+
158
+ describe('captionDragPatch', () => {
159
+ it('a move writes only the offsets', () => {
160
+ expect(captionDragPatch(drag(), { offsetX: 3, offsetY: 4, scale: 2 }))
161
+ .toEqual({ offsetX: 3, offsetY: 4 })
162
+ })
163
+
164
+ it('a resize writes only the scale', () => {
165
+ expect(captionDragPatch(drag({ type: 'resize-sw' }), { offsetX: 3, offsetY: 4, scale: 2 }))
166
+ .toEqual({ scale: 2 })
167
+ })
168
+ })