feat(clip): crop a clip to a frame drawn on the picture, and give the clip panel the room it needs

An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1;
the whole picture stays the default). The frame is sent in fractions of the box it was drawn in,
with that box's shape; the worker fits the real picture into the box by its display aspect (sample
aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A
frame covering the whole box is no crop at all, and one off the picture is refused by name.

The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable.
YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel
docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
This commit is contained in:
2026-10-10 12:52:59 +02:00
parent e301632765
commit 520b0afb90
24 changed files with 1260 additions and 153 deletions
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
//
// The crop frame: it sits on the picture, not on the box's bars; the arrows move it and, on a focused
// corner, resize it, and the player underneath never sees those arrows; a drag moves it in picture
// fractions; and the layer lets every press outside the frame through. jsdom lays nothing out, so the
// box's size is stubbed.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import { CropFrame } from "./CropFrame";
import type { Frac } from "../../lib/cropGeometry";
afterEach(cleanup);
beforeAll(() => {
class PointerEventStub extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
// A 1600×900 box for every element: the frame's layer measures its own.
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 1600 });
Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: 900 });
});
const WIDE = 16 / 9;
function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) {
const [rect, setRect] = useState<Frac>({ x: 0.2, y: 0.2, w: 0.4, h: 0.4 });
return (
<div>
<output data-testid="rect">
{[rect.x, rect.y, rect.w, rect.h].map((v) => v.toFixed(3)).join(" ")}
</output>
<CropFrame aspect={aspect} rect={rect} ratio={ratio} onChange={setRect} />
</div>
);
}
const rect = () => screen.getByTestId("rect").textContent;
const picture = () => screen.getByRole("group", { name: "common.cropFrame.frame" }).parentElement!;
describe("CropFrame", () => {
it("lies on the picture, between a 4:3 picture's bars in a 16:9 box", () => {
render(<Harness aspect={4 / 3} />);
const p = picture();
expect([p.style.left, p.style.top, p.style.width, p.style.height]).toEqual([
"200px",
"0px",
"1200px",
"900px",
]);
});
it("moves with the arrows, further with Shift, and keeps them from the player", () => {
const player = vi.fn();
render(
<div onKeyDown={player}>
<Harness />
</div>,
);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
fireEvent.keyDown(frame, { key: "ArrowRight" });
expect(rect()).toBe("0.210 0.200 0.400 0.400");
fireEvent.keyDown(frame, { key: "ArrowUp", shiftKey: true });
expect(rect()).toBe("0.210 0.100 0.400 0.400");
expect(player).not.toHaveBeenCalled();
// Not an arrow: the player's (Escape steps out of the crop there).
fireEvent.keyDown(frame, { key: "Escape" });
expect(player).toHaveBeenCalledTimes(1);
});
it("resizes from a focused corner, the opposite corner staying put", () => {
render(<Harness />);
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
fireEvent.keyDown(se, { key: "ArrowRight", shiftKey: true });
fireEvent.keyDown(se, { key: "ArrowDown" });
expect(rect()).toBe("0.200 0.200 0.500 0.410");
const nw = screen.getByRole("group", { name: "common.cropFrame.nw" });
fireEvent.keyDown(nw, { key: "ArrowLeft" });
expect(rect()).toBe("0.190 0.200 0.510 0.410");
});
it("moves with a drag, in fractions of the picture", () => {
render(<Harness />);
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 });
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390 });
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390 });
// 160 px of 1600 and 90 px of 900: a tenth each way.
expect(rect()).toBe("0.300 0.300 0.400 0.400");
});
it("lets a press outside the frame through to the player, and keeps one on the frame", () => {
const click = vi.fn();
render(
<div onClick={click}>
<Harness />
</div>,
);
expect(screen.getByTestId("crop-frame").className).toContain("pointer-events-none");
fireEvent.click(screen.getByRole("group", { name: "common.cropFrame.frame" }));
expect(click).not.toHaveBeenCalled();
});
});
+207
View File
@@ -0,0 +1,207 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
} from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
contentRect,
moveCrop,
resizeCrop,
type CropHandle,
type Frac,
} from "../../lib/cropGeometry";
// A crop frame over a picture: drawn on the picture itself, not on the box it is shown in (a
// letterboxed video leaves bars there), in fractions of it. The frame is dragged to move, its edges
// and corners to resize; from the keyboard the frame moves with the arrows and each corner, focused,
// resizes with them (Shift for a bigger step). Outside the frame the picture is dimmed. The frame and
// its corners are groups, not buttons: nothing happens on Enter, and the name says what the arrows do.
//
// It fills its positioned parent and measures it, so the caller only places it over the picture's
// box (a <video>'s, an iframe's) and says the picture's shape. The layer itself lets pointers
// through: only the frame and its handles take them, so a press elsewhere on the picture is still
// the player's (play / pause).
type Corner = "nw" | "ne" | "sw" | "se";
interface Props {
/** The picture's shape as shown (width / height). */
aspect: number;
rect: Frac;
/** A shape lock in picture fractions (cropGeometry.fracRatio), or null for a free frame. */
ratio: number | null;
onChange: (r: Frac) => void;
className?: string;
}
const STEP = 0.01;
const BIG_STEP = 0.1;
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
const CORNERS: Corner[] = ["nw", "ne", "sw", "se"];
const EDGES: CropHandle[] = ["n", "s", "e", "w"];
// Each handle's hit area, centred on its edge or corner (the frame's own border is 2 px).
const HANDLE_BOX: Record<CropHandle, string> = {
nw: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize",
ne: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize",
sw: "-left-2.5 -bottom-2.5 size-5 cursor-nesw-resize",
se: "-right-2.5 -bottom-2.5 size-5 cursor-nwse-resize",
n: "inset-x-3 -top-2 h-4 cursor-ns-resize",
s: "inset-x-3 -bottom-2 h-4 cursor-ns-resize",
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
w: "inset-y-3 -left-2 w-4 cursor-ew-resize",
};
type Drag = { handle: CropHandle | "move"; 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";
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
const { t } = useTranslation();
const boxRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ w: 0, h: 0 });
const [drag, setDrag] = useState<Drag | null>(null);
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight });
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const pic = contentRect(box.w, box.h, aspect);
const begin = (handle: Drag["handle"]) => (e: PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
e.stopPropagation();
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} 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 });
};
const follow = (e: PointerEvent<HTMLElement>) => {
if (!drag || !pic) return;
const dx = (e.clientX - drag.x) / pic.width;
const dy = (e.clientY - drag.y) / pic.height;
onChange(
drag.handle === "move"
? moveCrop(drag.from, dx, dy)
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
);
};
const end = () => setDrag(null);
const pointer = (handle: Drag["handle"]) => ({
onPointerDown: begin(handle),
onPointerMove: follow,
onPointerUp: end,
onPointerCancel: end,
// A click after the drag is the frame's too.
onClick: (e: MouseEvent<HTMLElement>) => e.stopPropagation(),
});
const keys = (handle: CropHandle | "move") => (e: KeyboardEvent<HTMLElement>) => {
const dir = ARROWS[e.key];
if (!dir || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek or change the volume on the arrow.
e.preventDefault();
e.stopPropagation();
const step = e.shiftKey ? BIG_STEP : STEP;
const [dx, dy] = [dir[0] * step, dir[1] * step];
onChange(handle === "move" ? moveCrop(rect, dx, dy) : resizeCrop(rect, handle, dx, dy, ratio));
};
const pct = (v: number) => `${(v * 100).toFixed(3)}%`;
return (
<div
ref={boxRef}
data-testid="crop-frame"
className={clsx("pointer-events-none absolute inset-0 overflow-hidden", className)}
>
{pic && (
<div
className="absolute"
style={{ left: pic.left, top: pic.top, width: pic.width, height: pic.height }}
>
<div
role="group"
tabIndex={0}
aria-label={t("common.cropFrame.frame")}
data-crop="frame"
className={clsx(
"pointer-events-auto absolute cursor-move touch-none border-2 border-white",
FOCUS,
)}
style={{
left: pct(rect.x),
top: pct(rect.y),
width: pct(rect.w),
height: pct(rect.h),
// The dim outside the frame, as far as the picture goes (the layer clips it).
boxShadow: "0 0 0 100vmax rgb(0 0 0 / 0.55)",
}}
onKeyDown={keys("move")}
{...pointer("move")}
>
{drag &&
[1, 2].map((i) => (
<div key={i} aria-hidden="true">
<div
className="absolute inset-y-0 w-px bg-white/40"
style={{ left: `${(i * 100) / 3}%` }}
/>
<div
className="absolute inset-x-0 h-px bg-white/40"
style={{ top: `${(i * 100) / 3}%` }}
/>
</div>
))}
{EDGES.map((h) => (
<div
key={h}
aria-hidden="true"
className={clsx("absolute touch-none", HANDLE_BOX[h])}
{...pointer(h)}
/>
))}
{CORNERS.map((h) => (
<div
key={h}
role="group"
tabIndex={0}
aria-label={t(`common.cropFrame.${h}`)}
data-crop={h}
className={clsx(
"absolute flex touch-none items-center justify-center rounded-xs",
HANDLE_BOX[h],
FOCUS,
)}
onKeyDown={keys(h)}
{...pointer(h)}
>
<span className="size-2.5 rounded-xs bg-white shadow" />
</div>
))}
</div>
</div>
)}
</div>
);
}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| 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` |
| 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) |
| 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` |
| 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` |
| 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` |
## The size contract (E10 S1c)