rvlib-shipkit 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
1
1
  // the review page's middle column: every view of the open card stacked, each under its heading with the owner's reaction and
2
2
  // its zoom; a view is an image, a clip, a pair (side by side, the curtain, the onion skin, the after alone) or a
3
- // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time. A choice, or
4
- // views that each play alone, show as thin option rows; the card's header sits on top when the page reads in one column
3
+ // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time; the picked
4
+ // rectangle's note box opens beside it. A choice, or views that each play alone, show as option rows, each image and
5
+ // clip shown full under its row; the card's header sits on top when the page reads in one column
5
6
  import { observer } from 'mobx-react-lite'
6
7
  import { useEffect, useRef, useState, type ReactNode } from 'react'
7
8
  import { isGallery, isPair, onFrame, STARS, thumbFileOf, verdictOf, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
9
+ import { AutoText } from '../auto-text.tsx'
8
10
  import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
9
11
  import { CardHead } from './review-answer.tsx'
10
12
  import type { ClipSt } from './clip-st.ts'
@@ -20,11 +22,13 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
20
22
  const views = r.views
21
23
  const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
22
24
  const rows = rv.rows
25
+ // a draft card's history is no view of its own: its frames cycle in place over the live one
26
+ const history = r.wip ? views.find(isGallery) : undefined
23
27
  return (
24
28
  <div className="rv-views">
25
29
  {rv.headInViews ? <CardHead rv={rv} x={x} /> : null}
26
30
  <div className="rv-viewsbar">
27
- {views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
31
+ {views.length > 1 && !rows && !history ? <JumpList rv={rv} /> : <span />}
28
32
  <span className="rv-viewsctl">
29
33
  {asOptionsCard(rv) ? (
30
34
  <span className="seg" title="the options as thin rows, one below the other, or every view full">
@@ -49,7 +53,9 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
49
53
  {rows ? (
50
54
  <div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
51
55
  ) : (
52
- views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
56
+ views.map((v, i) => (v === history ? null : (
57
+ <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > (history ? 2 : 1)} {...(i === 0 && history && isGallery(history) ? { frames: history } : {})} />
58
+ )))
53
59
  )}
54
60
  {views.length === 0 ? <TextOnly x={x} /> : null}
55
61
  </div>
@@ -59,8 +65,9 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
59
65
  /** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
60
66
  const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
61
67
 
62
- /** one option of a choice, or one view of a group, as a thin row: its number, ▶ and the clock, its label, a strip that
63
- * scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view under it */
68
+ /** one option of a choice, or one view of a group, as a row: its number, ▶ and the clock, its label, a strip that
69
+ * scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ folds or unfolds the full view
70
+ * under it (an image or a clip shows it from the start) */
64
71
  const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
65
72
  const [word, setWord] = useState(false)
66
73
  const id = x.item.id
@@ -71,7 +78,7 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
71
78
  const choice = rv.choice
72
79
  const picked = rv.draft.pick === i
73
80
  const sent = verdictOf(x)?.pick === i
74
- const unfolded = rv.openRows[i] === true
81
+ const unfolded = rv.rowOpen(i)
75
82
  const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
76
83
  const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
77
84
  const showWord = word || (reaction?.comment ?? '') !== ''
@@ -92,7 +99,7 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
92
99
  ) : null}
93
100
  {c && playable ? (
94
101
  <Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
95
- ) : (
102
+ ) : unfolded ? null : (
96
103
  <span className="rv-optthumb img" onClick={() => rv.toggleRow(i)} title="the full view"><img src={rv.thumbUrl(x, thumbFileOf(v))} alt="" draggable={false} /></span>
97
104
  )}
98
105
  {c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
@@ -112,8 +119,8 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
112
119
  </div>
113
120
  {media?.note ? <div className="rv-optnote">{media.note}</div> : null}
114
121
  {showWord ? (
115
- <textarea className="rv-word" rows={1} placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
116
- onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
122
+ <AutoText className="rv-word" placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
123
+ onKeyDown={blurOnEscape} onText={(t) => rv.setReactionComment(i, undefined, t)} />
117
124
  ) : null}
118
125
  {c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
119
126
  {unfolded ? (
@@ -223,8 +230,11 @@ const JumpList = observer(({ rv }: { rv: ReviewSt }) => {
223
230
  )
224
231
  })
225
232
 
226
- /** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it */
227
- const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean }) => {
233
+ /** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it; a draft card's
234
+ * live view carries its history's frames, one shown in its place */
235
+ const ViewBlock = observer(({ rv, id, v: live, i, many, frames }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean; frames?: GalleryView }) => {
236
+ const past = frames && rv.frame !== null ? frames.gallery[rv.frame] : undefined
237
+ const v: ReviewView = past && !isGallery(live) ? { ...live, files: { image: past.file }, size: past.size } : live
228
238
  const reaction = rv.reactionOf(i)
229
239
  const media = isGallery(v) ? null : v
230
240
  const zoom = rv.zoomOf(i)
@@ -243,9 +253,25 @@ const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string;
243
253
  onClick={() => rv.setViewZoom(i, zoom === 'fit' ? '1' : 'fit')}>{zoom === 'fit' ? '100 %' : 'Fit'}</button>
244
254
  ) : null}
245
255
  </div>
256
+ {frames ? <FrameSlider rv={rv} frames={frames} /> : null}
246
257
  {isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={zoom} />}
247
- <textarea className="rv-word" rows={1} placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
248
- onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
258
+ <AutoText className="rv-word" placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
259
+ onKeyDown={blurOnEscape} onText={(t) => rv.setReactionComment(i, undefined, t)} />
260
+ </div>
261
+ )
262
+ })
263
+
264
+ /** a draft card's frames over its live view: a slider and two arrows cycle them in place, the live one at the right end */
265
+ const FrameSlider = observer(({ rv, frames }: { rv: ReviewSt; frames: GalleryView }) => {
266
+ const last = frames.gallery.length
267
+ const at = rv.frame ?? last
268
+ const go = (f: number): void => rv.setFrame(f >= last ? null : Math.max(0, f))
269
+ return (
270
+ <div className="rv-frames">
271
+ <button title="the frame before" disabled={at === 0} onMouseDown={keepFocus} onClick={() => go(at - 1)}>‹</button>
272
+ <input type="range" min={0} max={last} step={1} value={at} aria-label="the draft's frames" onChange={(e) => go(Number(e.currentTarget.value))} onMouseUp={(e) => e.currentTarget.blur()} />
273
+ <button title="the frame after" disabled={at === last} onMouseDown={keepFocus} onClick={() => go(at + 1)}>›</button>
274
+ <span className="rv-dim rv-small">{at === last ? 'now' : (frames.gallery[at]?.label ?? `frame ${at + 1}`)}</span>
249
275
  </div>
250
276
  )
251
277
  })
@@ -285,8 +311,8 @@ const Gallery = observer(({ rv, id, v, view }: { rv: ReviewSt; id: string; v: Ga
285
311
  </div>
286
312
  <div className="rv-tilelabel" title={t.label}>{t.label}</div>
287
313
  <ReactionToggles rv={rv} view={view} tile={k} small />
288
- <textarea className="rv-word" rows={1} placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
289
- onChange={(e) => rv.setReactionComment(view, k, e.currentTarget.value)} />
314
+ <AutoText className="rv-word" placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
315
+ onText={(t) => rv.setReactionComment(view, k, t)} />
290
316
  </div>
291
317
  )
292
318
  })}
@@ -309,7 +335,8 @@ const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string;
309
335
  return (
310
336
  <div style={scroll}>
311
337
  <div style={{ width: scale ? `${v.size.w * scale}px` : `min(100%, calc((100vh - ${room}px) * ${ratio}))`, margin: '0 auto' }}>
312
- <Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />} />
338
+ <Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />}
339
+ over={() => <RectNote rv={rv} view={i} sides={[undefined]} />} />
313
340
  {bar}
314
341
  <Downloads rv={rv} id={id} files={[{ file: name }]} />
315
342
  </div>
@@ -334,7 +361,8 @@ const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string;
334
361
  <div style={scroll}>
335
362
  <div className="rv-pairgrid" style={{ width: scale ? `${v.size.w * scale * cols + 8 * (cols - 1)}px` : fitW, gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
336
363
  <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect}
337
- draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />} />
364
+ draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />}
365
+ over={(srcs) => <RectNote rv={rv} view={i} sides={srcs.map((s) => drawnSide(c.mode, s === before ? 'before' : 'after'))} />} />
338
366
  </div>
339
367
  </div>
340
368
  {bar ? <div style={{ width: fitW, margin: '0 auto' }}>{bar}</div> : null}
@@ -351,7 +379,7 @@ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) =>
351
379
  if (!a) return null
352
380
  const rows = isPair(f) ? [{ label: 'before', file: f.before, wave: a.waveBefore ?? a.wave }, { label: 'after', file: f.after, wave: a.wave }] : [{ label: '', file: 'audio' in f ? f.audio : '', wave: a.wave }]
353
381
  return (
354
- <div className="rv-sound">
382
+ <div className="rv-soundview">
355
383
  {rows.map((r) => (
356
384
  <div key={r.file} className="rv-soundrow">
357
385
  {r.label ? <div className="rv-dim">{r.label}</div> : null}
@@ -412,9 +440,11 @@ const PhonePair = observer(({ rv, view, bar, before, after, aspect, size, scale
412
440
  <div style={scale ? { overflow: 'auto', maxHeight: '70vh' } : undefined}>
413
441
  <div style={{ width: scale ? `${size.w * scale}px` : '100%' }}>
414
442
  {side === 'swipe' ? (
415
- <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={s === before ? 'before' : 'after'} view={view} />} />
443
+ <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={s === before ? 'before' : 'after'} view={view} />}
444
+ over={(srcs) => <RectNote rv={rv} view={view} sides={srcs.map((s) => (s === before ? 'before' : 'after'))} />} />
416
445
  ) : (
417
- <Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />} />
446
+ <Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />}
447
+ over={() => <RectNote rv={rv} view={view} sides={[side]} />} />
418
448
  )}
419
449
  {bar}
420
450
  </div>
@@ -458,27 +488,28 @@ function track(move: (e: PointerEvent) => void): void {
458
488
  }
459
489
 
460
490
  /** THE pair: side by side, the curtain clipping both images, the after over the before as an onion skin, or the
461
- * after alone (the before while the pointer is over it). One image alone when `mod` is undefined */
462
- const Compare = observer(({ c, base, mod, names, aspect, draw }: { c: CompareSt; base: string; mod: string | undefined; names?: { base: string; mod: string }; aspect: string; draw: (src: string) => ReactNode }) => {
491
+ * after alone (the before while the pointer is over it). One image alone when `mod` is undefined. `over` draws what
492
+ * sits over a frame unclipped (the picked rectangle's note box), given the images that frame shows */
493
+ const Compare = observer(({ c, base, mod, names, aspect, draw, over }: { c: CompareSt; base: string; mod: string | undefined; names?: { base: string; mod: string }; aspect: string; draw: (src: string) => ReactNode; over?: (srcs: string[]) => ReactNode }) => {
463
494
  const [peek, setPeek] = useState(false)
464
- if (mod === undefined) return <Cell aspect={aspect} caption={null}><div className="rv-fill">{draw(base)}</div></Cell>
495
+ if (mod === undefined) return <Cell aspect={aspect} caption={null} over={over?.([base])}><div className="rv-fill">{draw(base)}</div></Cell>
465
496
  const n = names ?? PAIR_NAMES
466
497
  if (c.mode === 'side')
467
498
  return (
468
499
  <>
469
- <Cell aspect={aspect} caption={n.base}><div className="rv-fill">{draw(base)}</div></Cell>
470
- <Cell aspect={aspect} caption={n.mod} modCap><div className="rv-fill">{draw(mod)}</div></Cell>
500
+ <Cell aspect={aspect} caption={n.base} over={over?.([base])}><div className="rv-fill">{draw(base)}</div></Cell>
501
+ <Cell aspect={aspect} caption={n.mod} modCap over={over?.([mod])}><div className="rv-fill">{draw(mod)}</div></Cell>
471
502
  </>
472
503
  )
473
504
  if (c.mode === 'mod')
474
505
  return (
475
- <Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek}>
506
+ <Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek} over={over?.([peek ? base : mod])}>
476
507
  <div className="rv-fill">{draw(peek ? base : mod)}</div>
477
508
  </Cell>
478
509
  )
479
510
  const pct = Math.round(c.split * 100)
480
511
  return (
481
- <Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined}>
512
+ <Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined} over={over?.([base, mod])}>
482
513
  <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 ${100 - pct}% 0 0)` } : undefined}>{draw(base)}</div>
483
514
  <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 0 0 ${pct}%)` } : { opacity: c.opacity }}>{draw(mod)}</div>
484
515
  {c.mode === 'swipe' ? <div className="rv-curtain" style={{ left: `${pct}%` }} /> : null}
@@ -486,8 +517,8 @@ const Compare = observer(({ c, base, mod, names, aspect, draw }: { c: CompareSt;
486
517
  )
487
518
  })
488
519
 
489
- /** one frame and its caption; the curtain's line follows the pointer */
490
- const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; children: ReactNode }) => {
520
+ /** one frame and its caption; the curtain's line follows the pointer; `over` sits on the frame, free of its clip */
521
+ const Cell = ({ aspect, caption, modCap, swipe, onPeek, over, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; over?: ReactNode; children: ReactNode }) => {
491
522
  const frame = useRef<HTMLDivElement>(null)
492
523
  const down = (e: React.PointerEvent): void => {
493
524
  const el = frame.current
@@ -502,9 +533,12 @@ const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: st
502
533
  }
503
534
  return (
504
535
  <div className="rv-cell">
505
- <div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
506
- onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
507
- {children}
536
+ <div className="rv-framebox">
537
+ <div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
538
+ onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
539
+ {children}
540
+ </div>
541
+ {over ? <div className="rv-over">{over}</div> : null}
508
542
  </div>
509
543
  {caption != null ? <div className={`rv-caption${modCap ? ' mod' : ''}`}>{caption}</div> : null}
510
544
  </div>
@@ -578,7 +612,7 @@ const Marks = observer(({ rv, src, side, view }: { rv: ReviewSt; src: string; si
578
612
  {clip ? <ClipVideo key={src} clip={c} src={src} /> : <img src={src} alt="" draggable={false} className="rv-markimg" />}
579
613
  {shown && rings.length > 0 ? <Rings marks={rings} w={shown.size.w} h={shown.size.h} /> : null}
580
614
  {rects.map(([r, i]) => (
581
- <RectMark key={i} r={r} n={i + 1} on={rv.rect === i} faint={!onIts(r)} at={clip && r.t !== undefined ? secs(r.t) : undefined} pick={() => rv.pickRect(i)} />
615
+ <RectMark key={i} r={r} n={i + 1} on={rv.rect === i} faint={!onIts(r)} at={clip && r.t !== undefined ? secs(r.t) : undefined} pick={() => rv.pickRect(i, true)} />
582
616
  ))}
583
617
  {drag ? <div className="rv-drag" style={pct(box(drag))} /> : null}
584
618
  </div>
@@ -600,11 +634,46 @@ const Rings = ({ marks, w, h }: { marks: readonly ReviewMark[]; w: number; h: nu
600
634
  /** a rectangle of his; on a clip, faint and dashed away from its frame, its time on its number */
601
635
  const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boolean; faint: boolean; at: string | undefined; pick: () => void }) => (
602
636
  <div className={`rv-rect${on ? ' on' : ''}${faint ? ' faint' : ''}`} style={pct(r)} title={r.comment || `rectangle ${n}`}
603
- onPointerDown={(e) => { e.stopPropagation(); pick() }}>
637
+ onPointerDown={(e) => { e.stopPropagation(); pick() }} onMouseDown={keepFocus}>
604
638
  <span className="rv-rectn">{n}{at ? ` · ${at}` : ''}</span>
605
639
  </div>
606
640
  )
607
641
 
642
+ /** the picked rectangle's note box, beside it on the image: under it, or over it when it sits low, focused once drawn
643
+ * or clicked. Enter or Escape closes it (the rectangle stays), Shift Enter is a new line; `sides` are the sides the
644
+ * frame shows */
645
+ const RectNote = observer(({ rv, view, sides }: { rv: ReviewSt; view: number; sides: readonly (Side | undefined)[] }) => {
646
+ const box = useRef<HTMLTextAreaElement | null>(null)
647
+ const i = rv.rect
648
+ const r = i === null ? undefined : rv.draft.rects[i]
649
+ const here = i !== null && r !== undefined && (r.view ?? 0) === view && sides.includes(r.side)
650
+ const focus = here && rv.noteFocus === i
651
+ useEffect(() => {
652
+ if (!focus || box.current === null) return
653
+ box.current.focus()
654
+ rv.noteFocused()
655
+ }, [focus, rv])
656
+ if (!here) return null
657
+ const below = r.y + r.h <= NOTE_LOW || r.y < 1 - NOTE_LOW
658
+ const place = below ? { top: `${(r.y + r.h) * 100}%` } : { bottom: `${(1 - r.y) * 100}%` }
659
+ return (
660
+ <div className={`rv-note${below ? ' below' : ' above'}`} style={{ ...place, ['--nx' as string]: `${r.x * 100}%` }} onPointerDown={(e) => e.stopPropagation()}>
661
+ <span className="rv-noten">{i + 1}{r.t !== undefined ? ` · ${secs(r.t)}` : ''}</span>
662
+ <AutoText el={(t) => (box.current = t)} placeholder="what is wrong here · Enter closes" value={r.comment}
663
+ onText={(t) => rv.setRectComment(i, t)}
664
+ onKeyDown={(e) => {
665
+ if ((e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) || e.key === 'Escape') {
666
+ e.preventDefault()
667
+ rv.pickRect(null)
668
+ }
669
+ }} />
670
+ <button className="ghost rv-x" title="delete this rectangle" aria-label="delete this rectangle" onMouseDown={keepFocus} onClick={() => rv.removeRect(i)}>✕</button>
671
+ </div>
672
+ )
673
+ })
674
+ /** a rectangle whose bottom is lower than this share of the image opens its note over it, when there is room above */
675
+ const NOTE_LOW = 0.78
676
+
608
677
  // ── a clip ──
609
678
 
610
679
  /** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
@@ -4,6 +4,7 @@
4
4
  import { observer } from 'mobx-react-lite'
5
5
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
6
6
  import { Group, Panel, Separator } from 'react-resizable-panels'
7
+ import { AutoText } from '../auto-text.tsx'
7
8
  import { clock, cutsByRecent, shotAt, type CutInfo, type TrailerNote } from '../../../review/trailer-model.ts'
8
9
  import { editorHref } from '../check-words.ts'
9
10
  import { renderMd } from '../md.ts'
@@ -271,7 +272,7 @@ const DraftBox = observer(({ tr }: { tr: TrailerSt }) => {
271
272
  <span className="tr-dim tr-small">click the image to pin a point</span>
272
273
  )}
273
274
  </div>
274
- <textarea autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onChange={(e) => tr.setDraftText(e.currentTarget.value)}
275
+ <AutoText autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onText={(t) => tr.setDraftText(t)}
275
276
  onKeyDown={(e) => {
276
277
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
277
278
  e.preventDefault()
@@ -362,7 +363,7 @@ const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
362
363
  {on ? <Still tr={tr} n={n} w="100%" /> : null}
363
364
  {editing ? (
364
365
  <div className="tr-edit">
365
- <textarea autoFocus rows={3} value={editing.text} onChange={(e) => tr.setEditText(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
366
+ <AutoText autoFocus rows={3} value={editing.text} onText={(t) => tr.setEditText(t)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
366
367
  <div className="tr-dim tr-small">Enter saves · Escape drops it · on its frame, a click moves the pin</div>
367
368
  </div>
368
369
  ) : null}
@@ -374,8 +375,8 @@ const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
374
375
  ) : null}
375
376
  {closing ? (
376
377
  <div className="tr-edit">
377
- <textarea autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
378
- onChange={(e) => tr.setReply(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
378
+ <AutoText autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
379
+ onText={(t) => tr.setReply(t)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
379
380
  <div className="tr-draftbtns">
380
381
  <button onClick={() => tr.cancelClose()}>Cancel</button>
381
382
  <button className="primary" onClick={() => void tr.confirmClose()}>{closing.state === 'done' ? 'Mark done' : 'Not doing it'}</button>
@@ -120,6 +120,9 @@
120
120
  .rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
121
121
  .rv-pairgrid{display:grid;gap:var(--s2)}
122
122
  .rv-cell{display:flex;flex-direction:column;align-items:center;gap:var(--s0);min-width:0}
123
+ /* the frame's box: what sits over the frame free of its clip (the note box) shares its size */
124
+ .rv-framebox{position:relative;width:100%}
125
+ .rv-over{position:absolute;inset:0;z-index:4;pointer-events:none}
123
126
  .rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
124
127
  background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
125
128
  .rv-frame.swipe{cursor:ew-resize;touch-action:none}
@@ -142,6 +145,13 @@
142
145
  .rv-rectn{position:absolute;left:-2px;top:-2px;transform:translateY(-100%);padding:0 var(--s1);border-radius:3px;background:var(--warn);color:var(--bg);font-size:11px;font-weight:800;line-height:16px;white-space:nowrap}
143
146
  .rv-rect.on .rv-rectn{background:var(--blue)}
144
147
  .rv-drag{position:absolute;border:2px dashed var(--warn)}
148
+ /* the picked rectangle's note box, in the picked rectangle's blue: under it, or over it (clearing its number) */
149
+ .rv-note{position:absolute;left:clamp(0px,var(--nx),calc(100% - min(300px,100%)));width:min(300px,100%);pointer-events:auto;display:flex;gap:var(--s1);align-items:flex-start;
150
+ padding:var(--s1);border-radius:6px;border:2px solid var(--blue);background:var(--panel);box-shadow:0 2px 10px var(--shadow)}
151
+ .rv-note.below{margin-top:var(--s1)}
152
+ .rv-note.above{margin-bottom:var(--s4)}
153
+ .rv-noten{flex:none;padding:0 var(--s1);border-radius:3px;background:var(--blue);color:var(--bg);font-size:11px;font-weight:800;line-height:18px;white-space:nowrap}
154
+ .rv-note textarea{flex:1;min-width:0}
145
155
  .rv-downloads{display:flex;gap:var(--s2);justify-content:flex-end;margin-top:var(--s1)}
146
156
  .rv-dl{font-size:11.5px;text-decoration:none}
147
157
  .rv-gallery{display:flex;flex-wrap:wrap;gap:var(--s2);align-items:flex-start}
@@ -153,8 +163,12 @@
153
163
  .rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
154
164
  .rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
155
165
  .rv-clipbar{display:flex;gap:var(--s1);align-items:center;margin-top:var(--s2)}
166
+ .rv-frames{display:flex;gap:var(--s1);align-items:center;margin-bottom:var(--s2)}
167
+ .rv-frames input{flex:1;min-width:60px}
168
+ .rv-frames button{padding:0 var(--s2);line-height:22px}
156
169
  .rv-clipbar button{padding:0 var(--s2);line-height:22px}
157
170
  .rv-scrub{flex:1;min-width:60px}
171
+ .rv-clipbar .rv-mono{flex:none;white-space:nowrap}
158
172
  .rv-sound{display:inline-flex;align-items:center;gap:var(--s1)}
159
173
  .rv-sound button{padding:0 var(--s1);line-height:22px}
160
174
  .rv-sound input{width:72px}
@@ -223,7 +237,7 @@
223
237
  .rv-linkedtitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
224
238
  .rv-outgoing,.rv-messages{display:flex;flex-direction:column;gap:var(--s1)}
225
239
  .rv-outrow{display:flex;gap:var(--s1);align-items:flex-start}
226
- .rv-outrow textarea{flex:1;min-height:44px}
240
+ .rv-outrow textarea{flex:1}
227
241
  .rv-outbadge{flex:none;max-width:170px;margin-top:var(--s0);font-size:11px;padding:0 var(--s2);line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
228
242
  color:var(--tone,var(--ink));border-color:color-mix(in srgb,var(--tone,var(--line)) 50%,var(--line))}
229
243
  .rv-outbadge.rect{--tone:var(--warn)}
@@ -251,6 +265,8 @@
251
265
  .rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
252
266
  .rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
253
267
  .rv-phone .rv-views{gap:var(--s3)}
268
+ /* the views' labels take the phone's whole width, the zoom under them */
269
+ .rv-phone .rv-jumps{flex:1 1 100%}
254
270
  .rv-phone .rv-toast{top:var(--s4);bottom:auto}
255
271
  /* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
256
272
  .rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
@@ -261,6 +277,8 @@
261
277
  .rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
262
278
  .rv-phone .rv-optplay{width:40px;height:34px}
263
279
  .rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
280
+ /* a phone zooms the page into a field whose text is under 16px: the field takes 16px instead */
281
+ .rv-phone textarea,.tr-phone textarea{font-size:16px}
264
282
 
265
283
  /* ── the trailer page ── */
266
284
  .tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
@@ -385,5 +403,5 @@
385
403
  .rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
386
404
  .rv-anim select{font-size:12px}
387
405
  /* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
388
- .rv-sound{display:flex;flex-direction:column;gap:var(--s2)}
406
+ .rv-soundview{display:flex;flex-direction:column;gap:var(--s2)}
389
407
  .rv-soundrow{display:flex;flex-direction:column;gap:var(--s1);background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:var(--s2)}
@@ -12,6 +12,7 @@ export function comfyModule(comfy: string | undefined, name: string): string {
12
12
  const COMFY_TIMEOUT_S = 15 * 60
13
13
 
14
14
  export async function comfyRun(module: string, vars: Record<string, string | number | boolean | undefined>, out: string): Promise<void> {
15
+ if (Bun.which('comfy-ts') == null) throw new Error(`comfy-ts is not on PATH, and ${module} is a comfy-ts module. Install it: bun add -g comfy-ts`)
15
16
  const args = ['comfy-ts', 'run', module]
16
17
  for (const [k, v] of Object.entries(vars)) if (v !== undefined) args.push(`--${k}`, String(v))
17
18
  args.push('--out', out, '--json')