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:
@@ -33,8 +33,16 @@ beforeAll(() => {
|
|||||||
|
|
||||||
const WIDE = 16 / 9;
|
const WIDE = 16 / 9;
|
||||||
|
|
||||||
function Harness({ aspect = WIDE, ratio = null }: { aspect?: number; ratio?: number | null }) {
|
function Harness({
|
||||||
const [rect, setRect] = useState<Frac>({ x: 0.2, y: 0.2, w: 0.4, h: 0.4 });
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<output data-testid="rect">
|
<output data-testid="rect">
|
||||||
@@ -117,6 +125,20 @@ describe("CropFrame", () => {
|
|||||||
expect(document.activeElement).toBe(frame);
|
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", () => {
|
it("lets a press outside the frame through to the player, and keeps one on the frame", () => {
|
||||||
const click = vi.fn();
|
const click = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ const HANDLE_BOX: Record<CropHandle, string> = {
|
|||||||
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
|
e: "inset-y-3 -right-2 w-4 cursor-ew-resize",
|
||||||
w: "inset-y-3 -left-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 };
|
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
|
// 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 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>) => {
|
const begin = (handle: Drag["handle"]) => (e: PointerEvent<HTMLElement>) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -180,7 +196,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={clsx("absolute touch-none", HANDLE_BOX[h])}
|
className={clsx("absolute touch-none", handleBox[h])}
|
||||||
{...pointer(h)}
|
{...pointer(h)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -193,13 +209,15 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
|||||||
data-crop={h}
|
data-crop={h}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute flex touch-none items-center justify-center rounded-xs",
|
"absolute flex touch-none items-center justify-center rounded-xs",
|
||||||
HANDLE_BOX[h],
|
handleBox[h],
|
||||||
FOCUS,
|
FOCUS,
|
||||||
)}
|
)}
|
||||||
onKeyDown={keys(h)}
|
onKeyDown={keys(h)}
|
||||||
{...pointer(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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user