diff --git a/config.php b/config.php index 79377c1..8c32ba3 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ and inherits the dim color from the td. */ +.shortcut-table kbd { + display:inline-block; + min-width:20px; text-align:center; + padding:3px 7px; + background: linear-gradient(180deg, color-mix(in srgb, var(--t-raised) 90%, white 10%), var(--t-bg)); + border:1px solid var(--t-border); + border-bottom-width:2px; + border-radius:4px; + font-family:var(--t-font-content, 'JetBrains Mono', monospace); + font-size:10px; + color:var(--t-bright); + line-height:1.4; + box-shadow: 0 1px 0 color-mix(in srgb, var(--t-border) 80%, black 20%), + inset 0 1px 0 color-mix(in srgb, var(--t-raised) 60%, white 15%); +} +.shortcut-table .mouse { + font-family:var(--t-font-pixel, 'Silkscreen', cursive); + font-size:9px; color:var(--t-dim); +} +@media (max-width: 900px) { + #shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr 1fr !important; } +} +@media (max-width: 640px) { + #shortcuts [style*="grid-template-columns"] { grid-template-columns: 1fr !important; } +} + /* Tag chips */ .tag-chip { display: inline-flex; align-items: center; padding: 1px 6px; diff --git a/public/index.php b/public/index.php index 03e2c19..f22ddae 100644 --- a/public/index.php +++ b/public/index.php @@ -1322,10 +1322,120 @@ if (str_starts_with($route, '/api/')) {
+
+ +
+
+
+ +

keyboard shortcuts

+
+ +
+
+ +
+
+

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)
+
+
+
+
+
+
@@ -1351,13 +1461,7 @@ if (str_starts_with($route, '/api/')) {

shortcuts

- - - - - - -
Ctrl+K open / close search
Ctrl+N quick capture — type title, Enter to save
Ctrl+Z undo last action
F1 open / close help
Escape close any overlay
+

Press ? for the full keyboard reference.

diff --git a/public/js/app.js b/public/js/app.js index 825875d..34215ac 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -112,8 +112,16 @@ function closeHelp() { helpOverlay?.classList.remove('open'); } document.getElementById('help-btn')?.addEventListener('click', openHelp); document.getElementById('help-close')?.addEventListener('click', closeHelp); helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); }); + +const shortcutsOverlay = document.getElementById('shortcuts'); +function openShortcuts() { shortcutsOverlay?.classList.add('open'); } +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 ── @@ -228,12 +236,48 @@ document.addEventListener('keydown', e => { 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. @@ -255,6 +299,36 @@ document.addEventListener('keydown', e => { 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 @@ -287,8 +361,93 @@ document.addEventListener('keydown', e => { e.preventDefault(); showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id); } + + // 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; + } + + // ── 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; + + const board = document.getElementById('board'); + if (!board) return; + const cards = [...board.querySelectorAll('.board-card[data-id]')]; + if (!cards.length) return; + + 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]); + } }); +let focusedCardId = null; +function setCardFocus(el) { + clearCardFocus(); + if (!el) return; + el.classList.add('card-focused'); + focusedCardId = el.dataset.id; + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); +} +function clearCardFocus() { + document.querySelectorAll('.card-focused').forEach(c => c.classList.remove('card-focused')); + focusedCardId = null; +} +// Clear focus when clicking anywhere on the board (a card click opens +// detail/editor and focus is no longer meaningful), or when any overlay +// opens. Also re-apply after a board re-render if the focused card is +// still in the DOM. +document.addEventListener('click', () => { if (focusedCardId) clearCardFocus(); }); +window._restoreCardFocus = function () { + if (!focusedCardId) return; + const el = document.querySelector(`#board .board-card[data-id="${focusedCardId}"]`); + if (el) el.classList.add('card-focused'); + else clearCardFocus(); +}; + // ── Quick Capture ── document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => { @@ -318,7 +477,12 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn if (!cardTitle) return; if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox'; - await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1 } }); + // Place the card in the currently active bucket (if it's a real + // user bucket). Special filters (all/current/favorites/journal/none) + // don't map to a bucket_id — cards captured there stay unsorted. + const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none']; + const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket; + await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } }); e.target.value = ''; document.getElementById('quick-capture').classList.add('hidden'); diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 225ad65..6131a30 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -714,6 +714,9 @@ export function renderBoard() { }); } + // Restore arrow-key card focus if the focused card is still rendered + window._restoreCardFocus?.(); + // Restore selection state selectedIds.forEach(id => { const el = board.querySelector(`[data-id="${id}"]`); diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js index 0c97f48..66a5211 100644 --- a/public/plugins/tests/runner.js +++ b/public/plugins/tests/runner.js @@ -171,7 +171,7 @@ export function csrf() { } export async function closeAll() { - for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) { + for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) { const el = document.getElementById(id); if (el?.classList.contains('open')) el.classList.remove('open'); }