New module public/js/modules/shortcuts.js:
* registerShortcut({key, ctrl, shift, alt, match, guard, priority,
section, label, keys, handler}) — declarative shortcut registration
with priority ordering, custom match predicates, and runtime guards.
* Single document-level keydown dispatcher iterates the registry
(sorted by priority desc), matches key+modifiers or custom match(),
checks guard(), fires handler with preventDefault.
* guards object: board(), editor(), detail(), search(), email(),
canvas(), bodyFocused(), noOverlay(), isOpen(id) — reusable
predicates that replace the repeated blocker-array patterns.
* renderShortcutHelp() auto-generates the #shortcuts overlay content
from the registry, grouped by section. New shortcuts added via
registerShortcut appear in the help screen automatically.
* Shortcuts with guard: () => false are help-screen-only entries for
element-level shortcuts handled by their own modules (editor
textarea, search input, canvas overlay, etc.).
Migration (public/js/app.js):
* The ~200-line monolithic keydown handler is replaced by ~80
declarative registerShortcut() calls.
* Escape chain: 10 separate registrations with priorities 100→20,
each with its own guard (quick-capture, detail/zen, modal, search,
tasks, gallery, calendar, shortcuts, help). A hidden: true flag
suppresses them from the help screen; one visible entry with
guard: () => false shows "Esc — close any overlay".
* Global combos: Ctrl+K (search), Ctrl+N (quick capture), Ctrl+Z
(undo), F1 (help), ? (shortcuts).
* Editor: Ctrl+S (save), Ctrl+Shift+S (save+close), Ctrl+Enter
(save+close). Element-level shortcuts (Tab indent, Alt+arrows,
autocomplete triggers) registered as help-screen-only.
* Board bare keys: a/f/j/n/t/c//, Tab/Shift+Tab, 1-9, arrow nav,
Enter → preview, Delete/Backspace selected.
* Detail: ←/→ group navigation, Enter → editor.
* Help-screen-only: search ↑↓/Enter, board mouse actions, canvas
tool shortcuts (B/E/L/R/O/F/T/⌫), mail ⌫/⇧⌫, m (plugin).
Removed from board.js: the document-level Delete/Backspace listener
(now in the central registry with the same logic).
Help screen (public/index.php):
* The hardcoded 3-column shortcut HTML is replaced by an empty
.shortcut-help-content div that renderShortcutHelp() fills at boot.
* CSS: .shortcut-help-content section gets break-inside:avoid +
margin-bottom for clean column flow.
Test stabilization:
* card-interactions.test.js: due-pill, overdue-border, and
child-hidden-on-board tests now set state.activeBucket='all'
before _reloadAll and poll for the DOM element (40×50ms) instead
of a fixed sleep(200). Eliminates the flaky "card not found" when
activeBucket was leaked by a prior test.
* editor-features.test.js: "save creates card and closes editor"
polls for #modal to lose .open class (40×50ms) instead of a fixed
sleep(400). doSave(true) does 2-3 serial API calls before
closeModal which sometimes exceeded the old budget.
chore: bump version to 0.9.13
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
156 lines
5.6 KiB
JavaScript
156 lines
5.6 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 ──
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
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;
|
|
|
|
// 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, '>');
|
|
}
|