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:
@@ -44,6 +44,7 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget } from "../lib/keymap";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
@@ -329,72 +330,74 @@ export default function PlayerModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
|
||||
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(e)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
e.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
|
||||
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
|
||||
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
|
||||
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
|
||||
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
|
||||
// there: deferring would scroll something invisible and leave the viewer with no volume control at
|
||||
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
|
||||
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
|
||||
return;
|
||||
}
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
const typing =
|
||||
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
|
||||
if (e.key === "f" || e.key === "F") {
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
} else if (e.key === " " || e.code === "Space") {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (typing || tag === "button" || tag === "a") return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||||
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
|
||||
// maximise — because the card is behind/off-screen there: deferring would scroll something
|
||||
// invisible and leave the viewer with no volume control at all. Both flags, not just
|
||||
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
|
||||
// checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const up = e.key === "ArrowUp";
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
const forward = e.key === "ArrowRight";
|
||||
if (e.shiftKey) {
|
||||
if (forward) goNext();
|
||||
else goPrev();
|
||||
} else {
|
||||
nudgeSeek(forward ? 5 : -5);
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
};
|
||||
const seek = (e: KeyboardEvent, secs: number) => {
|
||||
e.preventDefault();
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
ev.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
fullscreen: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleFullscreen();
|
||||
},
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
},
|
||||
volumeUp: (ev) => volume(ev, true),
|
||||
volumeDown: (ev) => volume(ev, false),
|
||||
seekBack: (ev) => seek(ev, -5),
|
||||
seekFwd: (ev) => seek(ev, 5),
|
||||
prevVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goPrev();
|
||||
},
|
||||
nextVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goNext();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
focusModal();
|
||||
|
||||
@@ -39,6 +39,7 @@ import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePublishPlexView } from "./PlexViewBridge";
|
||||
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch } from "../lib/keymap";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -163,31 +164,31 @@ export default function PlexBrowse() {
|
||||
}, [sub.view.kind]);
|
||||
|
||||
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
||||
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
|
||||
// and never hijack Backspace while typing in a field.
|
||||
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here
|
||||
// (its "player" view kind is deliberately absent from the list below); the typing guard and the
|
||||
// chord itself come from the shared keymap.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Backspace") return;
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (
|
||||
[
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
dispatch(e, "plexBrowse", {
|
||||
back: (ev) => {
|
||||
if (
|
||||
![
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
)
|
||||
return;
|
||||
ev.preventDefault();
|
||||
sub.back();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
||||
@@ -53,6 +53,13 @@ import {
|
||||
useWallClock,
|
||||
} from "../lib/plexPlayerHooks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import {
|
||||
dispatch,
|
||||
formatChord,
|
||||
helpLines,
|
||||
isActivationTarget,
|
||||
isTypingTarget,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
@@ -617,81 +624,63 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
|
||||
e.preventDefault();
|
||||
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
|
||||
};
|
||||
const step = (e: KeyboardEvent, id: string | null | undefined) => {
|
||||
e.preventDefault();
|
||||
go(id);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
wake(); // keyboard use also reveals the controls
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
if (!isTypingTarget(e)) wake();
|
||||
dispatch(e, "plexPlayer", {
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||
// playback when the focus isn't on a control — matching PlayerModal's guard.
|
||||
if (tag === "BUTTON" || tag === "A") return;
|
||||
e.preventDefault();
|
||||
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "f":
|
||||
toggleFs();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(prevId);
|
||||
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(nextId);
|
||||
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
},
|
||||
fullscreen: () => toggleFs(),
|
||||
seekBack: (ev) => seekBy(ev, -1, false),
|
||||
seekFwd: (ev) => seekBy(ev, 1, false),
|
||||
fineBack: (ev) => seekBy(ev, -1, true),
|
||||
fineFwd: (ev) => seekBy(ev, 1, true),
|
||||
prevEpisode: (ev) => step(ev, prevId),
|
||||
nextEpisode: (ev) => step(ev, nextId),
|
||||
volumeUp: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
},
|
||||
volumeDown: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(-0.05);
|
||||
break;
|
||||
case "m":
|
||||
toggleMute();
|
||||
break;
|
||||
case "a":
|
||||
case "A":
|
||||
cycleAudio();
|
||||
break;
|
||||
case "s":
|
||||
case "S":
|
||||
cycleSubtitle();
|
||||
break;
|
||||
case "h":
|
||||
case "H":
|
||||
setHelpOpen((v) => !v);
|
||||
break;
|
||||
case "i":
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
setStatsOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
},
|
||||
mute: () => toggleMute(),
|
||||
audio: () => cycleAudio(),
|
||||
subtitle: () => cycleSubtitle(),
|
||||
help: () => setHelpOpen((v) => !v),
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
ev.preventDefault();
|
||||
if (skipProgressRef.current != null)
|
||||
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
||||
else handleBack();
|
||||
break;
|
||||
case "Escape":
|
||||
},
|
||||
escape: (ev) => {
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(e)) break;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
@@ -701,8 +690,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -1261,40 +1250,27 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Rendered FROM the shared keymap, not from a copy of it beside the handler: the two
|
||||
used to be edited separately, which is how a sheet ends up documenting a key that no
|
||||
longer exists. `when: "episode"` rows only apply to an episode. */}
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{(
|
||||
[
|
||||
[["Space", "K"], "plex.player.keys.playPause"],
|
||||
[["←", "→"], "plex.player.keys.seek"],
|
||||
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
|
||||
...(detail?.kind === "episode"
|
||||
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
|
||||
: []),
|
||||
[["↑", "↓"], "plex.player.keys.volume"],
|
||||
[["M"], "plex.player.keys.mute"],
|
||||
[["A"], "plex.player.keys.audio"],
|
||||
[["S"], "plex.player.keys.subtitle"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["D"], "plex.player.keys.stats"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
).map(([keys, labelKey]) => (
|
||||
<Fragment key={labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-white/80">{t(labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
{helpLines(["plexPlayer", "global"])
|
||||
.filter((line) => line.when !== "episode" || detail?.kind === "episode")
|
||||
.map((line) => (
|
||||
<Fragment key={line.labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{line.chords.map((chord) => (
|
||||
<kbd
|
||||
key={chord}
|
||||
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-white/80">{t(line.labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { dispatch } 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
|
||||
// undo source. The shortcuts ignore keystrokes while a text field is focused.
|
||||
// undo source. The chords and the typing guard come from the shared keymap (`undo` scope), so
|
||||
// they are listed in the help sheet with everything else instead of only living here.
|
||||
export default function UndoToolbar({
|
||||
canUndo,
|
||||
canRedo,
|
||||
@@ -20,22 +22,18 @@ export default function UndoToolbar({
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable))
|
||||
return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "z" && !e.shiftKey) {
|
||||
if (canUndo) {
|
||||
e.preventDefault();
|
||||
dispatch(e, "undo", {
|
||||
undo: (ev) => {
|
||||
if (!canUndo) return;
|
||||
ev.preventDefault();
|
||||
onUndo();
|
||||
}
|
||||
} else if (k === "y" || (k === "z" && e.shiftKey)) {
|
||||
if (canRedo) {
|
||||
e.preventDefault();
|
||||
},
|
||||
redo: (ev) => {
|
||||
if (!canRedo) return;
|
||||
ev.preventDefault();
|
||||
onRedo();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Keyboard shortcuts",
|
||||
"contextMenu": "Open the context menu",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"playPause": "Play / pause",
|
||||
"fullscreen": "Fullscreen",
|
||||
"seek": "Seek 10 seconds",
|
||||
"seek5": "Seek 5 seconds",
|
||||
"fineSeek": "Fine seek",
|
||||
"episode": "Previous / next episode",
|
||||
"video": "Previous / next video",
|
||||
"volume": "Volume up / down",
|
||||
"mute": "Mute",
|
||||
"audio": "Cycle audio track",
|
||||
"subtitle": "Cycle subtitle",
|
||||
"help": "Show this help",
|
||||
"info": "Media info",
|
||||
"stats": "Stats for nerds",
|
||||
"back": "Stop & back to feed",
|
||||
"browseBack": "Back one level",
|
||||
"closePlayer": "Close the player"
|
||||
}
|
||||
@@ -186,21 +186,6 @@
|
||||
"stop": "Stop",
|
||||
"help": "Keyboard shortcuts",
|
||||
"infoBtn": "Media info",
|
||||
"keys": {
|
||||
"playPause": "Play / pause",
|
||||
"seek": "Seek 10 seconds",
|
||||
"episode": "Previous / next episode",
|
||||
"volume": "Volume up / down",
|
||||
"mute": "Mute",
|
||||
"fullscreen": "Fullscreen",
|
||||
"info": "Media info",
|
||||
"back": "Stop & back to feed",
|
||||
"help": "Show this help",
|
||||
"audio": "Cycle audio track",
|
||||
"subtitle": "Cycle subtitle",
|
||||
"fineSeek": "Fine seek",
|
||||
"stats": "Stats for nerds"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Up next",
|
||||
"playNow": "Play now",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Billentyűparancsok",
|
||||
"contextMenu": "Helyi menü megnyitása",
|
||||
"undo": "Visszavonás",
|
||||
"redo": "Újra",
|
||||
"playPause": "Lejátszás / szünet",
|
||||
"fullscreen": "Teljes képernyő",
|
||||
"seek": "Tekerés 10 másodperc",
|
||||
"seek5": "Tekerés 5 másodperc",
|
||||
"fineSeek": "Finom léptetés",
|
||||
"episode": "Előző / következő rész",
|
||||
"video": "Előző / következő videó",
|
||||
"volume": "Hangerő fel / le",
|
||||
"mute": "Némítás",
|
||||
"audio": "Hangsáv váltása",
|
||||
"subtitle": "Felirat váltása",
|
||||
"help": "Súgó megjelenítése",
|
||||
"info": "Média infó",
|
||||
"stats": "Statisztika (nerdöknek)",
|
||||
"back": "Leállítás és vissza a feedre",
|
||||
"browseBack": "Vissza egy szintet",
|
||||
"closePlayer": "Lejátszó bezárása"
|
||||
}
|
||||
@@ -186,21 +186,6 @@
|
||||
"stop": "Leállítás",
|
||||
"help": "Billentyűparancsok",
|
||||
"infoBtn": "Média infó",
|
||||
"keys": {
|
||||
"playPause": "Lejátszás / szünet",
|
||||
"seek": "Tekerés 10 másodperc",
|
||||
"episode": "Előző / következő rész",
|
||||
"volume": "Hangerő fel / le",
|
||||
"mute": "Némítás",
|
||||
"fullscreen": "Teljes képernyő",
|
||||
"info": "Média infó",
|
||||
"back": "Leállítás és vissza a feedre",
|
||||
"help": "Súgó megjelenítése",
|
||||
"audio": "Hangsáv váltása",
|
||||
"subtitle": "Felirat váltása",
|
||||
"fineSeek": "Finom léptetés",
|
||||
"stats": "Statisztika (nerdöknek)"
|
||||
},
|
||||
"upNext": {
|
||||
"title": "Következik",
|
||||
"playNow": "Lejátszás most",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user