Files
siftlode/frontend/src/components/ui/RangeTrack.tsx
T
peter 977ba244fa fix(clip): a time's error line sits in the flow, the remove button off the cut, and every player surface rings amber
- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role
  alert) in its own flow under the fields' row: it no longer lies over the crop row taking its
  clicks, runs out of the editor's card, or joins the field's <label> name.
- The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the
  cut's grab area, where a press drags it; RangeTrack's adornment slot is gone.
- media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every
  bg-media-chrome surface; a contract test holds each such class string to media-focus or
  media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme.
- An upload's quota headline says the converted file does not fit, not "Storage full".
- DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all
  three colours on the chrome.
2026-10-10 21:29:55 +02:00

320 lines
12 KiB
TypeScript

import {
useEffect,
useEffectEvent,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
import { trackFrac, trackPct } from "./trackPosition";
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
//
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
// editors that drew the hit area as thin as the bar were the ones people missed.
//
// Every move reports `final`: false while a pointer drags, true on release or a key — and when the
// browser takes the pointer away mid-drag (a cancel: a system gesture on a tablet), true where it
// stopped. A player whose seek is expensive (a transcode restarts) acts on the final one only; a cheap
// one follows the drag. A drag is its pointer's: a second finger neither takes it over nor starts
// another, so two fingers on the track never fight over one mark.
export interface RangeWindow {
from: number;
to: number;
}
export interface TrackHandle {
id: string;
time: number;
/** The range the handle may move in (seconds); a move past it stops at the edge. */
min: number;
max: number;
/** Accessible name ("In", "Cut 2"). */
label: string;
/** The time as read out (aria-valuetext), in the caller's own format. */
valueText: string;
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
step: number;
bigStep: number;
onChange: (time: number, final: boolean) => void;
/** The drawn bar's look (its colour); the hit area around it is the track's. */
barClassName?: string;
}
interface TrackRegion {
key: string | number;
start: number;
end: number;
className?: string;
style?: CSSProperties;
title?: string;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
children?: ReactNode;
}
interface Props {
/** The media's length; with no `view` the track shows all of it. */
duration: number;
view?: RangeWindow;
playhead?: number | null;
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
onSeek?: (time: number, final: boolean) => void;
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
onHover?: (h: { x: number; time: number } | null) => void;
handles?: readonly TrackHandle[];
regions?: readonly TrackRegion[];
background?: ReactNode;
/** The track's box: its height, surface and edge. */
className?: string;
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** The time under `clientX` on the track that holds `el`. */
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
const track = el.closest("[data-range-track]") ?? el;
const r = track.getBoundingClientRect();
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
return win.from + frac * (win.to - win.from);
}
export function RangeTrack({
duration,
view,
playhead,
onSeek,
onHover,
handles = [],
regions = [],
background,
className,
label,
playheadClassName = "bg-white",
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const frac = (t: number) => trackFrac(t, win);
const pct = trackPct;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<{
id: string;
pointer: number;
el: HTMLElement;
} | null>(null);
const [scrubbing, setScrubbing] = useState<number | null>(null);
// Where the drag last put its handle, for a cancel to report as final.
const lastRef = useRef(0);
const busy = dragging != null || scrubbing != null;
// A handle can go while its pointer is still down: its mark moved out of the window under it (a
// key marked the other end and carried this one along). Its own up / cancel then never come — the
// capture went with the node — and the drag would hold the track `busy` for good (a finger never
// comes back with the same pointer). So the pointer's release anywhere ends it, reported final
// where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs.
const endLostDrag = useEffectEvent((id: string) => {
setDragging(null);
const h = handles.find((x) => x.id === id);
if (h) h.onChange(clamp(h.time, h.min, h.max), true);
});
useEffect(() => {
if (!dragging) return;
const { id, pointer, el } = dragging;
const end = (e: globalThis.PointerEvent) => {
if (e.pointerId === pointer && !el.isConnected) endLostDrag(id);
};
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
return () => {
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
};
}, [dragging]);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
}
};
return (
<div
data-range-track=""
role="group"
aria-label={label}
className={clsx("relative touch-none select-none overflow-hidden", className)}
onPointerDown={(e) => {
if (!onSeek || e.button !== 0 || busy) return;
capture(e);
setScrubbing(e.pointerId);
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerMove={(e) => {
const time = timeAtPointer(e.currentTarget, e.clientX, win);
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
if (scrubbing === e.pointerId) onSeek?.(time, false);
}}
onPointerUp={(e) => {
if (scrubbing !== e.pointerId) return;
setScrubbing(null);
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
// A cancelled scrub seeks no further: where it was taken away is not a place anyone chose.
onPointerCancel={(e) => {
if (scrubbing === e.pointerId) setScrubbing(null);
}}
onPointerLeave={() => onHover?.(null)}
>
{background}
{regions.map((r) => {
const a = clamp(frac(r.start), 0, 1);
const b = clamp(frac(r.end), 0, 1);
if (b <= a && r.end > r.start) return null; // wholly outside the window
return (
<div
key={r.key}
className={clsx("absolute inset-y-0", r.className)}
style={{ left: pct(a), width: pct(b - a), ...r.style }}
title={r.title}
onPointerDown={r.onPointerDown}
>
{r.children}
</div>
);
})}
{playhead != null && playhead >= win.from && playhead <= win.to && (
<div
data-testid="range-playhead"
className={clsx(
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
playheadClassName,
)}
style={{ left: pct(frac(playhead)) }}
/>
)}
{handles.map((h) => {
const f = frac(h.time);
if (f < 0 || f > 1) return null;
const move = (time: number, final: boolean) => {
const t = clamp(time, h.min, h.max);
lastRef.current = t;
h.onChange(t, final);
};
const mine = (e: PointerEvent) => dragging?.id === h.id && dragging.pointer === e.pointerId;
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? h.bigStep : h.step;
const to: Record<string, number> = {
ArrowLeft: h.time - big,
ArrowDown: h.time - big,
ArrowRight: h.time + big,
ArrowUp: h.time + big,
PageDown: h.time - h.bigStep,
PageUp: h.time + h.bigStep,
Home: h.min,
End: h.max,
};
const next = to[e.key];
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
// skips a handled key).
e.preventDefault();
e.stopPropagation();
move(next, true);
};
return (
<div
key={h.id}
role="slider"
tabIndex={0}
aria-label={h.label}
aria-valuemin={Math.round(h.min * 10) / 10}
aria-valuemax={Math.round(h.max * 10) / 10}
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow. Its colour is the base
// focus ring's, which a scope over another ground re-points (media-scope: the media amber).
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
if (busy) return;
capture(e);
lastRef.current = h.time;
setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
}}
onPointerMove={(e) => {
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerUp={(e) => {
if (!mine(e)) return;
e.stopPropagation();
setDragging(null);
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={(e) => {
if (!mine(e)) return;
setDragging(null);
move(lastRef.current, true);
}}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
</div>
);
})}
</div>
);
}
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
* each labelled by `format`. */
export function RulerTicks({
view,
format,
max = 8,
className,
}: {
view: RangeWindow;
format: (t: number) => string;
max?: number;
className?: string;
}) {
const span = Math.max(view.to - view.from, 1e-6);
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
const ticks: number[] = [];
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
return (
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
{ticks.map((t) => (
<div
key={t}
className="absolute bottom-0 h-2 border-l border-current"
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
>
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
{format(t)}
</span>
</div>
))}
</div>
);
}