feat(e10): put every keyboard shortcut in one table

Six surfaces each owned a private keydown listener with its own key
literals and its own idea of "am I typing?", and they had drifted:
PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A
(select all) cycled the audio track, and its typing guard missed
<select> and contentEditable where the other two surfaces covered both.

lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.

Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.

The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
This commit is contained in:
2026-09-05 20:58:17 +02:00
parent 928eac4c89
commit 12822a9e04
10 changed files with 663 additions and 226 deletions
+205
View File
@@ -0,0 +1,205 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import en from "../i18n/locales/en/keys.json";
import hu from "../i18n/locales/hu/keys.json";
import {
SHORTCUTS,
dispatch,
formatChord,
helpLines,
isActivationTarget,
isTypingTarget,
matches,
} from "./keymap";
/** A keydown as it arrives from a real focus target (jsdom only fills `e.target` on dispatch). */
function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent {
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
(on ?? document.body).dispatchEvent(e);
return e;
}
describe("matches", () => {
it("matches a plain letter case-insensitively", () => {
expect(matches(press({ key: "a" }), "a")).toBe(true);
expect(matches(press({ key: "A" }), "a")).toBe(true);
});
// The bug this table was written to kill: `case "a"` fired on Ctrl+A, so select-all cycled the
// Plex audio track. A chord without `Mod` must REJECT a modified key, not ignore the modifier.
it("rejects a modified key for an unmodified chord", () => {
expect(matches(press({ key: "a", ctrlKey: true }), "a")).toBe(false);
expect(matches(press({ key: "a", metaKey: true }), "a")).toBe(false);
expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "ArrowLeft")).toBe(false);
});
it("treats Mod as Ctrl or Cmd", () => {
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
expect(matches(press({ key: "k" }), "Mod+k")).toBe(false);
});
it("distinguishes Shift chords from their bare key", () => {
expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "Shift+ArrowLeft")).toBe(true);
expect(matches(press({ key: "ArrowLeft" }), "Shift+ArrowLeft")).toBe(false);
expect(matches(press({ key: "z", ctrlKey: true, shiftKey: true }), "Mod+Shift+z")).toBe(true);
expect(matches(press({ key: "z", ctrlKey: true }), "Mod+Shift+z")).toBe(false);
});
it("accepts Space by key or by code", () => {
expect(matches(press({ key: " " }), "Space")).toBe(true);
expect(matches(press({ key: "Unidentified", code: "Space" }), "Space")).toBe(true);
});
});
describe("isTypingTarget", () => {
it("covers every field kind, not just input/textarea", () => {
for (const tag of ["input", "textarea", "select"] as const) {
const el = document.createElement(tag);
document.body.append(el);
expect(isTypingTarget(press({ key: "a" }, el))).toBe(true);
el.remove();
}
const div = document.createElement("div");
div.contentEditable = "true";
// jsdom does not implement the contentEditable→isContentEditable reflection.
Object.defineProperty(div, "isContentEditable", { value: true });
document.body.append(div);
expect(isTypingTarget(press({ key: "a" }, div))).toBe(true);
div.remove();
});
it("is false on ordinary content", () => {
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
});
});
describe("isActivationTarget", () => {
it("is true for the controls Space activates natively", () => {
const btn = document.createElement("button");
document.body.append(btn);
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
btn.remove();
expect(isActivationTarget(press({ key: " " }))).toBe(false);
});
});
describe("dispatch", () => {
const noop = () => {};
const plexHandlers = (calls: string[]) =>
({
playPause: () => calls.push("playPause"),
fullscreen: () => calls.push("fullscreen"),
seekBack: () => calls.push("seekBack"),
seekFwd: () => calls.push("seekFwd"),
fineBack: () => calls.push("fineBack"),
fineFwd: () => calls.push("fineFwd"),
prevEpisode: () => calls.push("prevEpisode"),
nextEpisode: () => calls.push("nextEpisode"),
volumeUp: noop,
volumeDown: noop,
mute: noop,
audio: () => calls.push("audio"),
subtitle: noop,
help: noop,
info: noop,
stats: noop,
back: () => calls.push("back"),
escape: () => calls.push("escape"),
}) as const;
it("routes each arrow variant to its own id", () => {
const calls: string[] = [];
const h = plexHandlers(calls);
dispatch(press({ key: "ArrowLeft" }), "plexPlayer", h);
dispatch(press({ key: "ArrowLeft", ctrlKey: true }), "plexPlayer", h);
dispatch(press({ key: "ArrowLeft", shiftKey: true }), "plexPlayer", h);
expect(calls).toEqual(["seekBack", "fineBack", "prevEpisode"]);
});
it("does not fire an ordinary shortcut while typing", () => {
const calls: string[] = [];
const input = document.createElement("input");
document.body.append(input);
expect(dispatch(press({ key: "a" }, input), "plexPlayer", plexHandlers(calls))).toBe(false);
expect(calls).toEqual([]);
input.remove();
});
// Escape is the one opt-in: a player whose Escape died inside its own subtitle-offset field
// would be a trap, so `allowInInput` lets it through where every other key is swallowed.
it("still fires Escape from inside a field", () => {
const calls: string[] = [];
const input = document.createElement("input");
document.body.append(input);
expect(dispatch(press({ key: "Escape" }, input), "plexPlayer", plexHandlers(calls))).toBe(true);
expect(calls).toEqual(["escape"]);
input.remove();
});
it("ignores keys belonging to another scope", () => {
const calls: string[] = [];
// `m` (mute) is a plexPlayer key; the browse scope only owns Backspace.
expect(dispatch(press({ key: "m" }), "plexBrowse", { back: () => calls.push("back") })).toBe(
false,
);
expect(calls).toEqual([]);
});
});
describe("the table itself", () => {
// `dispatch` looks a row's handler up by id, so a duplicate would silently shadow the second one.
it("has no duplicate id within a scope", () => {
const seen = new Set<string>();
for (const s of SHORTCUTS) {
const key = `${s.scope}:${s.id}`;
expect(seen.has(key), `duplicate ${key}`).toBe(false);
seen.add(key);
}
});
// The done-condition of E10 S3: every shortcut in the app is listed in one place AND named in
// both languages. A row whose label is missing would render as a raw i18n key in the help sheet.
it("names every shortcut in both languages", () => {
for (const s of SHORTCUTS) {
const [ns, key] = s.labelKey.split(".");
expect(ns, `${s.id} must live in the keys namespace`).toBe("keys");
expect(en, `en is missing ${s.labelKey}`).toHaveProperty(key!);
expect(hu, `hu is missing ${s.labelKey}`).toHaveProperty(key!);
}
});
});
describe("helpLines", () => {
it("groups rows that share a description into one line", () => {
const back = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.back");
expect(back?.chords).toEqual(["Backspace", "Escape"]);
});
it("carries the episode-only condition through so the sheet can filter it", () => {
const episode = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.episode");
expect(episode?.when).toBe("episode");
expect(helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.mute")?.when).toBeUndefined();
});
it("returns only the requested scopes", () => {
expect(helpLines(["plexBrowse"]).map((l) => l.labelKey)).toEqual(["keys.browseBack"]);
});
// A surface's own keys are what its reader came for; the app-wide ones are context underneath.
it("orders scopes as asked, not as tabled", () => {
const first = (scopes: Parameters<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
expect(first(["global", "plexBrowse"])).toBe("keys.contextMenu");
});
});
describe("formatChord", () => {
it("renders glyphs and upper-cases the letter", () => {
expect(formatChord("ArrowLeft")).toBe("←");
expect(formatChord("Backspace")).toBe("⌫");
expect(formatChord("Shift+ArrowRight")).toBe("⇧ →");
expect(formatChord("k")).toBe("K");
expect(formatChord("Space")).toBe("Space");
});
});
+238
View File
@@ -0,0 +1,238 @@
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
// its own idea of "am I typing?" — and they had drifted: `PlexPlayer` matched `case "a"` without
// checking modifiers (so Ctrl+A, select-all, cycled the audio track) and its typing guard missed
// `<select>` and `contentEditable`, which the other two surfaces already covered. The classic
// comment-vs-code drift this codebase keeps getting bitten by, one layer up.
//
// The fix is a TABLE, not a dispatcher. Each surface keeps its own listener and its own closures —
// `PlexPlayer`'s 1300 lines of refs are not worth re-plumbing — but the key literals, the modifier
// rules and the typing guard all live here, and `dispatch()` binds a scope's rows to handlers with
// a mapped type. So a row without a handler (or a handler without a row) is a **tsc error**, not a
// comment someone forgets: the parity is enforced by the gate, which is the house rule.
//
// It is also the source for the keyboard help sheet — every shortcut in the app is listed here, in
// one place, and the sheet renders this table rather than a hand-copied array beside it.
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
* is mounted. The help sheet shows `global` plus the topmost registered scope. */
export type KeyScope = "global" | "undo" | "ytPlayer" | "plexPlayer" | "plexBrowse";
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
readonly id: string;
readonly scope: KeyScope;
/** Chords that trigger it, in the `matches()` mini-syntax. All are listed in the help sheet. */
readonly keys: readonly string[];
/** i18n key (the `keys` namespace) for the description. Rows SHARING a labelKey are shown as one
* help line with their chords side by side — that is how "← → Seek" stays a single row. */
readonly labelKey: string;
readonly when?: ShortcutWhen;
/** Fires even while a text field has focus. Escape only: it is the universal "get me out of here",
* and a player whose Escape died inside its own subtitle-offset input is a trap. Every other
* shortcut stays out of the way of typing. */
readonly allowInInput?: boolean;
}
// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final
// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a
// focused button reports `key === " "` inconsistently across engines).
export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
{ id: "contextMenu", scope: "global", keys: ["Shift+F10"], labelKey: "keys.contextMenu" },
// --- undo/redo (wherever an UndoToolbar is mounted) -----------------------------------------
{ id: "undo", scope: "undo", keys: ["Mod+z"], labelKey: "keys.undo" },
{ id: "redo", scope: "undo", keys: ["Mod+y", "Mod+Shift+z"], labelKey: "keys.redo" },
// --- the YouTube player modal ---------------------------------------------------------------
{ id: "playPause", scope: "ytPlayer", keys: ["Space"], labelKey: "keys.playPause" },
{ id: "fullscreen", scope: "ytPlayer", keys: ["f"], labelKey: "keys.fullscreen" },
{ id: "seekBack", scope: "ytPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "ytPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
{ id: "prevVideo", scope: "ytPlayer", keys: ["Shift+ArrowLeft"], labelKey: "keys.video" },
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
{
id: "close",
scope: "ytPlayer",
keys: ["Escape"],
labelKey: "keys.closePlayer",
allowInInput: true,
},
// --- the Plex player ------------------------------------------------------------------------
{ id: "playPause", scope: "plexPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "fullscreen", scope: "plexPlayer", keys: ["f"], labelKey: "keys.fullscreen" },
{ id: "seekBack", scope: "plexPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek" },
{ id: "seekFwd", scope: "plexPlayer", keys: ["ArrowRight"], labelKey: "keys.seek" },
{ id: "fineBack", scope: "plexPlayer", keys: ["Mod+ArrowLeft"], labelKey: "keys.fineSeek" },
{ id: "fineFwd", scope: "plexPlayer", keys: ["Mod+ArrowRight"], labelKey: "keys.fineSeek" },
{
id: "prevEpisode",
scope: "plexPlayer",
keys: ["Shift+ArrowLeft"],
labelKey: "keys.episode",
when: "episode",
},
{
id: "nextEpisode",
scope: "plexPlayer",
keys: ["Shift+ArrowRight"],
labelKey: "keys.episode",
when: "episode",
},
{ id: "volumeUp", scope: "plexPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
{ id: "volumeDown", scope: "plexPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
{ id: "mute", scope: "plexPlayer", keys: ["m"], labelKey: "keys.mute" },
{ id: "audio", scope: "plexPlayer", keys: ["a"], labelKey: "keys.audio" },
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
{ id: "help", scope: "plexPlayer", keys: ["h"], labelKey: "keys.help" },
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
// Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to
// anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey
// keeps them a single "⌫ Esc Stop & back to feed" row in the sheet.
{ id: "back", scope: "plexPlayer", keys: ["Backspace"], labelKey: "keys.back" },
{
id: "escape",
scope: "plexPlayer",
keys: ["Escape"],
labelKey: "keys.back",
allowInInput: true,
},
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
// `as const` keeps the ids literal (that is what makes `dispatch`'s handler map exhaustive), but it
// also narrows each row to its own shape — so the optional `when`/`allowInInput` vanish from rows
// that omit them. Iterate through this widened view; read ids through `Row`.
const ROWS: readonly Shortcut[] = SHORTCUTS;
type Row = (typeof SHORTCUTS)[number];
/** The shortcut ids defined for one scope — the exact handler keys `dispatch()` requires. */
export type ShortcutId<S extends KeyScope> = Extract<Row, { scope: S }>["id"];
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before S3 the
* players disagreed about `<select>` and `contentEditable`, so the same keystroke was a shortcut in
* one and text in the other. Reads `e.target` (the focused node for a keydown) and falls back to
* `document.activeElement` for synthetic events that carry no target. */
export function isTypingTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
}
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
export function isActivationTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
const tag = (el?.tagName || "").toLowerCase();
return tag === "button" || tag === "a";
}
function keyMatches(e: KeyboardEvent, name: string): boolean {
if (name === "Space") return e.key === " " || e.code === "Space";
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
return e.key === name;
}
/** Match one chord EXACTLY, modifiers included. Exactness is the point: `case "a"` used to fire on
* Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a
* narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own
* rows (Shift+← steps an episode without ← also seeking). */
export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+");
const key = parts[parts.length - 1] ?? "";
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
if ((e.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
if (e.shiftKey !== mods.includes("shift")) return false;
if (e.altKey !== mods.includes("alt")) return false;
return keyMatches(e, key);
}
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
): boolean {
const typing = isTypingTarget(e);
for (const s of ROWS) {
if (s.scope !== scope) continue;
if (typing && !s.allowInInput) continue;
if (!s.keys.some((k) => matches(e, k))) continue;
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
return true;
}
return false;
}
// ── Display ───────────────────────────────────────────────────────────────────────────────────
const GLYPHS: Record<string, string> = {
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
Backspace: "⌫",
Escape: "Esc",
Shift: "⇧",
};
const isMac = (): boolean =>
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac). */
export function formatChord(chord: string): string {
return chord
.split("+")
.map((part, i, all) => {
if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl";
if (i < all.length - 1) return GLYPHS[part] ?? part;
return GLYPHS[part] ?? (part.length === 1 ? part.toUpperCase() : part);
})
.join(" ");
}
/** One help-sheet line: every chord that shares a description, in table order. */
export interface HelpLine {
labelKey: string;
chords: string[];
when?: ShortcutWhen;
}
/** The shortcuts of `scopes`, grouped into help lines by description. Scopes come out in the order
* ASKED FOR, rows within a scope in table order — so a surface passing its own scope first heads its
* sheet with its own keys and leaves the app-wide ones underneath, where they read as context. */
export function helpLines(scopes: readonly KeyScope[]): HelpLine[] {
const out: HelpLine[] = [];
const byLabel = new Map<string, HelpLine>();
for (const scope of scopes) {
for (const s of ROWS) {
if (s.scope !== scope) continue;
const existing = byLabel.get(s.labelKey);
if (existing) {
existing.chords.push(...s.keys);
continue;
}
const line: HelpLine = {
labelKey: s.labelKey,
chords: [...s.keys],
...(s.when && { when: s.when }),
};
byLabel.set(s.labelKey, line);
out.push(line);
}
}
return out;
}