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:
2026-10-10 15:35:21 +02:00
parent b4bf71ea64
commit c489b60c8b
18 changed files with 607 additions and 121 deletions
@@ -107,6 +107,19 @@ describe("CropFrame", () => {
expect(rect()).toBe("0.300 0.300 0.400 0.400");
});
it("follows the pointer that took it, not a second finger landing on it", () => {
render(<Harness />);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300, pointerId: 1 });
// A second finger lands 200 px away and stays: it must not become the drag's origin.
fireEvent.pointerDown(frame, { button: 0, clientX: 700, clientY: 300, pointerId: 2 });
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390, pointerId: 1 });
expect(rect()).toBe("0.300 0.300 0.400 0.400");
fireEvent.pointerMove(frame, { clientX: 900, clientY: 300, pointerId: 2 });
expect(rect()).toBe("0.300 0.300 0.400 0.400");
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390, pointerId: 1 });
});
it("takes the focus on a press, so the arrows go on from where the pointer left it", () => {
render(<Harness />);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
@@ -116,6 +129,7 @@ describe("CropFrame", () => {
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 });
expect(document.activeElement).toBe(se);
fireEvent.pointerUp(se, { clientX: 960, clientY: 540 });
// An edge is the pointer's only: its press puts the focus on the frame it belongs to.
fireEvent.pointerDown(document.querySelector<HTMLElement>(".cursor-ew-resize")!, {
button: 0,
+9 -4
View File
@@ -75,7 +75,9 @@ const SMALL_HANDLE_BOX: Record<CropHandle, string> = {
// Below this many px on its shorter side a frame takes the small handles.
const SMALL_FRAME_PX = 64;
type Drag = { handle: CropHandle | "move"; x: number; y: number; from: Frac };
// A drag is its pointer's: a second finger on the frame neither restarts it from where it landed nor
// moves it (two origins at once made the frame jump between them).
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
@@ -106,6 +108,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
e.stopPropagation();
e.preventDefault();
if (drag) return;
// preventDefault also stops the press from focusing: focused by hand, so the arrows go on from
// here (an edge is the pointer's only, so its frame takes the focus).
e.currentTarget.closest<HTMLElement>("[tabindex]")?.focus({ preventScroll: true });
@@ -114,10 +117,10 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
}
setDrag({ handle, x: e.clientX, y: e.clientY, from: rect });
setDrag({ handle, pointer: e.pointerId, x: e.clientX, y: e.clientY, from: rect });
};
const follow = (e: PointerEvent<HTMLElement>) => {
if (!drag || !pic) return;
if (!drag || drag.pointer !== e.pointerId || !pic) return;
const dx = (e.clientX - drag.x) / pic.width;
const dy = (e.clientY - drag.y) / pic.height;
onChange(
@@ -126,7 +129,9 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
);
};
const end = () => setDrag(null);
const end = (e: PointerEvent<HTMLElement>) => {
if (drag?.pointer === e.pointerId) setDrag(null);
};
const pointer = (handle: Drag["handle"]) => ({
onPointerDown: begin(handle),
onPointerMove: follow,
+1 -1
View File
@@ -38,7 +38,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays, marked invalid), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
@@ -110,6 +110,60 @@ describe("RangeTrack", () => {
expect(onSeek).not.toHaveBeenCalled();
});
it("reports a drag the browser took away (a cancel) as final where it stopped", () => {
// A caller that acts on the final move only (the Plex seek, the clip strip's window) was left
// with a not-final one for good.
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
fireEvent.pointerCancel(s, { pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, true);
});
it("follows the pointer that took a handle, not a second finger", () => {
const a = handle({ id: "in", label: "In", time: 30 });
const b = handle({ id: "out", label: "Out", time: 70 });
render(<RangeTrack duration={100} label="Clip" handles={[a, b]} />);
box(screen.getByRole("group", { name: "Clip" }));
const inH = screen.getByRole("slider", { name: "In" });
const outH = screen.getByRole("slider", { name: "Out" });
fireEvent.pointerDown(inH, { button: 0, clientX: 300, pointerId: 1 });
fireEvent.pointerDown(outH, { button: 0, clientX: 700, pointerId: 2 });
fireEvent.pointerMove(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, false);
fireEvent.pointerMove(outH, { clientX: 800, pointerId: 2 });
expect(b.onChange).not.toHaveBeenCalled();
fireEvent.pointerUp(inH, { clientX: 400, pointerId: 1 });
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
});
it("puts a handle's adornment beside its slider, not inside it, where a button stays a button", () => {
const h = handle({ adornment: <button>Remove</button> });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const button = screen.getByRole("button", { name: "Remove" });
expect(screen.getByRole("slider", { name: "In" }).contains(button)).toBe(false);
// The slider's arrows are not the button's.
fireEvent.keyDown(button, { key: "ArrowLeft" });
expect(h.onChange).not.toHaveBeenCalled();
});
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => {
render(
<RangeTrack
duration={100}
label="Clip"
handles={[handle()]}
handleRingClassName="focus-visible:outline-warn"
/>,
);
expect(screen.getByRole("slider", { name: "In" }).className).toContain(
"focus-visible:outline-warn",
);
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
+59 -21
View File
@@ -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>
);
@@ -0,0 +1,58 @@
import { useState, type ComponentProps } from "react";
import { formatTimecode, parseTimecode } from "../../lib/timecode";
import { Input } from "./Input";
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
// stays, marked invalid, for fixing rather than vanishing or seeming taken. One element throughout,
// so Enter leaves the focus in the field (a field re-created per value dropped it on <body>).
type Props = Omit<
ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange" | "invalid"
> & {
/** Seconds. */
value: number;
/** Show hours (pass `duration >= 3600`, so one item's fields agree). */
hours?: boolean;
/** A readable time was typed (not called when it equals `value`). */
onCommit: (seconds: number) => void;
};
export function TimecodeField({
value,
hours = false,
onCommit,
onBlur,
onKeyDown,
...rest
}: Props) {
const [draft, setDraft] = useState<string | null>(null);
const invalid = draft != null && parseTimecode(draft) == null;
const commit = () => {
if (draft == null) return;
const v = parseTimecode(draft);
if (v == null) return;
setDraft(null);
if (Math.abs(v - value) > 1e-3) onCommit(v);
};
return (
<Input
{...rest}
value={draft ?? formatTimecode(value, hours)}
invalid={invalid}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
}}
/>
);
}