S4's reachability half. Home already led the nav order; what was missing was every way IN other than the rail. - Settings → Appearance → Startup: "Start on Home", the documented escape hatch for the landing override. Deliberately still localStorage and not a server pref — `loadInitialPage` answers before /api/me lands, and a landing module decided after the first paint is a visible jump. Its two stored values are now ONE exported constant, so the writer and the boot-time reader cannot drift on a magic string. - The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home module row already matches that phrase through its descriptive title. - A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown a viewer out of the film. The palette's request crosses a lazy module boundary, so it is a token that waits (lib/homeIntent) rather than an event fired into an empty room. Browser-measured: taken with a compare-and-clear, because StrictMode's double-invoked mount effect otherwise rerolled twice and landed on the page two picks deep. HU/EN parity kept; UAT on localdev in both languages, including the empty shelf.
408 lines
21 KiB
TypeScript
408 lines
21 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { createStore } from "./store";
|
|
|
|
// ── 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" | "contextMenu" | "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;
|
|
/** Matched at the element level rather than through `dispatch` — a row that is here to be
|
|
* DOCUMENTED and to share the chord parser, not to be routed. `dispatch` neither requires nor
|
|
* accepts a handler for it, so the exhaustiveness check stays honest about what it covers. */
|
|
readonly handledLocally?: boolean;
|
|
/** 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 ---------------------------------------------------------------------------------
|
|
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
|
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
|
|
// `allowInInput` because the palette AUTOFOCUSES its search field: without it the typing guard ate
|
|
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead
|
|
// (E10 review round 1). Its handler preventDefaults, so nothing leaks to the browser either way.
|
|
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
|
|
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
|
|
// Shift+W and not a bare `w` (E24): the Plex player binds bare letters (a s d f h i k m) and `w`
|
|
// 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" },
|
|
// --- 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
|
|
// — where nothing answers the key (E10 review round 1). Same honest-states rule `plexBrowse` obeys.
|
|
{
|
|
id: "contextMenu",
|
|
scope: "contextMenu",
|
|
keys: ["Shift+F10", "ContextMenu"],
|
|
labelKey: "keys.contextMenu",
|
|
handledLocally: true,
|
|
},
|
|
|
|
// --- 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: "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; handledLocally?: undefined }
|
|
>["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). */
|
|
/** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is
|
|
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
|
|
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
|
|
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
|
|
* editing the row changed the help sheet and nothing else (E10 review round 1).
|
|
*
|
|
* Keyed on scope AND id, both as literal types: ids are only unique WITHIN a scope (`back` is on
|
|
* both players), so a bare `ROWS.find(s => s.id === id)` would answer for whichever came first, and
|
|
* a typo'd string would silently never match (round 2). */
|
|
export function matchesShortcut<S extends KeyScope>(
|
|
e: KeyboardEvent,
|
|
scope: S,
|
|
id: Extract<Row, { scope: S }>["id"],
|
|
): boolean {
|
|
const row = ROWS.find((s) => s.scope === scope && s.id === id);
|
|
return !!row?.keys.some((k) => matches(e, k));
|
|
}
|
|
|
|
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 || 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);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Which scopes are live right now ───────────────────────────────────────────────────────────
|
|
// A stack rather than a single value: surfaces nest (the Plex player opens over the Plex browser,
|
|
// a reorderable playlist has both its page and an UndoToolbar). Innermost first, so the help sheet
|
|
// leads with the surface you are actually in and trails with the app-wide keys.
|
|
//
|
|
// Push a scope only while its keys really DO something — `PlexBrowse` deactivates its own scope
|
|
// while the player is up, because its Backspace handler no-ops there and a sheet promising "Back
|
|
// one level" would be lying. That is the honest-states rule applied to documentation.
|
|
const scopeStack = createStore<readonly KeyScope[]>([]);
|
|
|
|
/** Register `scope` as live for as long as this component is mounted and `active`. */
|
|
export function useKeyScope(scope: KeyScope, active = true): void {
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const token = { scope };
|
|
tokens.push(token);
|
|
scopeStack.set(tokens.map((t) => t.scope));
|
|
return () => {
|
|
tokens.splice(tokens.indexOf(token), 1);
|
|
scopeStack.set(tokens.map((t) => t.scope));
|
|
};
|
|
}, [scope, active]);
|
|
}
|
|
|
|
// Identity tokens, so unmounting removes THIS registration and not another instance of the same
|
|
// scope that happens to sit earlier in the array.
|
|
const tokens: { scope: KeyScope }[] = [];
|
|
|
|
/** The live scopes, innermost first, always ending in `global`. Re-renders on change. */
|
|
export function useActiveScopes(): KeyScope[] {
|
|
const stack = scopeStack.use();
|
|
// DEDUPED: a scope can be registered by more than one live component (the Playlists page and its
|
|
// 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");
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
|
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
|
|
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
|
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
|
|
|
// ── The command palette ───────────────────────────────────────────────────────────────────────
|
|
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.
|
|
*
|
|
* `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
|
|
// 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;
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
|
helpOpen.set(false);
|
|
paletteOpen.set(false);
|
|
return;
|
|
}
|
|
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
|
|
// way, on auto-repeat that is ~30 push/back pairs a second, which Chrome starts dropping while
|
|
// 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", {
|
|
help: (ev) => {
|
|
ev.preventDefault();
|
|
helpOpen.set((v) => !v);
|
|
},
|
|
palette: (ev) => {
|
|
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
|
|
ev.preventDefault();
|
|
paletteOpen.set((v) => !v);
|
|
},
|
|
home: (ev) => {
|
|
ev.preventDefault();
|
|
homeRef.current();
|
|
},
|
|
});
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [enabled]);
|
|
}
|
|
|
|
/** Answers for the `when` conditions, published by the surface that knows them. */
|
|
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
|
const helpConditions = createStore<HelpConditions>({});
|
|
|
|
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
|
|
* sheet lives in App and cannot ask, so the surface tells it. */
|
|
export function useHelpConditions(conditions: HelpConditions): void {
|
|
const episode = conditions.episode;
|
|
useEffect(() => {
|
|
helpConditions.set({ episode });
|
|
return () => helpConditions.set({});
|
|
}, [episode]);
|
|
}
|
|
|
|
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
|
|
|
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
|
const GLYPHS: Record<string, string> = {
|
|
ArrowLeft: "←",
|
|
ArrowRight: "→",
|
|
ArrowUp: "↑",
|
|
ArrowDown: "↓",
|
|
Backspace: "⌫",
|
|
ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap
|
|
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;
|
|
}
|