diff --git a/config.php b/config.php
index 8c32ba3..e4b944f 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
-
-
-
-
- navigation
-
- | A | all cards |
- | F | favorites |
- | J | today's journal |
- | N | new card |
- | T | tag quick-pick |
- | C | calendar |
- | M | mail |
- | 1 – 9 | Nth user bucket |
- | ⇥ | next bucket |
- | ⇧ ⇥ | prev bucket |
- | / | search |
-
-
-
- global
-
- | ⌃ K | search |
- | ⌃ N | quick capture (in bucket) |
- | ⌃ Z | undo |
- | Esc | close overlay |
- | F1 | help |
- | ? | this screen |
- | Del | delete selected |
-
-
-
-
-
-
- editor
-
- | ⌃ S | save |
- | ⌃ ⇧ S | save + close |
- | ⌃ ↵ | save + close |
- | ⇥ | indent / title→body |
- | Alt ↑ ↓ | move line |
- | ⇧ Alt ↑ ↓ | duplicate line |
- | ↵ | auto-continue list |
- | [[ | link autocomplete |
- | # | tag autocomplete |
- | ?snippet | snippet expand |
-
-
-
- search
-
- | ↑ ↓ | navigate results |
- | ↵ | open selected result |
- | Esc | close search |
-
-
-
-
-
-
- board + detail
-
- | ↑ ↓ ← → | focus card |
- | ↵ | focused → preview |
- | ↵ in preview | → editor |
- | ← → in preview | group prev / next |
- | click | preview / split |
- | dbl-click | editor |
- | right-click | context menu |
- | ⌃ click | multi-select |
- | drag → edge | reorder |
- | drag → center | group |
-
-
-
- canvas (ascii editor)
-
- | B | brush |
- | E | eraser |
- | L | line |
- | R | rectangle |
- | O | circle |
- | F | fill |
- | T | text cursor |
- | ⌫ | delete char |
-
-
-
- mail
-
- | ⌫ | delete selected mails |
- | ⇧ ⌫ | delete (skip confirm) |
-
-
-
-
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 34215ac..c0e8c03 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -13,6 +13,7 @@ import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
+import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
@@ -119,11 +120,6 @@ function closeShortcuts() { shortcutsOverlay?.classList.remove('open'); }
document.getElementById('shortcuts-btn')?.addEventListener('click', openShortcuts);
document.getElementById('shortcuts-close')?.addEventListener('click', closeShortcuts);
shortcutsOverlay?.addEventListener('click', e => { if (e.target === shortcutsOverlay) closeShortcuts(); });
-document.addEventListener('keydown', e => {
- if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
- if (e.key === '?' && document.activeElement === document.body) { e.preventDefault(); shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts(); }
-});
-
// ── Export / Import ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
@@ -201,228 +197,151 @@ async function checkAuth() {
startBackgroundSync();
}
-// ── Global keyboard ──
+// ── Keyboard shortcuts (central registry) ──
+//
+// All document-level shortcuts are registered here via registerShortcut().
+// The dispatcher in shortcuts.js handles key matching, guards, and
+// priority ordering. Element-specific shortcuts (editor textarea, search
+// input, canvas, dialog) stay on their respective DOM elements.
-document.addEventListener('keydown', e => {
- const modal = document.getElementById('modal');
- const detail = document.getElementById('detail');
- const search = document.getElementById('search');
- const tasks = document.getElementById('tasks');
- if (isDialogOpen()) return;
+const { board: gBoard } = guards;
- if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
- if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
- e.preventDefault();
- const qc = document.getElementById('quick-capture');
- const qci = document.getElementById('quick-capture-input');
- if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); }
- else { qc.classList.add('hidden'); qci.value = ''; }
- return;
- }
- if (e.key === 'Escape') {
- const qc = document.getElementById('quick-capture');
- if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; }
- if (detail.classList.contains('open')) {
- // If the user chose "exit zen first", ESC leaves zen mode before closing the card.
- const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close';
- if (escAction === 'exitZen' && detail.classList.contains('zen')) {
- document.getElementById('detail-zen')?.click();
- return;
- }
- closeDetail();
- }
- else if (modal.classList.contains('open')) closeModal();
- else if (search.classList.contains('open')) closeSearch();
- else if (tasks?.classList.contains('open')) closeTasks();
- else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
- else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar();
- else if (shortcutsOverlay?.classList.contains('open')) closeShortcuts();
- return;
- }
- if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
- if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
- if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
- // Bare "n" → open editor for a new card (same as clicking the + button).
- if (e.key === 'n' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- e.preventDefault();
- openModal(null);
- return;
- }
- // Bare "a" → bucket all, "f" → bucket favorites. Same guards as the
- // other bare-key shortcuts.
- if ((e.key === 'a' || e.key === 'f') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- e.preventDefault();
- activateBucket(e.key === 'a' ? 'all' : 'favorites');
- return;
- }
- // Bare "j" → open today's journal card (or create one if it doesn't
- // exist). Journal cards have a YYYY-MM-DD title prefix.
- if (e.key === 'j' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- e.preventDefault();
- const today = new Date().toISOString().substring(0, 10);
- const existing = state.cards.find(c => (c.title || '').startsWith(today));
- if (existing) {
- openModal(existing);
- } else {
- // Switch to journal view so openModal picks up the journal
- // context (auto-fills the date title).
- activateBucket('journal');
- openModal(null);
- }
- return;
- }
- // Bare "c" toggles the calendar from the board. Same guards as "/":
- // no modifier, focus on (so it doesn't intercept text typing),
- // and no full-screen overlay already in front.
- if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const calendar = document.getElementById('calendar');
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- e.preventDefault();
- if (calendar?.classList.contains('open')) closeCalendar();
- else openCalendar();
- return;
- }
- // Bare "t" opens the tag quick-pick on the board. Same focus +
- // overlay guards as "c". Picking a tag sets state.activeTags = [tag].
- if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- e.preventDefault();
- openTagQuickPick();
- return;
- }
- // Tab / Shift+Tab cycles through ALL bucket tiles in bar order
- // (specials + user buckets + super-buckets). Wraps around at both
- // ends. Same focus + overlay guards as the other bare-key shortcuts.
- if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
- if (!tiles.length) return;
- // Find which tile is currently active
- const curIdx = tiles.findIndex(t => {
- const bid = t.dataset.bucket || t.dataset.bucketSuper;
- if (bid == state.activeBucket) return true;
- // Super-bucket: active if any child matches
- if (t.dataset.bucketSuper) {
- return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket);
- }
- return false;
- });
- const dir = e.shiftKey ? -1 : 1;
- const nextIdx = (curIdx + dir + tiles.length) % tiles.length;
- const next = tiles[nextIdx];
- if (next) {
- e.preventDefault();
- const target = next.dataset.bucketSuper
- ? (next.dataset.superShown || next.dataset.bucketSuper)
- : next.dataset.bucket;
- activateBucket(target);
- }
- return;
- }
- // Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters
- // (all/current/favorites/journal/none) are skipped, super-buckets
- // count as one slot and activate via activateBucket() so the
- // dropdown popover is NOT triggered.
- if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
- const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
- const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]
- .filter(t => !SPECIAL.has(t.dataset.bucket));
- const idx = parseInt(e.key, 10) - 1;
- const tile = tiles[idx];
- if (tile) {
- e.preventDefault();
- // Super-bucket: activate the currently shown member (parent or
- // active child) directly instead of clicking, which would open
- // the dropdown popover. Regular bucket: just take its data-bucket.
- const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket;
- activateBucket(target);
- }
- return;
- }
+// — Escape chain (highest priority first) —
+registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
+registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
+registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
+registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
+registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
+registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() });
+registerShortcut({ key: 'Escape', priority: 40, guard: () => !isDialogOpen() && guards.isOpen('calendar'), section: 'global', label: 'close calendar', hidden: true, handler: () => closeCalendar() });
+registerShortcut({ key: 'Escape', priority: 30, guard: () => !isDialogOpen() && guards.isOpen('shortcuts'), section: 'global', label: 'close shortcuts', hidden: true, handler: () => closeShortcuts() });
+registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() });
+// Visible Escape entry for the help screen
+registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: 'Esc', label: 'close any overlay' });
- // Arrow keys navigate group in detail view
- if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
- const gc = getGroupCards(state.viewingCard);
- if (!gc || gc.length < 2) return;
- const idx = gc.findIndex(c => c.id == state.viewingCard.id);
- if (idx === -1) return;
- e.preventDefault();
- showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
+// — Global (work everywhere unless dialog is open) —
+registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '⌃ K', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
+registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '⌃ N', label: 'quick capture (in bucket)', handler: () => { const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } } });
+registerShortcut({ key: 'z', ctrl: true, guard: () => !isDialogOpen() && !guards.isOpen('modal') && canUndo(), section: 'global', keys: '⌃ Z', label: 'undo', handler: () => undo() });
+registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: 'F1', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
+registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '?', label: 'this screen', handler: () => shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts() });
+
+// — Editor (modal open) —
+registerShortcut({ key: 's', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ S', label: 'save', handler: () => saveOnly() });
+registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ ⇧ S', label: 'save + close', handler: () => saveCard() });
+registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ ↵', label: 'save + close', handler: () => saveCard() });
+// Editor element-level shortcuts listed for help screen only (handled by editor.js)
+registerShortcut({ key: 'Tab', guard: () => false, section: 'editor', keys: '⇥', label: 'indent / title→body' });
+registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: 'Alt ↑ ↓', label: 'move line' });
+registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '⇧ Alt ↑ ↓', label: 'duplicate line' });
+registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '↵', label: 'auto-continue list' });
+registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '[[', label: 'link autocomplete' });
+registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '#', label: 'tag autocomplete' });
+registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '?snippet', label: 'snippet expand' });
+
+// — Detail view —
+registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '← → in preview', label: 'group prev / next', handler: (e) => {
+ const gc = getGroupCards(state.viewingCard);
+ if (!gc || gc.length < 2) return;
+ const idx = gc.findIndex(c => c.id == state.viewingCard.id);
+ if (idx === -1) return;
+ showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
+}});
+registerShortcut({ key: 'Enter', guard: () => !isDialogOpen() && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '↵ in preview', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
+
+// — Board navigation (body focus, no overlay) —
+registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: 'A', label: 'all cards', handler: () => activateBucket('all') });
+registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: 'F', label: 'favorites', handler: () => activateBucket('favorites') });
+registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: 'N', label: 'new card', handler: () => openModal(null) });
+registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: 'J', label: 'today\'s journal', handler: () => {
+ const today = new Date().toISOString().substring(0, 10);
+ const existing = state.cards.find(c => (c.title || '').startsWith(today));
+ if (existing) openModal(existing);
+ else { activateBucket('journal'); openModal(null); }
+}});
+registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: 'T', label: 'tag quick-pick', handler: () => openTagQuickPick() });
+registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: 'C', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
+registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: 'M', label: 'mail (plugin)' });
+registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '/', label: 'search', handler: () => openSearch() });
+registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '⇥ / ⇧ ⇥', label: 'cycle buckets', handler: (e) => {
+ const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
+ if (!tiles.length) return;
+ const curIdx = tiles.findIndex(t => {
+ const bid = t.dataset.bucket || t.dataset.bucketSuper;
+ if (bid == state.activeBucket) return true;
+ if (t.dataset.bucketSuper) return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket);
+ return false;
+ });
+ const dir = e.shiftKey ? -1 : 1;
+ const next = tiles[(curIdx + dir + tiles.length) % tiles.length];
+ if (next) activateBucket(next.dataset.bucketSuper ? (next.dataset.superShown || next.dataset.bucketSuper) : next.dataset.bucket);
+}});
+registerShortcut({ match: (e) => /^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && gBoard(), section: 'navigation', keys: '1 – 9', label: 'Nth user bucket', handler: (e) => {
+ const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
+ const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')].filter(t => !SPECIAL.has(t.dataset.bucket));
+ const tile = tiles[parseInt(e.key, 10) - 1];
+ if (tile) activateBucket(tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket);
+}});
+
+// — Board arrow-key card navigation + Enter —
+registerShortcut({ match: (e) => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && gBoard(), section: 'board + detail', keys: '↑ ↓ ← →', label: 'focus card', handler: (e) => {
+ const board = document.getElementById('board');
+ if (!board) return;
+ const cards = [...board.querySelectorAll('.board-card[data-id]')];
+ if (!cards.length) return;
+ const focused = board.querySelector('.card-focused');
+ let curIdx = focused ? cards.indexOf(focused) : -1;
+ if (curIdx < 0) { setCardFocus(cards[0]); return; }
+ const firstTop = cards[0].offsetTop;
+ let cols = cards.length;
+ for (let i = 1; i < cards.length; i++) { if (cards[i].offsetTop !== firstTop) { cols = i; break; } }
+ let next = curIdx;
+ if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1);
+ else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0);
+ else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1);
+ else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0);
+ if (next !== curIdx) setCardFocus(cards[next]);
+}});
+registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelector('#board .card-focused'), section: 'board + detail', keys: '↵', label: 'focused → preview', handler: () => {
+ const focused = document.querySelector('#board .card-focused');
+ if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
+}});
+// Delete selected cards
+registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: 'Del', label: 'delete selected', hidden: true, handler: async () => {
+ // Delegated to board.js's existing handler via a synthetic event is
+ // impractical; re-implement the small logic here to avoid coupling.
+ const ids = getSelectedIds();
+ if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
+ const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
+ if (!ok) return;
}
+ for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
+ clearSelection();
+ await window._reloadAll();
+}});
- // Enter in detail view → close preview, then open editor
- if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && detail.classList.contains('open') && state.viewingCard) {
- e.preventDefault();
- const card = state.viewingCard;
- closeDetail();
- openModal(card);
- return;
- }
+// — Search (help-screen-only, handled by search.js element listener) —
+registerShortcut({ key: 'ArrowDown', guard: () => false, section: 'search', keys: '↑ ↓', label: 'navigate results' });
+registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '↵', label: 'open selected result' });
+registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: 'Esc', label: 'close search' });
- // ── Board arrow-key card navigation ──
- // Arrow keys select a card on the board (different from Ctrl+click
- // multi-select — this is a single-focus cursor). Enter on a focused
- // card opens the detail view. Escape clears the focus. The focus
- // indicator is a `.card-focused` outline (accent2, distinct from the
- // multi-select accent outline).
- const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
- if ((isArrow || e.key === 'Enter') && document.activeElement === document.body) {
- const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
- if (blockers.some(el => el?.classList.contains('open'))) return;
+// — Board mouse actions (help-screen-only) —
+registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: 'click', label: 'preview / split' });
+registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: 'dbl-click', label: 'editor' });
+registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: 'right-click', label: 'context menu' });
+registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '⌃ click', label: 'multi-select' });
+registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: 'drag → edge', label: 'reorder' });
+registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: 'drag → center', label: 'group' });
- const board = document.getElementById('board');
- if (!board) return;
- const cards = [...board.querySelectorAll('.board-card[data-id]')];
- if (!cards.length) return;
+// — Canvas (help-screen-only, handled by canvas.js overlay listener) —
+for (const [k, lbl] of [['B','brush'],['E','eraser'],['L','line'],['R','rectangle'],['O','circle'],['F','fill'],['T','text cursor']]) {
+ registerShortcut({ key: k.toLowerCase(), guard: () => false, section: 'canvas', keys: `${k}`, label: lbl });
+}
+registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '⌫', label: 'delete char' });
- if (e.key === 'Enter') {
- const focused = board.querySelector('.card-focused');
- if (focused) {
- e.preventDefault();
- window._openDetail(focused.dataset.id);
- clearCardFocus();
- }
- return;
- }
-
- e.preventDefault();
- const focused = board.querySelector('.card-focused');
- let curIdx = focused ? cards.indexOf(focused) : -1;
-
- if (curIdx < 0) {
- // No focus yet → select the first card
- setCardFocus(cards[0]);
- return;
- }
-
- // Compute columns per row from the grid layout
- const firstTop = cards[0].offsetTop;
- let cols = cards.length;
- for (let i = 1; i < cards.length; i++) {
- if (cards[i].offsetTop !== firstTop) { cols = i; break; }
- }
-
- let next = curIdx;
- if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1);
- else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0);
- else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1);
- else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0);
-
- if (next !== curIdx) setCardFocus(cards[next]);
- }
-});
+// — Mail (help-screen-only, handled by email.js document listener) —
+registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '⌫', label: 'delete selected mails' });
+registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '⇧ ⌫', label: 'delete (skip confirm)' });
let focusedCardId = null;
function setCardFocus(el) {
@@ -588,6 +507,7 @@ function openTagQuickPick() {
// ── Boot ──
+renderShortcutHelp();
checkAuth();
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index 6131a30..4c6298a 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -119,21 +119,9 @@ function clearSelection() {
export function getSelectedIds() { return [...selectedIds]; }
export { clearSelection };
-// Delete/Backspace deletes selected cards
-document.addEventListener('keydown', async e => {
- if (!selectedIds.size) return;
- if (e.key !== 'Delete' && e.key !== 'Backspace') return;
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
- e.preventDefault();
- if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
- const { showDialog } = await import('./dialog.js');
- const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
- if (!ok) return;
- }
- for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
- clearSelection();
- await window._reloadAll();
-});
+// Delete/Backspace for selected cards is now in the central shortcuts
+// registry (app.js registerShortcut). It uses getSelectedIds() /
+// clearSelection() which are already exported.
// Clear selection on click outside board (header, footer, etc)
document.addEventListener('click', e => {
diff --git a/public/js/modules/shortcuts.js b/public/js/modules/shortcuts.js
new file mode 100644
index 0000000..8960543
--- /dev/null
+++ b/public/js/modules/shortcuts.js
@@ -0,0 +1,155 @@
+// ── 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 += `
+ ${esc(name)}
+ `;
+ for (const s of shortcuts) {
+ const keysHtml = s.keys
+ ? s.keys // pre-formatted HTML (kbd tags etc.)
+ : `${esc(s.key || '?')}`;
+ html += `| ${keysHtml} | ${esc(s.label)} |
`;
+ }
+ html += `
`;
+ }
+ container.innerHTML = html;
+}
+
+function esc(s) {
+ return String(s ?? '').replace(/&/g, '&').replace(//g, '>');
+}
diff --git a/public/plugins/tests/card-interactions.test.js b/public/plugins/tests/card-interactions.test.js
index 80262a8..917db40 100644
--- a/public/plugins/tests/card-interactions.test.js
+++ b/public/plugins/tests/card-interactions.test.js
@@ -59,10 +59,15 @@ describe('Card: Due Date & Done', () => {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2099-12-31' }),
});
+ state.activeBucket = 'all';
await window._reloadAll?.();
- await sleep(200);
-
- const cardEl = document.querySelector(`[data-id="${res.id}"]`);
+ // Poll for the card to appear in the DOM instead of a fixed sleep
+ let cardEl = null;
+ for (let i = 0; i < 40; i++) {
+ cardEl = document.querySelector(`[data-id="${res.id}"]`);
+ if (cardEl) break;
+ await sleep(50);
+ }
const pill = cardEl?.querySelector('.due-pill');
expect(pill).toExist();
expect(pill.textContent).toContain('2099-12-31');
@@ -92,12 +97,15 @@ describe('Card: Due Date & Done', () => {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ due_date: '2020-01-01' }),
});
+ state.activeBucket = 'all';
await window._reloadAll?.();
- await sleep(200);
-
- const cardEl = document.querySelector(`[data-id="${res.id}"]`);
+ let cardEl = null;
+ for (let i = 0; i < 40; i++) {
+ cardEl = document.querySelector(`[data-id="${res.id}"]`);
+ if (cardEl) break;
+ await sleep(50);
+ }
expect(cardEl).toExist();
- // Overdue cards get danger border via inline style
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
});
});
@@ -259,13 +267,14 @@ describe('Card: Grouping', () => {
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
}).then(r => r.json());
+ state.activeBucket = 'all';
await window._reloadAll?.();
- await sleep(200);
-
- // Child should not be a standalone card on board
- const childEl = document.querySelector(`[data-id="${child.id}"]`);
- // It may or may not render depending on group view, but parent should exist
- const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
+ let parentEl = null;
+ for (let i = 0; i < 40; i++) {
+ parentEl = document.querySelector(`[data-id="${parent.id}"]`);
+ if (parentEl) break;
+ await sleep(50);
+ }
expect(parentEl).toExist();
});
});
diff --git a/public/plugins/tests/editor-features.test.js b/public/plugins/tests/editor-features.test.js
index 1659b90..3142e35 100644
--- a/public/plugins/tests/editor-features.test.js
+++ b/public/plugins/tests/editor-features.test.js
@@ -68,7 +68,13 @@ describe('Editor: Save Card', () => {
await sleep(50);
click('#modal-save-close');
- await sleep(400);
+ // doSave(true) does API PATCH + _reloadAll (2-3 network calls)
+ // before closeModal — poll until the modal is actually hidden
+ // instead of a fixed sleep that races against server speed.
+ for (let i = 0; i < 40; i++) {
+ if (!$('#modal')?.classList.contains('open')) break;
+ await sleep(50);
+ }
expect($('#modal')).toBeHidden();
// Card should exist