feat(keys): j/k walk the feed, s finds the search box, g jumps to a module
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass. First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c. - `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k` step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next item takes it, so the player's focus restore lands back on the card. The first `j` takes the first card you can see, not the top of the list. - `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in); `VirtualFeed` turns it on, so the feed, the library and the channel page all have it. - Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open, enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the Hungarian layout. - `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends its first Escape on its panel). - Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences the page keys. - Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/ link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome). - Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable module; opened over a player or dialog it leaves out the page-level keys and the context-menu key, which nothing can reach there (the latter pre-existing since E10). - CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` + `data-nav-open`, not a private arrow-key handler).
This commit is contained in:
+239
-25
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { anyBlockingLayer } from "./layerStack";
|
||||
import { createStore } from "./store";
|
||||
import type { Page } from "./urlState";
|
||||
|
||||
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
|
||||
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
|
||||
@@ -18,8 +20,26 @@ import { createStore } from "./store";
|
||||
// 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" | "contextMenu" | "ytPlayer" | "plexPlayer" | "plexBrowse";
|
||||
* is mounted. The help sheet shows `global` plus the registered scopes.
|
||||
*
|
||||
* `page`, `search` and `list` are the PAGE-LEVEL scopes (E11 S4): they act on the page underneath
|
||||
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
|
||||
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
|
||||
* run with the player's own layer up. */
|
||||
export type KeyScope =
|
||||
| "global"
|
||||
| "page"
|
||||
| "search"
|
||||
| "list"
|
||||
| "undo"
|
||||
| "contextMenu"
|
||||
| "ytPlayer"
|
||||
| "plexPlayer"
|
||||
| "plexBrowse";
|
||||
|
||||
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>(["page", "search", "list"]);
|
||||
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
|
||||
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||
|
||||
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
||||
type ShortcutWhen = "episode";
|
||||
@@ -42,11 +62,19 @@ export interface Shortcut {
|
||||
* 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;
|
||||
/** The module a `g` chord jumps to. The sheet lists the row only while that module is available
|
||||
* to this account, and the handler refuses it otherwise — a gated module has no page to land on. */
|
||||
readonly page?: Page;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// focused button reports `key === " "` inconsistently across engines). A SPACE separates the two
|
||||
// steps of a sequence: `g h` is `g`, then `h` within SEQUENCE_MS.
|
||||
//
|
||||
// Owner rule (E11 D1): no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
|
||||
// or `?` (both Shift chords there). Letters, digits, `-`, `,`, `.`, Esc/Enter/arrows only. `keyMatches`
|
||||
// compares `e.key`, so a letter follows the printed legend (the HU Z/Y swap included).
|
||||
export const SHORTCUTS = [
|
||||
// --- global ---------------------------------------------------------------------------------
|
||||
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
||||
@@ -60,6 +88,50 @@ export const SHORTCUTS = [
|
||||
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
|
||||
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
|
||||
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
|
||||
|
||||
// --- page-level: jump to a module (quiet under an overlay) -----------------------------------
|
||||
// English initials, because the Hungarian ones collide (Letöltések / Lejátszási listák). `g h` is
|
||||
// Shift+W's modifier-free twin and shares its help line. Admin pages stay on the palette.
|
||||
{ id: "goHome", scope: "page", keys: ["g h"], labelKey: "keys.home", page: "home" },
|
||||
{ id: "goFeed", scope: "page", keys: ["g f"], labelKey: "keys.goFeed", page: "feed" },
|
||||
{ id: "goChannels", scope: "page", keys: ["g c"], labelKey: "keys.goChannels", page: "channels" },
|
||||
{
|
||||
id: "goPlaylists",
|
||||
scope: "page",
|
||||
keys: ["g p"],
|
||||
labelKey: "keys.goPlaylists",
|
||||
page: "playlists",
|
||||
},
|
||||
{ id: "goPlex", scope: "page", keys: ["g x"], labelKey: "keys.goPlex", page: "plex" },
|
||||
{
|
||||
id: "goNotifications",
|
||||
scope: "page",
|
||||
keys: ["g n"],
|
||||
labelKey: "keys.goNotifications",
|
||||
page: "notifications",
|
||||
},
|
||||
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
|
||||
{
|
||||
id: "goDownloads",
|
||||
scope: "page",
|
||||
keys: ["g d"],
|
||||
labelKey: "keys.goDownloads",
|
||||
page: "downloads",
|
||||
},
|
||||
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
|
||||
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
|
||||
|
||||
// --- the module's search box (wherever a SearchBar is mounted) -------------------------------
|
||||
{ id: "focus", scope: "search", keys: ["s"], labelKey: "keys.search" },
|
||||
|
||||
// --- list / grid navigation (wherever a list registers with `useListNav`) --------------------
|
||||
{ id: "next", scope: "list", keys: ["j"], labelKey: "keys.listStep" },
|
||||
{ id: "prev", scope: "list", keys: ["k"], labelKey: "keys.listStep" },
|
||||
{ id: "left", scope: "list", keys: ["ArrowLeft"], labelKey: "keys.gridMove" },
|
||||
{ id: "right", scope: "list", keys: ["ArrowRight"], labelKey: "keys.gridMove" },
|
||||
{ id: "up", scope: "list", keys: ["ArrowUp"], labelKey: "keys.gridMove" },
|
||||
{ id: "down", scope: "list", keys: ["ArrowDown"], labelKey: "keys.gridMove" },
|
||||
{ id: "open", scope: "list", keys: ["Enter"], labelKey: "keys.open" },
|
||||
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
||||
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
||||
@@ -206,23 +278,87 @@ export function matchesShortcut<S extends KeyScope>(
|
||||
return !!row?.keys.some((k) => matches(e, k));
|
||||
}
|
||||
|
||||
// ── Two-step sequences (`g h`) ─────────────────────────────────────────────────────────────────
|
||||
// Each scope keeps its own window listener, so a sequence cannot live in one listener's closure: the
|
||||
// `h` that completes `g h` also reaches the global listener (where `h` opens the sheet) and every
|
||||
// other live scope. So the pending first step is module state, and it belongs to the scope that
|
||||
// started it — every OTHER scope ignores the next key while it is pending, and the owner stamps the
|
||||
// event once it has resolved it, so a scope whose listener runs after the owner's ignores it too.
|
||||
// The stamp is the layerStack Escape trick: every listener in one dispatch gets the same object.
|
||||
const SEQUENCE_MS = 1500;
|
||||
let pending: { scope: KeyScope; key: string; at: number } | null = null;
|
||||
const sequenceEvents = new WeakSet<KeyboardEvent>();
|
||||
// A modifier's own keydown (reaching for Shift) neither completes nor breaks a sequence.
|
||||
const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
|
||||
|
||||
function liveSequence(e: KeyboardEvent): typeof pending {
|
||||
if (!pending) return null;
|
||||
if (Date.now() - pending.at >= SEQUENCE_MS) {
|
||||
pending = null;
|
||||
return null;
|
||||
}
|
||||
return MODIFIER_KEYS.has(e.key) ? null : pending;
|
||||
}
|
||||
|
||||
const stepsOf = (chord: string): string[] => chord.split(" ");
|
||||
|
||||
/** 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). */
|
||||
* whether to `preventDefault`, because only it knows if it actually acted) or a sequence's first
|
||||
* step was taken. `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), and — for a page-level scope — while a layer that
|
||||
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
|
||||
* surface cannot forget it. */
|
||||
export function dispatch<S extends KeyScope>(
|
||||
e: KeyboardEvent,
|
||||
scope: S,
|
||||
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
|
||||
): boolean {
|
||||
if (sequenceEvents.has(e)) return false;
|
||||
const live = liveSequence(e);
|
||||
if (live && live.scope !== scope) return false;
|
||||
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
|
||||
const typing = isTypingTarget(e);
|
||||
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
|
||||
|
||||
if (live) {
|
||||
// This key ends the sequence either way: it completes a row, or it breaks the attempt.
|
||||
pending = null;
|
||||
sequenceEvents.add(e);
|
||||
if (quiet || typing) return false;
|
||||
for (const s of ROWS) {
|
||||
if (s.scope !== scope || s.handledLocally) continue;
|
||||
const hit = s.keys.some((k) => {
|
||||
const [first, second] = stepsOf(k);
|
||||
return second !== undefined && first === live.key && matches(e, second);
|
||||
});
|
||||
if (hit) {
|
||||
run(s.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quiet) return false;
|
||||
for (const s of ROWS) {
|
||||
if (s.scope !== scope || s.handledLocally) 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);
|
||||
if (!s.keys.some((k) => stepsOf(k).length === 1 && matches(e, k))) continue;
|
||||
run(s.id);
|
||||
return true;
|
||||
}
|
||||
if (typing) return false;
|
||||
// Nothing matched outright — does this key open one of the scope's sequences?
|
||||
for (const s of ROWS) {
|
||||
if (s.scope !== scope || s.handledLocally) continue;
|
||||
const first = s.keys.map(stepsOf).find((st) => st.length === 2 && matches(e, st[0]!))?.[0];
|
||||
if (first !== undefined) {
|
||||
pending = { scope, key: first, at: Date.now() };
|
||||
sequenceEvents.add(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -261,18 +397,40 @@ export function useActiveScopes(): KeyScope[] {
|
||||
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
|
||||
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
|
||||
// (E10 review round 2). Innermost registration wins the ordering.
|
||||
return [...new Set([...stack].reverse())].concat("global");
|
||||
const live = [...new Set([...stack].reverse())];
|
||||
// The page-level scopes go in a FIXED order after the surfaces, not in registration order: App
|
||||
// registers `page` in its own effect, which React runs after every child's — so by mount order
|
||||
// the module jumps would head the sheet, above the list keys of the page you are looking at.
|
||||
const surfaces = live.filter((s) => !isPageLevelScope(s));
|
||||
const pageLevel = (["list", "search", "page"] as const).filter((s) => live.includes(s));
|
||||
return [...surfaces, ...pageLevel, "global"];
|
||||
}
|
||||
|
||||
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
|
||||
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
|
||||
// there is exactly one answer to "what can I press here" instead of one per player.
|
||||
const helpOpen = createStore(false);
|
||||
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
|
||||
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
|
||||
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
|
||||
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
|
||||
const helpOverLayer = createStore(false);
|
||||
|
||||
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
||||
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
|
||||
export const toggleKeyboardHelp = (): void => {
|
||||
const next = !helpOpen.get();
|
||||
if (next) helpOverLayer.set(anyBlockingLayer());
|
||||
helpOpen.set(next);
|
||||
};
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
||||
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
|
||||
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.use();
|
||||
|
||||
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
|
||||
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
|
||||
const navigablePages = createStore<readonly Page[]>([]);
|
||||
export const useNavigablePages = (): readonly Page[] => navigablePages.use();
|
||||
|
||||
// ── The command palette ───────────────────────────────────────────────────────────────────────
|
||||
const paletteOpen = createStore(false);
|
||||
@@ -280,21 +438,32 @@ const paletteOpen = createStore(false);
|
||||
export const closePalette = (): void => paletteOpen.set(false);
|
||||
export const usePaletteOpen = (): boolean => paletteOpen.use();
|
||||
|
||||
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
|
||||
* overlay because both are opened by it: a listener that sat in one of them would have to route a
|
||||
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
|
||||
* calls this; the overlays are pure renderers of their store.
|
||||
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
|
||||
* either overlay because both are opened by it: a listener that sat in one of them would have to
|
||||
* route a key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything.
|
||||
* App calls this; the overlays are pure renderers of their store.
|
||||
*
|
||||
* `pages` is the set of modules this account can reach (a `g` chord to anything else is a no-op).
|
||||
*
|
||||
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
|
||||
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
|
||||
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
|
||||
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
|
||||
export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
||||
// The Home jump is the one global key whose action lives OUTSIDE this file — navigation is a
|
||||
export function useGlobalKeymap(
|
||||
enabled: boolean,
|
||||
onNavigate: (page: Page) => void,
|
||||
pages: readonly Page[],
|
||||
): void {
|
||||
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
|
||||
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
|
||||
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
|
||||
const homeRef = useRef(onHome);
|
||||
homeRef.current = onHome;
|
||||
const navRef = useRef(onNavigate);
|
||||
navRef.current = onNavigate;
|
||||
useKeyScope("page", enabled);
|
||||
const pagesKey = pages.join(",");
|
||||
useEffect(() => {
|
||||
navigablePages.set(pagesKey ? (pagesKey.split(",") as Page[]) : []);
|
||||
}, [pagesKey]);
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
||||
@@ -302,6 +471,17 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
||||
paletteOpen.set(false);
|
||||
return;
|
||||
}
|
||||
// Built from the rows, so a new `g` row needs no second edit here — the row's `page` IS the action.
|
||||
const pageHandlers = Object.fromEntries(
|
||||
ROWS.filter((s) => s.scope === "page").map((s) => [
|
||||
s.id,
|
||||
(ev: KeyboardEvent) => {
|
||||
if (!s.page || !navigablePages.get().includes(s.page)) return;
|
||||
ev.preventDefault();
|
||||
navRef.current(s.page);
|
||||
},
|
||||
]),
|
||||
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
|
||||
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
|
||||
@@ -309,10 +489,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
||||
// our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
|
||||
// walks out of the app. Holding a key is not a second press.
|
||||
if (e.repeat) return;
|
||||
dispatch(e, "global", {
|
||||
const handled = dispatch(e, "global", {
|
||||
help: (ev) => {
|
||||
ev.preventDefault();
|
||||
helpOpen.set((v) => !v);
|
||||
toggleKeyboardHelp();
|
||||
},
|
||||
palette: (ev) => {
|
||||
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
|
||||
@@ -321,9 +501,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
||||
},
|
||||
home: (ev) => {
|
||||
ev.preventDefault();
|
||||
homeRef.current();
|
||||
navRef.current("home");
|
||||
},
|
||||
});
|
||||
if (!handled) dispatch(e, "page", pageHandlers);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -344,6 +525,30 @@ export function useHelpConditions(conditions: HelpConditions): void {
|
||||
}, [episode]);
|
||||
}
|
||||
|
||||
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
||||
/** Bind `s` to "put the caret in this search box" while the box is mounted. The one SearchBar in the
|
||||
* header calls this; the text is selected so the next keystrokes replace the old query (an arrow key
|
||||
* keeps it). `preventDefault` keeps the `s` itself out of the field it just focused. */
|
||||
export function useSearchFocusKey(input: RefObject<HTMLInputElement | null>): void {
|
||||
useKeyScope("search");
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.repeat) return;
|
||||
dispatch(e, "search", {
|
||||
focus: (ev) => {
|
||||
const el = input.current;
|
||||
if (!el) return;
|
||||
ev.preventDefault();
|
||||
el.focus();
|
||||
el.select();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [input]);
|
||||
}
|
||||
|
||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -361,8 +566,13 @@ const GLYPHS: Record<string, string> = {
|
||||
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). */
|
||||
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a
|
||||
* sequence step by step: `g h` → `G H`. */
|
||||
export function formatChord(chord: string): string {
|
||||
return stepsOf(chord).map(formatStep).join(" ");
|
||||
}
|
||||
|
||||
function formatStep(chord: string): string {
|
||||
return chord
|
||||
.split("+")
|
||||
.map((part, i, all) => {
|
||||
@@ -380,15 +590,19 @@ export interface HelpLine {
|
||||
when?: ShortcutWhen;
|
||||
}
|
||||
|
||||
/** The shortcuts of `scopes`, grouped into help lines by description. Scopes come out in the order
|
||||
/** The shortcuts of `scopes`, grouped into help lines by description (with `pages`, the `g` chords
|
||||
* are limited to those modules). 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[] {
|
||||
export function helpLines(scopes: readonly KeyScope[], pages?: readonly Page[]): 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;
|
||||
// A `g` chord to a module this account cannot reach is left out ROW by row, not line by line:
|
||||
// `g h` shares its line with Shift+W, which must stay.
|
||||
if (s.page && pages && !pages.includes(s.page)) continue;
|
||||
const existing = byLabel.get(s.labelKey);
|
||||
if (existing) {
|
||||
existing.chords.push(...s.keys);
|
||||
|
||||
Reference in New Issue
Block a user