diff --git a/config.php b/config.php index 8c32ba3..e4b944f 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ -
- -
-
-

navigation

- - - - - - - - - - - - -
Aall cards
Ffavorites
Jtoday's journal
Nnew card
Ttag quick-pick
Ccalendar
Mmail
1 – 9Nth user bucket
⇥next bucket
⇧ ⇥prev bucket
/search
-
-
-

global

- - - - - - - - -
⌃ Ksearch
⌃ Nquick capture (in bucket)
⌃ Zundo
Escclose overlay
F1help
?this screen
Deldelete selected
-
-
- -
-
-

editor

- - - - - - - - - - - -
⌃ Ssave
⌃ ⇧ Ssave + close
⌃ ↵save + close
⇥indent / title→body
Alt ↑ ↓move line
⇧ Alt ↑ ↓duplicate line
↵auto-continue list
[[link autocomplete
#tag autocomplete
?snippetsnippet expand
-
-
-

search

- - - - -
↑ ↓navigate results
↵open selected result
Escclose search
-
-
- -
-
-

board + detail

- - - - - - - - - - - -
↑ ↓ ← →focus card
↵focused → preview
↵ in preview→ editor
← → in previewgroup prev / next
clickpreview / split
dbl-clickeditor
right-clickcontext menu
⌃ clickmulti-select
drag → edgereorder
drag → centergroup
-
-
-

canvas (ascii editor)

- - - - - - - - - -
Bbrush
Eeraser
Lline
Rrectangle
Ocircle
Ffill
Ttext 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 += ``; + } + html += `
${keysHtml}${esc(s.label)}
`; + } + 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