fix(clip): typed times belong to their value, and every mark on the player chrome can be seen
TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so leaving the field never commits it to another cut; it is invalid only once offered, with a line under it saying how to type a time. On the always-dark chrome media-scope points every focus ring at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under 3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
@@ -80,9 +82,6 @@ interface Props {
|
||||
/** Accessible name of the whole track. */
|
||||
label: string;
|
||||
playheadClassName?: string;
|
||||
/** A focused handle's ring colour (a `focus-visible:outline-*` class) where the theme accent does
|
||||
* not read on the track, e.g. on the dark media chrome. */
|
||||
handleRingClassName?: string;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
@@ -107,7 +106,6 @@ export function RangeTrack({
|
||||
className,
|
||||
label,
|
||||
playheadClassName = "bg-white",
|
||||
handleRingClassName,
|
||||
}: Props) {
|
||||
const win: RangeWindow = view ?? { from: 0, to: duration };
|
||||
const span = Math.max(win.to - win.from, 1e-6);
|
||||
@@ -115,12 +113,40 @@ export function RangeTrack({
|
||||
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
|
||||
// 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 } | null>(null);
|
||||
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);
|
||||
@@ -224,11 +250,9 @@ export function RangeTrack({
|
||||
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.
|
||||
className={clsx(
|
||||
"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",
|
||||
handleRingClassName,
|
||||
)}
|
||||
// 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) => {
|
||||
@@ -237,7 +261,7 @@ export function RangeTrack({
|
||||
if (busy) return;
|
||||
capture(e);
|
||||
lastRef.current = h.time;
|
||||
setDragging({ id: h.id, pointer: e.pointerId });
|
||||
setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
|
||||
Reference in New Issue
Block a user