From 7c113fd5ab1fbc50356e1af10b65ac28448949fc Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 10:32:31 +0200 Subject: [PATCH] =?UTF-8?q?shortcuts:=20board=20arrow-nav,=20enter?= =?UTF-8?q?=E2=86=92preview=E2=86=92edit,=20help=20screen=20polish,=20n/a/?= =?UTF-8?q?f/j=20hotkeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board arrow-key card navigation (public/js/app.js): * ↑/↓/←/→ on the board (body focus, no overlay) move a single-card focus cursor (.card-focused, accent2 outline) through the visible card grid. Column count is computed live from the first row's offsetTop so it tracks the CSS grid columns at any viewport width. * First arrow press focuses the first card; subsequent presses move in the grid. Focused card scrolls into view (smooth, nearest). * Enter on a focused card → openDetail (preview). Focus cleared. * Enter in detail view → closeDetail + openModal (editor). Card ref is captured before closeDetail nulls state.viewingCard. * Click anywhere → focus cleared. Re-renders restore focus via window._restoreCardFocus() called from board.js renderBoard(). CSS (public/css/style.css): * .card-focused: accent2 outline + glow, visually distinct from .card-selected (accent, multi-select) and .preview-active. * .shortcut-table kbd keycaps: JetBrains Mono font (not pixel), 10px, 3D raised look with gradient + bottom border + inner highlight + shadow. * Responsive shortcut grid: 3 cols > 900px, 2 cols > 640px, 1 col. New bare-key shortcuts (public/js/app.js): * n → openModal(null) (new card, same as the + button) * a → activateBucket('all') * f → activateBucket('favorites') * j → open today's journal card (find by YYYY-MM-DD prefix in state.cards), or create a new one (switch to journal view, open empty editor which auto-fills the date title) Ctrl+N quick capture (public/js/app.js): * Now sends bucket_id: state.activeBucket on the POST (unless the active view is a virtual filter like all/current/etc., in which case bucket_id stays null). Cards captured while viewing a user bucket land directly in that bucket. Shortcuts help screen (public/index.php): * max-w-4xl, 3-column grid layout. * Complete shortcut inventory across all modules: - Navigation: a/f/j/n/t/c/m, 1-9, Tab/Shift+Tab, / - Global: Ctrl+K/N/Z, Esc, F1, ?, Del - Editor: Ctrl+S, Ctrl+Shift+S, Ctrl+Enter, Tab, Alt+↑↓, Shift+Alt+↑↓, Enter (list continue), [[ (link ac), # (tag ac), ?snippet - Search: ↑↓ navigate, Enter open, Esc close - Board+Detail: arrows focus, Enter→preview, Enter→editor, ←→ group nav, click/dbl/right/ctrl+click, drag edge/center - Canvas: b/e/l/r/o/f/t tool shortcuts, ⌫ delete - Mail: ⌫ delete selected, Shift+⌫ skip confirm * kbd elements use normal font (JetBrains Mono) instead of pixel. * Help overlay's old shortcuts section replaced with a one-liner pointing to ? for the full reference. * #shortcuts added to runner.js closeAll() list. chore: bump version to 0.9.12 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/css/style.css | 41 ++++++++ public/index.php | 118 +++++++++++++++++++++-- public/js/app.js | 166 ++++++++++++++++++++++++++++++++- public/js/modules/board.js | 3 + public/plugins/tests/runner.js | 2 +- 6 files changed, 322 insertions(+), 10 deletions(-) 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'); }