Shortcut dispatcher now skips non-modifier keys when focus is in an editable field. Editor find bar ESC stops propagation to close bar before modal. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
6.1 KiB
JavaScript
171 lines
6.1 KiB
JavaScript
// ── 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
|
|
// (except Escape which should always close overlays)
|
|
if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape') 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 += `<section>
|
|
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">${esc(name)}</h3>
|
|
<table class="shortcut-table"><tbody>`;
|
|
for (const s of shortcuts) {
|
|
const keysHtml = s.keys
|
|
? s.keys // pre-formatted HTML (kbd tags etc.)
|
|
: `<kbd>${esc(s.key || '?')}</kbd>`;
|
|
html += `<tr><td>${keysHtml}</td><td>${esc(s.label)}</td></tr>`;
|
|
}
|
|
html += `</tbody></table></section>`;
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|