fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item neither closes the player nor steps the queue, and Plex's auto-skip waits. - Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the clip; Back under a dialog is the dialog's; no range opens while the length is unknown. - YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps its name, Loop and focus when the view is maximised or restored under it. - A typed time stays in its field until taken, then shows where it landed; unreadable stays marked invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too). - RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's. - The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the cut's hit area.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
@@ -18,8 +19,11 @@ import clsx from "clsx";
|
||||
// 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. A player whose
|
||||
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
|
||||
// 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;
|
||||
@@ -42,7 +46,9 @@ export interface TrackHandle {
|
||||
onChange: (time: number, final: boolean) => void;
|
||||
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
||||
barClassName?: string;
|
||||
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
|
||||
/** Drawn at the handle, e.g. a remove button; it should stop its own pointer events. Beside the
|
||||
* slider, not in it: a slider's content is not a control to assistive tech, and its keys are the
|
||||
* slider's. */
|
||||
adornment?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -74,6 +80,9 @@ 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));
|
||||
@@ -98,6 +107,7 @@ 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);
|
||||
@@ -105,8 +115,11 @@ 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<string | null>(null);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
const [dragging, setDragging] = useState<{ id: string; pointer: number } | 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;
|
||||
|
||||
const capture = (e: PointerEvent<HTMLElement>) => {
|
||||
try {
|
||||
@@ -123,22 +136,25 @@ export function RangeTrack({
|
||||
aria-label={label}
|
||||
className={clsx("relative touch-none select-none overflow-hidden", className)}
|
||||
onPointerDown={(e) => {
|
||||
if (!onSeek || e.button !== 0) return;
|
||||
if (!onSeek || e.button !== 0 || busy) return;
|
||||
capture(e);
|
||||
setScrubbing(true);
|
||||
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) onSeek?.(time, false);
|
||||
if (scrubbing === e.pointerId) onSeek?.(time, false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!scrubbing) return;
|
||||
setScrubbing(false);
|
||||
if (scrubbing !== e.pointerId) return;
|
||||
setScrubbing(null);
|
||||
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setScrubbing(false)}
|
||||
// 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}
|
||||
@@ -171,7 +187,12 @@ export function RangeTrack({
|
||||
{handles.map((h) => {
|
||||
const f = frac(h.time);
|
||||
if (f < 0 || f > 1) return null;
|
||||
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
|
||||
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> = {
|
||||
@@ -192,7 +213,7 @@ export function RangeTrack({
|
||||
e.stopPropagation();
|
||||
move(next, true);
|
||||
};
|
||||
return (
|
||||
return [
|
||||
<div
|
||||
key={h.id}
|
||||
role="slider"
|
||||
@@ -204,30 +225,47 @@ export function RangeTrack({
|
||||
aria-valuetext={h.valueText}
|
||||
data-handle={h.id}
|
||||
// The ring sits inside the hit area: the track clips its overflow.
|
||||
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"
|
||||
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,
|
||||
)}
|
||||
style={{ left: pct(f) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
capture(e);
|
||||
setDragging(h.id);
|
||||
lastRef.current = h.time;
|
||||
setDragging({ id: h.id, pointer: e.pointerId });
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (dragging !== h.id) return;
|
||||
if (!mine(e)) return;
|
||||
e.stopPropagation();
|
||||
setDragging(null);
|
||||
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setDragging(null)}
|
||||
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")} />
|
||||
{h.adornment}
|
||||
</div>
|
||||
);
|
||||
</div>,
|
||||
h.adornment != null && (
|
||||
<div
|
||||
key={`${h.id}-adornment`}
|
||||
className="pointer-events-none absolute inset-y-0 z-base w-6 -translate-x-1/2"
|
||||
style={{ left: pct(f) }}
|
||||
>
|
||||
{h.adornment}
|
||||
</div>
|
||||
),
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user