Skip to content

Commit 472e2e1

Browse files
committed
feat: add vim-style keyboard navigation
Add keyboard shortcuts for navigating the site: - j/k: move between items - h/l: previous/next page - o/Enter: open selected item's link - /: focus search input - 0: go home - gg/G: jump to first/last item Includes visual selection indicator and brief hint display.
1 parent a9ea6ce commit 472e2e1

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

internal/assets/css/screen.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1739,3 +1739,48 @@ html.fonts-loaded .material-symbols-rounded {
17391739
float: none;
17401740
}
17411741
}
1742+
1743+
/* ==================== VIM KEYBOARD NAVIGATION ==================== */
1744+
.item.vim-selected {
1745+
outline: 2px solid var(--accent-primary);
1746+
outline-offset: 4px;
1747+
border-radius: var(--radius-sm);
1748+
}
1749+
1750+
.item.vim-selected .link-card-wrapper,
1751+
.item.vim-selected .quote-card {
1752+
border-color: var(--accent-primary);
1753+
box-shadow: var(--shadow-md);
1754+
}
1755+
1756+
/* Vim mode indicator */
1757+
.vim-hint {
1758+
position: fixed;
1759+
bottom: 60px;
1760+
right: 20px;
1761+
background: var(--card-bg);
1762+
border: 1px solid var(--border-default);
1763+
border-radius: var(--radius-sm);
1764+
padding: var(--space-2) var(--space-3);
1765+
font-size: var(--font-size-xs);
1766+
color: var(--text-secondary);
1767+
font-family: var(--font-mono);
1768+
z-index: 200;
1769+
box-shadow: var(--shadow-md);
1770+
opacity: 0;
1771+
transition: opacity 0.2s ease;
1772+
pointer-events: none;
1773+
}
1774+
1775+
.vim-hint.visible {
1776+
opacity: 1;
1777+
}
1778+
1779+
.vim-hint kbd {
1780+
background: var(--bg-surface);
1781+
border: 1px solid var(--border-default);
1782+
border-radius: 3px;
1783+
padding: 1px 4px;
1784+
margin: 0 2px;
1785+
font-weight: var(--font-weight-semibold);
1786+
}

internal/templates/views/index.html

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,5 +702,210 @@
702702
}
703703
</script>
704704

705+
<!-- Vim Keyboard Navigation -->
706+
<script>
707+
(function() {
708+
var items = [];
709+
var currentIndex = -1;
710+
var hintTimeout = null;
711+
712+
// Create hint element
713+
var hint = document.createElement('div');
714+
hint.className = 'vim-hint';
715+
hint.innerHTML = '<kbd>j</kbd>/<kbd>k</kbd> navigate <kbd>h</kbd>/<kbd>l</kbd> pages <kbd>o</kbd> open <kbd>/</kbd> search <kbd>0</kbd> home';
716+
document.body.appendChild(hint);
717+
718+
function init() {
719+
items = Array.from(document.querySelectorAll('#content .item[data-url], #content .item:has(.quote-card)'));
720+
}
721+
722+
function showHint() {
723+
hint.classList.add('visible');
724+
if (hintTimeout) clearTimeout(hintTimeout);
725+
hintTimeout = setTimeout(function() {
726+
hint.classList.remove('visible');
727+
}, 2000);
728+
}
729+
730+
function selectItem(index) {
731+
// Remove previous selection
732+
if (currentIndex >= 0 && items[currentIndex]) {
733+
items[currentIndex].classList.remove('vim-selected');
734+
}
735+
736+
// Clamp index to valid range
737+
if (index < 0) index = 0;
738+
if (index >= items.length) index = items.length - 1;
739+
740+
currentIndex = index;
741+
742+
if (items[currentIndex]) {
743+
items[currentIndex].classList.add('vim-selected');
744+
// Scroll into view with offset for fixed header
745+
var rect = items[currentIndex].getBoundingClientRect();
746+
var headerHeight = 76; // 56px header + 20px spacing
747+
if (rect.top < headerHeight) {
748+
window.scrollBy(0, rect.top - headerHeight);
749+
} else if (rect.bottom > window.innerHeight - 60) {
750+
window.scrollBy(0, rect.bottom - window.innerHeight + 60);
751+
}
752+
}
753+
}
754+
755+
function openSelectedItem() {
756+
if (currentIndex < 0 || !items[currentIndex]) return;
757+
var item = items[currentIndex];
758+
759+
// Try to find the main link
760+
var link = item.querySelector('.link a') ||
761+
item.querySelector('.quote-link') ||
762+
item.querySelector('a[href]');
763+
764+
if (link && link.href) {
765+
window.open(link.href, '_blank');
766+
}
767+
}
768+
769+
function navigatePage(direction) {
770+
var nav = document.querySelector('#navigation');
771+
if (!nav) return;
772+
773+
var links = nav.querySelectorAll('a.nav-link');
774+
if (links.length === 0) return;
775+
776+
// h = previous (older), l = next (newer)
777+
// NavP is "previous" (older content), NavN is "next" (newer content)
778+
if (direction === 'prev') {
779+
// Find link with chevron_left (previous/older)
780+
for (var i = 0; i < links.length; i++) {
781+
if (links[i].textContent.indexOf('chevron_left') !== -1 ||
782+
links[i].querySelector('.material-symbols-rounded')?.textContent === 'chevron_left') {
783+
links[i].click();
784+
return;
785+
}
786+
}
787+
// Fallback: first link is usually prev
788+
if (links[0]) links[0].click();
789+
} else {
790+
// Find link with chevron_right (next/newer)
791+
for (var i = 0; i < links.length; i++) {
792+
if (links[i].textContent.indexOf('chevron_right') !== -1 ||
793+
links[i].querySelector('.material-symbols-rounded')?.textContent === 'chevron_right') {
794+
links[i].click();
795+
return;
796+
}
797+
}
798+
// Fallback: last link is usually next
799+
if (links[links.length - 1]) links[links.length - 1].click();
800+
}
801+
}
802+
803+
function handleKeydown(e) {
804+
// Ignore if typing in input/textarea
805+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
806+
return;
807+
}
808+
809+
// Ignore if modifier keys are pressed (except shift)
810+
if (e.ctrlKey || e.altKey || e.metaKey) {
811+
return;
812+
}
813+
814+
var key = e.key.toLowerCase();
815+
816+
switch (key) {
817+
case 'j':
818+
e.preventDefault();
819+
if (currentIndex === -1) {
820+
selectItem(0);
821+
} else {
822+
selectItem(currentIndex + 1);
823+
}
824+
showHint();
825+
break;
826+
827+
case 'k':
828+
e.preventDefault();
829+
if (currentIndex === -1) {
830+
selectItem(items.length - 1);
831+
} else {
832+
selectItem(currentIndex - 1);
833+
}
834+
showHint();
835+
break;
836+
837+
case 'h':
838+
e.preventDefault();
839+
navigatePage('prev');
840+
break;
841+
842+
case 'l':
843+
e.preventDefault();
844+
navigatePage('next');
845+
break;
846+
847+
case 'o':
848+
case 'enter':
849+
if (currentIndex >= 0) {
850+
e.preventDefault();
851+
openSelectedItem();
852+
}
853+
break;
854+
855+
case 'g':
856+
// gg to go to top (first item)
857+
if (e.shiftKey) {
858+
// G = go to last item
859+
e.preventDefault();
860+
selectItem(items.length - 1);
861+
showHint();
862+
}
863+
break;
864+
865+
case '/':
866+
e.preventDefault();
867+
var searchInput = document.querySelector('.nav-header input[name="search"]');
868+
if (searchInput) {
869+
searchInput.focus();
870+
searchInput.select();
871+
}
872+
break;
873+
874+
case '0':
875+
e.preventDefault();
876+
window.location = window.location.origin + '/';
877+
break;
878+
}
879+
}
880+
881+
// Handle gg (double g) for going to top
882+
var lastGTime = 0;
883+
document.addEventListener('keydown', function(e) {
884+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
885+
if (e.key.toLowerCase() === 'g' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
886+
var now = Date.now();
887+
if (now - lastGTime < 500) {
888+
e.preventDefault();
889+
selectItem(0);
890+
window.scrollTo(0, 0);
891+
showHint();
892+
lastGTime = 0;
893+
} else {
894+
lastGTime = now;
895+
}
896+
}
897+
});
898+
899+
// Initialize
900+
if (document.readyState === 'loading') {
901+
document.addEventListener('DOMContentLoaded', init);
902+
} else {
903+
init();
904+
}
905+
906+
document.addEventListener('keydown', handleKeydown);
907+
})();
908+
</script>
909+
705910
</body>
706911
</html>

0 commit comments

Comments
 (0)