shortcuts: central registry + auto-generated help, test stabilization
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>
This commit is contained in:
1 parent
7c113fd5ab
commit
b459159b0f
8 files changed
+337
-348
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.12');
|
||||
define('APP_VERSION', '0.9.13');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
@@ -838,11 +838,15 @@ body.anim-slow .board-card.card-pulse-new {
|
||||
font-family:var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size:9px; color:var(--t-dim);
|
||||
}
|
||||
/* The auto-generated shortcut help uses a 3-column grid on the
|
||||
.shortcut-help-content div. Collapse to 2 then 1 on smaller screens. */
|
||||
.shortcut-help-content { column-gap: 20px; }
|
||||
.shortcut-help-content section { break-inside: avoid; margin-bottom: 16px; }
|
||||
@media (max-width: 900px) {
|
||||
#shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr 1fr !important; }
|
||||
.shortcut-help-content { grid-template-columns: 1fr 1fr !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
#shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
|
||||
.shortcut-help-content { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
/* Tag chips */
|
||||
|
||||
+4
-97
@@ -1336,103 +1336,10 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div class="flex-1"></div>
|
||||
<button id="shortcuts-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||
</div>
|
||||
<div class="p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
|
||||
<!-- Column 1 -->
|
||||
<div class="space-y-5">
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">navigation</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>A</kbd></td><td>all cards</td></tr>
|
||||
<tr><td><kbd>F</kbd></td><td>favorites</td></tr>
|
||||
<tr><td><kbd>J</kbd></td><td>today's journal</td></tr>
|
||||
<tr><td><kbd>N</kbd></td><td>new card</td></tr>
|
||||
<tr><td><kbd>T</kbd></td><td>tag quick-pick</td></tr>
|
||||
<tr><td><kbd>C</kbd></td><td>calendar</td></tr>
|
||||
<tr><td><kbd>M</kbd></td><td>mail</td></tr>
|
||||
<tr><td><kbd>1</kbd> – <kbd>9</kbd></td><td>Nth user bucket</td></tr>
|
||||
<tr><td><kbd>⇥</kbd></td><td>next bucket</td></tr>
|
||||
<tr><td><kbd>⇧</kbd> <kbd>⇥</kbd></td><td>prev bucket</td></tr>
|
||||
<tr><td><kbd>/</kbd></td><td>search</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">global</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>K</kbd></td><td>search</td></tr>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>N</kbd></td><td>quick capture (in bucket)</td></tr>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>Z</kbd></td><td>undo</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>close overlay</td></tr>
|
||||
<tr><td><kbd>F1</kbd></td><td>help</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>this screen</td></tr>
|
||||
<tr><td><kbd>Del</kbd></td><td>delete selected</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Column 2 -->
|
||||
<div class="space-y-5">
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">editor</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>S</kbd></td><td>save</td></tr>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd></td><td>save + close</td></tr>
|
||||
<tr><td><kbd>⌃</kbd> <kbd>↵</kbd></td><td>save + close</td></tr>
|
||||
<tr><td><kbd>⇥</kbd></td><td>indent / title→body</td></tr>
|
||||
<tr><td><kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd></td><td>move line</td></tr>
|
||||
<tr><td><kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd></td><td>duplicate line</td></tr>
|
||||
<tr><td><kbd>↵</kbd></td><td>auto-continue list</td></tr>
|
||||
<tr><td><kbd>[</kbd><kbd>[</kbd></td><td>link autocomplete</td></tr>
|
||||
<tr><td><kbd>#</kbd></td><td>tag autocomplete</td></tr>
|
||||
<tr><td><kbd>?</kbd><em>snippet</em></td><td>snippet expand</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">search</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>↑</kbd> <kbd>↓</kbd></td><td>navigate results</td></tr>
|
||||
<tr><td><kbd>↵</kbd></td><td>open selected result</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>close search</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Column 3 -->
|
||||
<div class="space-y-5">
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">board + detail</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd></td><td>focus card</td></tr>
|
||||
<tr><td><kbd>↵</kbd></td><td>focused → preview</td></tr>
|
||||
<tr><td><kbd>↵</kbd> <span class="mouse">in preview</span></td><td>→ editor</td></tr>
|
||||
<tr><td><kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span></td><td>group prev / next</td></tr>
|
||||
<tr><td><span class="mouse">click</span></td><td>preview / split</td></tr>
|
||||
<tr><td><span class="mouse">dbl-click</span></td><td>editor</td></tr>
|
||||
<tr><td><span class="mouse">right-click</span></td><td>context menu</td></tr>
|
||||
<tr><td><kbd>⌃</kbd> <span class="mouse">click</span></td><td>multi-select</td></tr>
|
||||
<tr><td><span class="mouse">drag → edge</span></td><td>reorder</td></tr>
|
||||
<tr><td><span class="mouse">drag → center</span></td><td>group</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">canvas (ascii editor)</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>B</kbd></td><td>brush</td></tr>
|
||||
<tr><td><kbd>E</kbd></td><td>eraser</td></tr>
|
||||
<tr><td><kbd>L</kbd></td><td>line</td></tr>
|
||||
<tr><td><kbd>R</kbd></td><td>rectangle</td></tr>
|
||||
<tr><td><kbd>O</kbd></td><td>circle</td></tr>
|
||||
<tr><td><kbd>F</kbd></td><td>fill</td></tr>
|
||||
<tr><td><kbd>T</kbd></td><td>text cursor</td></tr>
|
||||
<tr><td><kbd>⌫</kbd></td><td>delete char</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">mail</h3>
|
||||
<table class="shortcut-table"><tbody>
|
||||
<tr><td><kbd>⌫</kbd></td><td>delete selected mails</td></tr>
|
||||
<tr><td><kbd>⇧</kbd> <kbd>⌫</kbd></td><td>delete (skip confirm)</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filled by shortcuts.js renderShortcutHelp() from the registry.
|
||||
Layout: 3 columns on desktop, 2 on tablet, 1 on mobile.
|
||||
The CSS grid is set on .shortcut-help-content. -->
|
||||
<div class="shortcut-help-content p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+139
-219
@@ -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 <body> (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: '<kbd>Esc</kbd>', 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: '<kbd>⌃</kbd> <kbd>K</kbd>', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
|
||||
registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>N</kbd>', 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: '<kbd>⌃</kbd> <kbd>Z</kbd>', label: 'undo', handler: () => undo() });
|
||||
registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>F1</kbd>', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
|
||||
registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '<kbd>?</kbd>', 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: '<kbd>⌃</kbd> <kbd>S</kbd>', label: 'save', handler: () => saveOnly() });
|
||||
registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd>', label: 'save + close', handler: () => saveCard() });
|
||||
registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>↵</kbd>', 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: '<kbd>⇥</kbd>', label: 'indent / title→body' });
|
||||
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'move line' });
|
||||
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'duplicate line' });
|
||||
registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<kbd>↵</kbd>', label: 'auto-continue list' });
|
||||
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
|
||||
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
|
||||
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', 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: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', 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: '<kbd>↵</kbd> <span class="mouse">in preview</span>', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
|
||||
|
||||
// — Board navigation (body focus, no overlay) —
|
||||
registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: '<kbd>A</kbd>', label: 'all cards', handler: () => activateBucket('all') });
|
||||
registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: '<kbd>F</kbd>', label: 'favorites', handler: () => activateBucket('favorites') });
|
||||
registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: '<kbd>N</kbd>', label: 'new card', handler: () => openModal(null) });
|
||||
registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J</kbd>', 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: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
|
||||
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
|
||||
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
|
||||
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
|
||||
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', 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: '<kbd>1</kbd> – <kbd>9</kbd>', 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: '<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd>', 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: '<kbd>↵</kbd>', 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: '<kbd>Del</kbd>', 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: '<kbd>↑</kbd> <kbd>↓</kbd>', label: 'navigate results' });
|
||||
registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '<kbd>↵</kbd>', label: 'open selected result' });
|
||||
registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: '<kbd>Esc</kbd>', 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: '<span class="mouse">click</span>', label: 'preview / split' });
|
||||
registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: '<span class="mouse">dbl-click</span>', label: 'editor' });
|
||||
registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: '<span class="mouse">right-click</span>', label: 'context menu' });
|
||||
registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '<kbd>⌃</kbd> <span class="mouse">click</span>', label: 'multi-select' });
|
||||
registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → edge</span>', label: 'reorder' });
|
||||
registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → center</span>', 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: `<kbd>${k}</kbd>`, label: lbl });
|
||||
}
|
||||
registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '<kbd>⌫</kbd>', 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: '<kbd>⌫</kbd>', label: 'delete selected mails' });
|
||||
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⇧</kbd> <kbd>⌫</kbd>', 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/) ──
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 += `<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, '>');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user