Files
siftlode/frontend/src/components/ui/CropFrame.dom.test.tsx
T
peter c489b60c8b 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.
2026-10-10 15:35:21 +02:00

168 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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,
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">
{[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("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" });
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300 });
fireEvent.pointerUp(frame, { clientX: 500, clientY: 300 });
expect(document.activeElement).toBe(frame);
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,
clientX: 960,
clientY: 400,
});
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(
<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();
});
});