- 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.
168 lines
6.9 KiB
TypeScript
168 lines
6.9 KiB
TypeScript
// @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();
|
||
});
|
||
});
|