feat(e10): one keyboard cheat sheet for the whole app
The Plex player had a shortcuts overlay nobody else did, so the YouTube player's Space/F/arrows and the Plex browser's Backspace were documented nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a global `h` - not the conventional `?`, which is a Shift chord on a Hungarian layout, while `h` is the key this app's reader already has in their fingers from the player. It renders the shared table filtered to the scopes that are LIVE: a surface registers its scope with useKeyScope while mounted, and only while its keys really do something - PlexBrowse deactivates its own scope inside the player, where its Backspace no-ops, so the sheet cannot promise a "Back one level" the handler declines to perform. Same rule for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer the player publishes because the sheet cannot ask. No new overlay primitive, after checking: Modal already portals into document.fullscreenElement ?? document.body (so this works over a fullscreen player), registers an Escape layer, traps focus and handles browser Back. Third time this epic that the thing was already built. Shift+F10 is in the table as `handledLocally` - documented and sharing the chord parser, but matched by useContextMenu on the focused row. dispatch neither routes it nor demands a handler for it, so the exhaustiveness check stays honest about what it actually covers. Also: A/S have cycled the Plex audio and subtitle tracks since E5, but silently - the only way to see where you landed was to open the tracks menu, the exact round-trip the shortcut exists to avoid. cycleAudio and cycleSubtitle now return the track they moved to, and the player flashes it over the video.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the app-wide cheat sheet: `h` opens it, it lists the SCOPES that are live (innermost
|
||||
// first, global last), and it hides a `when` row whose condition the surface has not published.
|
||||
// i18n is key-passthrough, so the asserted text is the i18n key.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import KeyboardHelp from "./KeyboardHelp";
|
||||
import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
// The open flag is module state shared by the whole app (one sheet, opened from anywhere), so a
|
||||
// test that leaves it open would flip the NEXT test's toggle into a close.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
|
||||
function Surface({ episode }: { episode?: boolean }) {
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: !!episode });
|
||||
return null;
|
||||
}
|
||||
|
||||
const pressH = () =>
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "h" });
|
||||
});
|
||||
|
||||
const descriptions = () => screen.getAllByRole("definition").map((el) => el.textContent);
|
||||
|
||||
describe("KeyboardHelp", () => {
|
||||
it("opens and closes on the global h", () => {
|
||||
render(<KeyboardHelp />);
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
pressH();
|
||||
expect(screen.getByText("keys.title")).toBeTruthy();
|
||||
pressH();
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open while a field has focus", () => {
|
||||
render(<KeyboardHelp />);
|
||||
const input = document.createElement("input");
|
||||
document.body.append(input);
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "h" });
|
||||
});
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("leads with the live surface and trails with the app-wide keys", () => {
|
||||
render(
|
||||
<>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<KeyboardHelp />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
// "Previous / next episode" inside a movie would be a promise the player does not keep.
|
||||
it("hides a conditional row until its surface says it applies", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).not.toContain("keys.episode");
|
||||
unmount();
|
||||
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
|
||||
|
||||
render(
|
||||
<>
|
||||
<Surface episode />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.episode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
dispatch,
|
||||
formatChord,
|
||||
helpLines,
|
||||
toggleKeyboardHelp,
|
||||
useActiveScopes,
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
|
||||
//
|
||||
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
|
||||
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
|
||||
// answer to "what can I press here" and nothing to keep in sync. Before this, the Plex player had a
|
||||
// cheat sheet nobody else did, listing an array hand-copied from its own handler.
|
||||
//
|
||||
// `h` rather than the conventional `?`: on a Hungarian layout `?` is a Shift chord, and `h` is the
|
||||
// key this app's reader already has in their fingers from the player.
|
||||
//
|
||||
// Deliberately NOT a new overlay primitive: `Modal` already portals into
|
||||
// `document.fullscreenElement ?? document.body` (so this works over a fullscreen player), registers
|
||||
// a layer so Escape closes it before the player underneath, traps focus and handles browser Back.
|
||||
export default function KeyboardHelp() {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardHelpOpen();
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "global", {
|
||||
help: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleKeyboardHelp();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||
// a promise the player does not keep.
|
||||
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
|
||||
return (
|
||||
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{lines.map((line) => (
|
||||
<Fragment key={line.labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{line.chords.map((chord) => (
|
||||
<kbd
|
||||
key={chord}
|
||||
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-muted">{t(line.labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { dispatch } from "../../lib/keymap";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
|
||||
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
|
||||
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
|
||||
@@ -19,6 +19,7 @@ export default function UndoToolbar({
|
||||
onRedo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useKeyScope("undo");
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
|
||||
Reference in New Issue
Block a user