fix(ui): a small crop frame takes smaller handles, so it can still be grabbed to move

The handles reach 10 px into the frame from every side; a frame at its minimum on a small picture
(the Downloads editor at 800 px: 41 x 23) was then nearly all handle - 74 of its 920 px still moved
it. Below 64 px on its shorter side the frame takes 12 px corners and 8 px edges: 262 px move it.
This commit is contained in:
2026-10-10 13:54:04 +02:00
parent 0a16bc40ae
commit 72df11f2c0
2 changed files with 45 additions and 5 deletions
@@ -33,8 +33,16 @@ beforeAll(() => {
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 });
function Harness({
aspect = WIDE,
ratio = null,
start = { x: 0.2, y: 0.2, w: 0.4, h: 0.4 },
}: {
aspect?: number;
ratio?: number | null;
start?: Frac;
}) {
const [rect, setRect] = useState<Frac>(start);
return (
<div>
<output data-testid="rect">
@@ -117,6 +125,20 @@ describe("CropFrame", () => {
expect(document.activeElement).toBe(frame);
});
it("shrinks its handles on a small frame, so the frame itself stays there to grab", () => {
const corner = () => screen.getByRole("group", { name: "common.cropFrame.se" });
const edge = () => document.querySelector<HTMLElement>(".cursor-ew-resize")!;
// 640 × 360 px of a 1600 × 900 picture: the full handles.
render(<Harness />);
expect(corner().className).toContain("size-5");
expect(edge().className).toContain("w-4");
cleanup();
// A 1:4 picture in that box is 225 px wide: a tenth of it is a 22 px frame.
render(<Harness aspect={0.25} start={{ x: 0.2, y: 0.2, w: 0.1, h: 0.5 }} />);
expect(corner().className).toContain("size-3");
expect(edge().className).toContain("w-2");
});
it("lets a press outside the frame through to the player, and keeps one on the frame", () => {
const click = vi.fn();
render(
+21 -3
View File
@@ -60,6 +60,20 @@ const HANDLE_BOX: Record<CropHandle, string> = {
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
w: "inset-y-3 -left-2 w-4 cursor-ew-resize",
};
// The same on a small frame: the full handles reach 10 px into it from every side, and a frame at its
// minimum on a small picture (40 × 23 px) was then nearly all handle, with no room left to grab it.
const SMALL_HANDLE_BOX: Record<CropHandle, string> = {
nw: "-left-1.5 -top-1.5 size-3 cursor-nwse-resize",
ne: "-right-1.5 -top-1.5 size-3 cursor-nesw-resize",
sw: "-left-1.5 -bottom-1.5 size-3 cursor-nesw-resize",
se: "-right-1.5 -bottom-1.5 size-3 cursor-nwse-resize",
n: "inset-x-1.5 -top-1 h-2 cursor-ns-resize",
s: "inset-x-1.5 -bottom-1 h-2 cursor-ns-resize",
e: "inset-y-1.5 -right-1 w-2 cursor-ew-resize",
w: "inset-y-1.5 -left-1 w-2 cursor-ew-resize",
};
// 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 };
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
@@ -84,6 +98,8 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
}, []);
const pic = contentRect(box.w, box.h, aspect);
const small = !!pic && Math.min(rect.w * pic.width, rect.h * pic.height) < SMALL_FRAME_PX;
const handleBox = small ? SMALL_HANDLE_BOX : HANDLE_BOX;
const begin = (handle: Drag["handle"]) => (e: PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
@@ -180,7 +196,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
<div
key={h}
aria-hidden="true"
className={clsx("absolute touch-none", HANDLE_BOX[h])}
className={clsx("absolute touch-none", handleBox[h])}
{...pointer(h)}
/>
))}
@@ -193,13 +209,15 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
data-crop={h}
className={clsx(
"absolute flex touch-none items-center justify-center rounded-xs",
HANDLE_BOX[h],
handleBox[h],
FOCUS,
)}
onKeyDown={keys(h)}
{...pointer(h)}
>
<span className="size-2.5 rounded-xs bg-white shadow" />
<span
className={clsx("rounded-xs bg-white shadow", small ? "size-1.5" : "size-2.5")}
/>
</div>
))}
</div>