From 72df11f2c0d368f27acca07339b6163bf8b1800d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 13:54:04 +0200 Subject: [PATCH] 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. --- .../src/components/ui/CropFrame.dom.test.tsx | 26 +++++++++++++++++-- frontend/src/components/ui/CropFrame.tsx | 24 ++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ui/CropFrame.dom.test.tsx b/frontend/src/components/ui/CropFrame.dom.test.tsx index a6b6d33c..0c26cf32 100644 --- a/frontend/src/components/ui/CropFrame.dom.test.tsx +++ b/frontend/src/components/ui/CropFrame.dom.test.tsx @@ -33,8 +33,16 @@ beforeAll(() => { const WIDE = 16 / 9; -function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) { - const [rect, setRect] = useState({ 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(start); return (
@@ -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(".cursor-ew-resize")!; + // 640 × 360 px of a 1600 × 900 picture: the full handles. + render(); + 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(); + 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( diff --git a/frontend/src/components/ui/CropFrame.tsx b/frontend/src/components/ui/CropFrame.tsx index c377ccdc..0e64182e 100644 --- a/frontend/src/components/ui/CropFrame.tsx +++ b/frontend/src/components/ui/CropFrame.tsx @@ -60,6 +60,20 @@ const HANDLE_BOX: Record = { 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 = { + 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) => { if (e.button !== 0) return; @@ -180,7 +196,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) { ))}