// ── Central keyboard shortcut registry ── // // Every keyboard shortcut — bare keys, combos, context-specific — is // registered here via registerShortcut(). A single document-level // keydown listener dispatches to the matching handler. The registry // also drives the auto-generated #shortcuts help screen so the UI // and the code always agree on what's available. // // Shortcuts that live on specific DOM elements (editor textarea, // search input, canvas overlay, dialog) stay element-scoped because // they depend on local module state (autocomplete visibility, etc.). // Only document-level shortcuts are centralized here. const registry = []; const OVERLAY_IDS = ['modal', 'detail', 'search', 'calendar', 'gallery', 'settings-overlay', 'help', 'shortcuts', 'email-overlay', 'tasks', 'dialog', 'ascii-canvas-overlay']; function isOpen(id) { return document.getElementById(id)?.classList.contains('open'); } function noOverlay() { return !OVERLAY_IDS.some(isOpen); } function bodyFocused() { return document.activeElement === document.body; } /** * Register a keyboard shortcut. * * @param {object} opts * @param {string} opts.key — e.key value to match (e.g. 'a', 'Enter', 'Escape', '?') * @param {boolean} [opts.ctrl] — require Ctrl/Cmd * @param {boolean} [opts.shift] — require Shift * @param {boolean} [opts.alt] — require Alt * @param {function} [opts.match] — custom predicate (e) => bool; overrides key/ctrl/shift/alt * @param {function} [opts.guard] — extra runtime check; shortcut only fires when guard() is truthy * @param {number} [opts.priority] — higher = checked first (default 0) * @param {boolean} [opts.stop] — call e.stopPropagation() + e.stopImmediatePropagation() * @param {string} opts.section — help-screen section header * @param {string} opts.label — human description for the help screen * @param {string} [opts.keys] — display string for the help screen (e.g. '⌃ S'); auto-built if omitted * @param {boolean} [opts.hidden] — true to suppress from the help screen * @param {function} opts.handler — (e: KeyboardEvent) => void */ export function registerShortcut(opts) { registry.push({ key: opts.key ?? null, ctrl: !!opts.ctrl, shift: !!opts.shift, alt: !!opts.alt, match: opts.match ?? null, guard: opts.guard ?? null, priority: opts.priority ?? 0, stop: !!opts.stop, section: opts.section ?? 'misc', label: opts.label ?? '', keys: opts.keys ?? null, hidden: !!opts.hidden, handler: opts.handler, }); // Keep sorted by priority desc so the dispatch loop can short-circuit. registry.sort((a, b) => b.priority - a.priority); } /** Return the full registry for the auto-generated help screen. */ export function getShortcuts() { return registry.filter(s => !s.hidden); } /** Utility guards for use in shortcut definitions. */ export const guards = { board: () => bodyFocused() && noOverlay(), editor: () => isOpen('modal'), detail: () => isOpen('detail'), search: () => isOpen('search'), email: () => isOpen('email-overlay'), canvas: () => isOpen('ascii-canvas-overlay'), noOverlay, bodyFocused, isOpen, }; // ── Dispatcher ── function isEditableTarget() { const el = document.activeElement; if (!el) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (el.isContentEditable) return true; return false; } document.addEventListener('keydown', (e) => { const inEditable = isEditableTarget(); for (const s of registry) { // Key match let matched; if (s.match) { matched = s.match(e); } else { if (s.key !== e.key) continue; if (s.ctrl !== (e.ctrlKey || e.metaKey)) continue; if (s.shift !== e.shiftKey) continue; if (s.alt !== e.altKey) continue; matched = true; } if (!matched) continue; // Suppress non-modifier shortcuts when focus is in an editable field // (allow Escape and function keys like F1 through) if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape' && !e.key.startsWith('F')) continue; // Guard if (s.guard && !s.guard()) continue; // Fire e.preventDefault(); if (s.stop) { e.stopPropagation(); e.stopImmediatePropagation(); } s.handler(e); return; } }); // ── Auto-generated help screen ── /** Render the #shortcuts overlay content from the registry. * Called once after all modules have registered their shortcuts * (typically at the end of app.js boot). */ export function renderShortcutHelp() { const container = document.querySelector('#shortcuts .shortcut-help-content'); if (!container) return; const visible = getShortcuts(); // Group by section, preserve registration order within each section const sections = new Map(); for (const s of visible) { if (!sections.has(s.section)) sections.set(s.section, []); sections.get(s.section).push(s); } // Build HTML — each section is a block with a header + table. let html = ''; for (const [name, shortcuts] of sections) { html += `

${esc(name)}

`; for (const s of shortcuts) { const keysHtml = s.keys ? s.keys // pre-formatted HTML (kbd tags etc.) : `${esc(s.key || '?')}`; html += ``; } html += `
${keysHtml}${esc(s.label)}
`; } container.innerHTML = html; } function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>'); }