Files
VoiceStudio/frontend/src/components/SegmentTrack.jsx
T
5c9b7ca313 chore(format): adopt oxfmt for JS/TS/JSX + CI format gate (#774)
Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).

Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
  style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
  default. (Flipping quotes globally also broke a source-string-parsing test;
  preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
  them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
  files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
  for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).

Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.

Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:18:01 +05:30

598 lines
21 KiB
React

import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Play, Headphones } from 'lucide-react';
import {
REGION_COLORS,
SNAP_PX,
visibleSegmentRange,
snapTime,
snapCandidates,
clampSegmentEdit,
detectOverlaps,
nearestOnset,
} from '../utils/timeline';
import './SegmentTrack.css';
const ONSET_STRIP_H = 8; // px — non-interactive onset tick strip
const KB_STEP_S = 0.01; // ←/→ nudge
const KB_STEP_BIG_S = 0.1; // Ctrl+←/→ nudge
const DRAG_DEADZONE_PX = 3;
const fmt = (t) => {
const m = Math.floor(t / 60);
const s = (t % 60).toFixed(2);
return `${m}:${s.padStart(5, '0')}`;
};
/**
* SegmentTrack — custom DOM segment editor lane for the dub timeline (#280).
*
* Replaces the WaveSurfer Regions plugin in the editing path. Renders one
* absolutely-positioned box per segment inside a lane whose horizontal
* position is derived from a single {pxPerSec, scrollLeft} source (read off
* WaveSurfer's wrapper by the parent), so boxes stay pixel-aligned with the
* waveform across zoom/scroll/resize. Virtualized by TIME — only the boxes
* inside the visible window (+ buffer) are mounted.
*
* Props:
* segments sorted-by-start segment array (store shape)
* pxPerSec px per second (single alignment source)
* scrollLeft px (ignored when selfScroll — WebKit fallback)
* duration track duration (s)
* currentTime playhead (s)
* onsets speech onset times (s) for snap + tick strip
* disabled locks all editing (generation in flight)
* selectedId selected segment id (String), selection syncs the table
* onSelectSeg (id|null) => void
* incrementalPlan { stale: [ids], fresh: [ids] } | null
* onCommit (id, {start,end}, {undo}) => void — ONE per gesture
* onDelete (id) => void
* onPlayRange (start, end) => void — play the slot on the main player
* onPreviewSegment(seg) => void — synthesize-and-play this segment's dub
* onEnsureVisible (timeS) => void — ask parent to scroll a time into view
* selfScroll WebKit fallback: lane scrolls itself (fixed pxPerSec)
*/
export default function SegmentTrack({
segments = [],
pxPerSec = 0,
scrollLeft = 0,
duration = 0,
currentTime = 0,
onsets = [],
disabled = false,
selectedId = null,
onSelectSeg,
incrementalPlan = null,
onCommit,
onDelete,
onPlayRange,
onPreviewSegment,
onEnsureVisible,
selfScroll = false,
}) {
const { t } = useTranslation();
const hostRef = useRef(null);
const viewportRef = useRef(null);
const canvasRef = useRef(null);
const boxRefs = useRef(new Map());
const gestureRef = useRef(null); // live pointer gesture
const kbGestureRef = useRef(false); // first nudge of a focus session pushed undo?
const [viewWidth, setViewWidth] = useState(0);
const [innerScroll, setInnerScroll] = useState(0); // selfScroll mode only
const [live, setLive] = useState(null); // {id,start,end} during drag
const [activeEdge, setActiveEdge] = useState(null); // dragged edge time (onset highlight)
const [focusId, setFocusId] = useState(null);
const [editMode, setEditMode] = useState(false);
const [announceMsg, setAnnounceMsg] = useState('');
const effScroll = selfScroll ? innerScroll : scrollLeft;
const announce = useCallback((msg) => setAnnounceMsg(msg), []);
// ── Geometry ────────────────────────────────────────────────────────────
useLayoutEffect(() => {
const el = hostRef.current;
if (!el) return undefined;
const measure = () => setViewWidth(el.clientWidth || 0);
measure();
if (typeof ResizeObserver === 'undefined') return undefined;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const indexById = useMemo(() => {
const m = new Map();
segments.forEach((s, i) => m.set(String(s.id), i));
return m;
}, [segments]);
// Segments with the in-flight drag override applied (render + overlap).
const effSegments = useMemo(() => {
if (!live) return segments;
return segments.map((s) =>
String(s.id) === live.id ? { ...s, start: live.start, end: live.end } : s,
);
}, [segments, live]);
const overlaps = useMemo(() => detectOverlaps(effSegments), [effSegments]);
const staleSet = useMemo(
() => new Set((incrementalPlan?.stale || []).map(String)),
[incrementalPlan],
);
const freshSet = useMemo(
() => new Set((incrementalPlan?.fresh || []).map(String)),
[incrementalPlan],
);
const viewStart = pxPerSec > 0 ? effScroll / pxPerSec : 0;
const viewEnd = pxPerSec > 0 ? (effScroll + viewWidth) / pxPerSec : 0;
const [lo, hi] = useMemo(
() => visibleSegmentRange(effSegments, viewStart, viewEnd, 2),
[effSegments, viewStart, viewEnd],
);
const speakerColor = useMemo(() => {
const speakers = [...new Set(segments.map((s) => s.speaker_id).filter(Boolean))];
const bySpeaker = new Map(
speakers.map((sp, i) => [sp, REGION_COLORS[i % REGION_COLORS.length]]),
);
return (seg, idx) => bySpeaker.get(seg.speaker_id) || REGION_COLORS[idx % REGION_COLORS.length];
}, [segments]);
// ── Onset tick strip (one viewport-sized canvas, non-interactive) ───────
useEffect(() => {
const cv = canvasRef.current;
if (!cv || viewWidth <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.round(viewWidth * dpr);
const h = Math.round(ONSET_STRIP_H * dpr);
if (cv.width !== w) cv.width = w;
if (cv.height !== h) cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
if (pxPerSec <= 0) return;
const snapS = SNAP_PX / pxPerSec;
const t0 = effScroll / pxPerSec - 1;
const t1 = (effScroll + viewWidth) / pxPerSec + 1;
for (const o of onsets) {
if (o < t0 || o > t1) continue;
const x = Math.round((o * pxPerSec - effScroll) * dpr) + 0.5;
const hot = activeEdge != null && Math.abs(o - activeEdge) <= snapS;
ctx.strokeStyle = hot ? '#fabd2f' : 'rgba(168,153,132,0.45)';
ctx.lineWidth = hot ? 2 * dpr : 1 * dpr;
ctx.beginPath();
ctx.moveTo(x, hot ? 0 : h * 0.35);
ctx.lineTo(x, h);
ctx.stroke();
}
}, [onsets, pxPerSec, effScroll, viewWidth, activeEdge]);
// ── Selection / focus plumbing ──────────────────────────────────────────
const ensureVisible = useCallback(
(timeS) => {
if (selfScroll) {
const vp = viewportRef.current;
if (vp && pxPerSec > 0) {
const x = timeS * pxPerSec;
if (x < vp.scrollLeft || x > vp.scrollLeft + vp.clientWidth) {
vp.scrollLeft = Math.max(0, x - vp.clientWidth * 0.3);
}
}
return;
}
onEnsureVisible?.(timeS);
},
[selfScroll, pxPerSec, onEnsureVisible],
);
const selectAndFocus = useCallback(
(sid) => {
setFocusId(sid);
onSelectSeg?.(sid);
},
[onSelectSeg],
);
// Keep DOM focus on the roving-focus box after re-renders, but only when
// focus already lives inside the track (never steal it from elsewhere).
useEffect(() => {
if (focusId == null) return;
const el = boxRefs.current.get(String(focusId));
const host = hostRef.current;
if (el && host && host.contains(document.activeElement) && document.activeElement !== el) {
el.focus({ preventScroll: true });
}
}, [focusId, lo, hi]);
// ── Pointer gestures (drag = move, handles = resize) ────────────────────
const onBoxPointerDown = useCallback(
(e) => {
if (e.button !== 0) return;
const sid = e.currentTarget.dataset.segid;
selectAndFocus(sid);
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s || pxPerSec <= 0) return;
const handle = e.target?.dataset?.handle || null;
gestureRef.current = {
sid,
idx,
mode: handle || 'move',
startX: e.clientX,
origStart: s.start,
origEnd: s.end,
moved: false,
last: null,
};
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* jsdom */
}
},
[disabled, indexById, segments, pxPerSec, selectAndFocus],
);
const onBoxPointerMove = useCallback(
(e) => {
const g = gestureRef.current;
if (!g || pxPerSec <= 0) return;
const dx = e.clientX - g.startX;
if (!g.moved && Math.abs(dx) < DRAG_DEADZONE_PX) return;
g.moved = true;
const dt = dx / pxPerSec;
let proposed;
if (g.mode === 'move') proposed = { start: g.origStart + dt, end: g.origEnd + dt };
else if (g.mode === 'start') proposed = { start: g.origStart + dt, end: g.origEnd };
else proposed = { start: g.origStart, end: g.origEnd + dt };
// Snap (unless Alt): onsets + adjacent edges + playhead + low-zoom grid.
const alt = e.altKey;
if (!alt) {
const thresholdS = SNAP_PX / pxPerSec;
const edge = g.mode === 'end' ? proposed.end : proposed.start;
const cands = snapCandidates({
onsets,
prevEnd: segments[g.idx - 1]?.end,
nextStart: segments[g.idx + 1]?.start,
playhead: currentTime,
pxPerSec,
t: edge,
});
const r = snapTime(edge, cands, thresholdS);
if (r.candidate != null) {
if (g.mode === 'move') {
const dur = g.origEnd - g.origStart;
proposed = { start: r.time, end: r.time + dur };
} else if (g.mode === 'start') {
proposed = { ...proposed, start: r.time };
} else {
proposed = { ...proposed, end: r.time };
}
}
}
const clamped = clampSegmentEdit(segments, g.idx, g.mode, proposed, {
allowOverlap: alt,
duration: duration || Infinity,
});
g.last = clamped;
setLive({ id: String(g.sid), ...clamped });
setActiveEdge(g.mode === 'end' ? clamped.end : clamped.start);
},
[pxPerSec, onsets, segments, currentTime, duration],
);
const onBoxPointerUp = useCallback(() => {
const g = gestureRef.current;
gestureRef.current = null;
setActiveEdge(null);
setLive(null);
if (!g || !g.moved || !g.last) return;
if (g.last.start === g.origStart && g.last.end === g.origEnd) return;
// ONE undo entry per drag gesture — live positions never hit the store.
onCommit?.(g.sid, g.last, { undo: true });
const idx = g.idx;
if (g.mode === 'move') {
announce(t('timeline.moved_announce', { index: idx + 1, start: fmt(g.last.start) }));
} else {
announce(
t('timeline.resized_announce', {
index: idx + 1,
start: fmt(g.last.start),
end: fmt(g.last.end),
}),
);
}
}, [onCommit, announce, t]);
// ── Keyboard (roving tabindex, #298 pattern) ────────────────────────────
const moveFocus = useCallback(
(sid, dir) => {
const idx = indexById.get(sid);
if (idx == null) return;
const next = segments[idx + dir];
if (!next) return;
const nid = String(next.id);
selectAndFocus(nid);
ensureVisible(next.start);
// Focus lands via the effect above once the box is mounted; force it if
// the box is already in the window.
const el = boxRefs.current.get(nid);
if (el) el.focus({ preventScroll: true });
},
[indexById, segments, selectAndFocus, ensureVisible],
);
const nudge = useCallback(
(sid, dir, e) => {
if (disabled) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const step = e.ctrlKey || e.metaKey ? KB_STEP_BIG_S : KB_STEP_S;
const delta = dir * step;
let mode;
let proposed;
if (e.altKey) {
mode = 'move';
proposed = { start: s.start + delta, end: s.end + delta };
} else if (e.shiftKey) {
mode = 'end';
proposed = { start: s.start, end: s.end + delta };
} else {
mode = 'start';
proposed = { start: s.start + delta, end: s.end };
}
const clamped = clampSegmentEdit(segments, idx, mode, proposed, {
duration: duration || Infinity,
});
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
// First nudge of the focus session pushes undo; the rest coalesce.
onCommit?.(sid, clamped, { undo: !kbGestureRef.current });
kbGestureRef.current = true;
announce(
t('timeline.resized_announce', {
index: idx + 1,
start: fmt(clamped.start),
end: fmt(clamped.end),
}),
);
},
[disabled, indexById, segments, duration, onCommit, announce, t],
);
const snapFocusedEdge = useCallback(
(sid, useEndEdge) => {
if (disabled || !onsets.length) return;
const idx = indexById.get(sid);
const s = segments[idx];
if (!s) return;
const mode = useEndEdge ? 'end' : 'start';
const target = nearestOnset(useEndEdge ? s.end : s.start, onsets);
if (target == null) return;
const proposed = useEndEdge ? { start: s.start, end: target } : { start: target, end: s.end };
const clamped = clampSegmentEdit(segments, idx, mode, proposed, {
duration: duration || Infinity,
});
if (Math.abs(clamped.start - s.start) < 1e-4 && Math.abs(clamped.end - s.end) < 1e-4) return;
onCommit?.(sid, clamped, { undo: true }); // discrete action = own gesture
announce(t('timeline.snapped_announce', { time: fmt(target) }));
},
[disabled, onsets, indexById, segments, duration, onCommit, announce, t],
);
const handleDelete = useCallback(
(sid) => {
if (disabled) return;
const idx = indexById.get(sid);
if (idx == null) return;
const neighbour = segments[idx + 1] || segments[idx - 1];
onDelete?.(sid);
announce(t('timeline.deleted_announce', { index: idx + 1 }));
if (neighbour) selectAndFocus(String(neighbour.id));
},
[disabled, indexById, segments, onDelete, announce, selectAndFocus, t],
);
const onBoxKeyDown = useCallback(
(e) => {
const sid = e.currentTarget.dataset.segid;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight': {
e.preventDefault();
e.stopPropagation();
const dir = e.key === 'ArrowLeft' ? -1 : 1;
if (editMode) nudge(sid, dir, e);
else moveFocus(sid, dir);
break;
}
case 'Enter':
e.preventDefault();
e.stopPropagation();
if (disabled) break;
if (editMode) {
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
} else {
setEditMode(true);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_on'));
}
break;
case 'Escape':
if (editMode) {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
kbGestureRef.current = false;
announce(t('timeline.edit_mode_off'));
}
break;
case 'Delete':
case 'Backspace':
e.preventDefault();
e.stopPropagation();
handleDelete(sid);
break;
case 's':
case 'S':
e.preventDefault();
e.stopPropagation();
snapFocusedEdge(sid, e.shiftKey);
break;
default:
break;
}
},
[editMode, disabled, nudge, moveFocus, handleDelete, snapFocusedEdge, announce, t],
);
const onBoxBlur = useCallback((e) => {
// Leaving the track ends the keyboard edit session.
const host = hostRef.current;
if (host && !host.contains(e.relatedTarget)) {
setEditMode(false);
kbGestureRef.current = false;
}
}, []);
if (!segments.length || pxPerSec <= 0) return null;
const innerWidth = Math.max(viewWidth, Math.ceil(duration * pxPerSec));
const playheadX = currentTime * pxPerSec - effScroll;
const windowed = effSegments.slice(lo, hi);
return (
<div className={`seg-track ${disabled ? 'is-disabled' : ''}`} ref={hostRef}>
<canvas
ref={canvasRef}
className="seg-track__onsets"
style={{ height: ONSET_STRIP_H }}
aria-hidden="true"
/>
<div
ref={viewportRef}
className={`seg-track__viewport ${selfScroll ? 'seg-track__viewport--scroll' : ''}`}
onScroll={selfScroll ? (e) => setInnerScroll(e.currentTarget.scrollLeft) : undefined}
>
<div
className="seg-track__lane"
role="listbox"
aria-label={t('timeline.track_label')}
aria-orientation="horizontal"
title={t('timeline.keyboard_hint')}
style={{
width: innerWidth,
transform: selfScroll ? undefined : `translateX(${-effScroll}px)`,
}}
>
{windowed.map((s) => {
const sid = String(s.id);
const idx = indexById.get(sid) ?? 0;
const left = s.start * pxPerSec;
const width = Math.max(2, (s.end - s.start) * pxPerSec);
const isSel = selectedId != null && String(selectedId) === sid;
const isFocus = focusId === sid;
const hasOverlap = overlaps.has(sid);
const stale = staleSet.has(sid);
const fresh = !stale && freshSet.has(sid);
const cls = [
'seg-track__box',
isSel && 'is-selected',
isFocus && editMode && 'is-editing',
hasOverlap && 'is-overlap',
stale && 'is-stale',
fresh && 'is-fresh',
live?.id === sid && 'is-dragging',
]
.filter(Boolean)
.join(' ');
return (
<div
key={sid}
ref={(el) => {
if (el) boxRefs.current.set(sid, el);
else boxRefs.current.delete(sid);
}}
role="option"
aria-selected={isSel}
aria-label={t('timeline.segment_aria', {
index: idx + 1,
start: fmt(s.start),
end: fmt(s.end),
})}
tabIndex={isFocus || (focusId == null && idx === 0) ? 0 : -1}
data-segid={sid}
className={cls}
style={{ left, width, background: speakerColor(s, idx) }}
title={hasOverlap ? t('timeline.overlap_warning') : s.text || ''}
onPointerDown={onBoxPointerDown}
onPointerMove={onBoxPointerMove}
onPointerUp={onBoxPointerUp}
onPointerCancel={onBoxPointerUp}
onDoubleClick={() => onPlayRange?.(s.start, s.end)}
onKeyDown={onBoxKeyDown}
onFocus={() => setFocusId(sid)}
onBlur={onBoxBlur}
>
{!disabled && (
<span className="seg-track__handle seg-track__handle--l" data-handle="start" />
)}
<span className="seg-track__label">
{s.text?.length > 32 ? `${s.text.slice(0, 30)}…` : s.text || ''}
</span>
{isSel && !disabled && width > 64 && (
<span className="seg-track__actions">
<button
type="button"
className="seg-track__action-btn"
aria-label={t('timeline.play_slot')}
title={t('timeline.play_slot')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => {
ev.stopPropagation();
onPlayRange?.(s.start, s.end);
}}
>
<Play size={9} />
</button>
{onPreviewSegment && (
<button
type="button"
className="seg-track__action-btn"
aria-label={t('timeline.preview_dub')}
title={t('timeline.preview_dub')}
onPointerDown={(ev) => ev.stopPropagation()}
onClick={(ev) => {
ev.stopPropagation();
onPreviewSegment(s);
}}
>
<Headphones size={9} />
</button>
)}
</span>
)}
{!disabled && (
<span className="seg-track__handle seg-track__handle--r" data-handle="end" />
)}
</div>
);
})}
</div>
{playheadX >= 0 && playheadX <= viewWidth && (
<div className="seg-track__playhead" style={{ left: playheadX }} aria-hidden="true" />
)}
</div>
<div className="seg-track__sr-announce" aria-live="polite" role="status">
{announceMsg}
</div>
</div>
);
}