Ctrl+Shift+H moves the keyboard between the session list and the pane; j/k walk the cards, Enter opens, Escape hands the pane back. The walk skips expanded folder headers and stops on collapsed ones, matching the TUI.
173 lines
5.2 KiB
TypeScript
173 lines
5.2 KiB
TypeScript
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
|
* overlay. One registry drives both the rendering and what we claim is bound,
|
|
* so a binding added to `main.ts` without an entry here reads as missing. */
|
|
|
|
export interface Shortcut {
|
|
/** Keycaps, rendered left to right as separate <kbd>. */
|
|
keys: string[];
|
|
description: string;
|
|
}
|
|
|
|
export interface ShortcutGroup {
|
|
title: string;
|
|
items: Shortcut[];
|
|
}
|
|
|
|
export const SHORTCUTS: ShortcutGroup[] = [
|
|
{
|
|
title: "Sessions",
|
|
items: [
|
|
{ keys: ["n"], description: "Open a spawn draft pane" },
|
|
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
|
],
|
|
},
|
|
{
|
|
title: "Session list",
|
|
items: [
|
|
{
|
|
keys: ["Ctrl", "Shift", "H"],
|
|
description: "Move the keyboard between the session list and the pane",
|
|
},
|
|
{ keys: ["j"], description: "Highlight the next session" },
|
|
{ keys: ["k"], description: "Highlight the previous session" },
|
|
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
|
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
|
|
],
|
|
},
|
|
{
|
|
title: "Help",
|
|
items: [
|
|
{ keys: ["?"], description: "Show or hide this help" },
|
|
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
|
|
],
|
|
},
|
|
{
|
|
title: "Debug",
|
|
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
|
|
},
|
|
];
|
|
|
|
/** True when the event target swallows plain keystrokes — a form field, or the
|
|
* hidden textarea xterm types into. Shared with `main.ts` so every global
|
|
* single-key binding is deaf in the same places. */
|
|
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
const el = target as HTMLElement | null;
|
|
if (!el || typeof el.tagName !== "string") return false;
|
|
return (
|
|
el.tagName === "INPUT" ||
|
|
el.tagName === "TEXTAREA" ||
|
|
el.tagName === "SELECT" ||
|
|
el.isContentEditable
|
|
);
|
|
}
|
|
|
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
|
|
|
export function isHelpOpen(): boolean {
|
|
return open !== null;
|
|
}
|
|
|
|
export function closeHelp(): void {
|
|
open?.dispose();
|
|
}
|
|
|
|
export function toggleHelp(): void {
|
|
if (open) closeHelp();
|
|
else openHelp();
|
|
}
|
|
|
|
/** Mount the overlay. Opening while already open is a no-op re-render, so a
|
|
* stray second call can never stack two dialogs. */
|
|
export function openHelp(): HTMLElement {
|
|
closeHelp();
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "help-overlay";
|
|
el.setAttribute("role", "dialog");
|
|
el.setAttribute("aria-modal", "true");
|
|
el.setAttribute("aria-label", "Keyboard shortcuts");
|
|
|
|
const panel = document.createElement("div");
|
|
panel.className = "help-panel";
|
|
|
|
const head = document.createElement("header");
|
|
head.className = "help-head";
|
|
const title = document.createElement("h2");
|
|
title.className = "help-title";
|
|
title.textContent = "Keyboard shortcuts";
|
|
const close = document.createElement("button");
|
|
close.type = "button";
|
|
close.className = "help-close";
|
|
close.setAttribute("aria-label", "Close");
|
|
close.textContent = "✕";
|
|
close.addEventListener("click", closeHelp);
|
|
head.append(title, close);
|
|
panel.appendChild(head);
|
|
|
|
for (const group of SHORTCUTS) {
|
|
const section = document.createElement("section");
|
|
section.className = "help-group";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "help-group-title";
|
|
heading.textContent = group.title;
|
|
const list = document.createElement("dl");
|
|
list.className = "help-list";
|
|
for (const item of group.items) {
|
|
const row = document.createElement("div");
|
|
row.className = "help-row";
|
|
const keys = document.createElement("dt");
|
|
keys.className = "help-keys";
|
|
for (const key of item.keys) {
|
|
const cap = document.createElement("kbd");
|
|
cap.textContent = key;
|
|
keys.appendChild(cap);
|
|
}
|
|
const desc = document.createElement("dd");
|
|
desc.className = "help-desc";
|
|
desc.textContent = item.description;
|
|
row.append(keys, desc);
|
|
list.appendChild(row);
|
|
}
|
|
section.append(heading, list);
|
|
panel.appendChild(section);
|
|
}
|
|
|
|
// A press on the scrim closes; one inside the panel is left alone.
|
|
el.addEventListener("click", (ev) => {
|
|
if (!panel.contains(ev.target as Node)) closeHelp();
|
|
});
|
|
el.appendChild(panel);
|
|
document.body.appendChild(el);
|
|
|
|
const dispose = () => {
|
|
if (open?.el !== el) return;
|
|
open = null;
|
|
el.remove();
|
|
};
|
|
open = { el, dispose };
|
|
close.focus();
|
|
return el;
|
|
}
|
|
|
|
let installed = false;
|
|
|
|
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
|
* second call during a hot reload doesn't double-toggle. */
|
|
export function installHelpKeybind(): void {
|
|
if (installed) return;
|
|
installed = true;
|
|
window.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && open) {
|
|
e.preventDefault();
|
|
closeHelp();
|
|
return;
|
|
}
|
|
// Shift is how `?` is typed on most layouts; the others would be a
|
|
// different binding entirely.
|
|
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
if (isTypingTarget(e.target)) return;
|
|
e.preventDefault();
|
|
toggleHelp();
|
|
});
|
|
}
|