Files
hyprcrd/public/js/modules/shortcuts.js
T
thandClaude Opus 4.6 22a43fdef7 help: add relay, context menu, find/replace, assets, super-buckets docs; fix flaky tests
Updates help screen with all undocumented features. Fixes F1 blocked
by input suppression filter (allow function keys). Stabilizes card-join
tests with poll loop instead of fixed sleep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:11:52 +02:00

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
// (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 += `<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}