keyboard: global shortcuts + ? cheat-sheet
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s

App-shell keydown dispatcher (active only inside the authed shell):
- c        new note (jump to board / reopen composer via a ui-store signal)
- /        focus the search box
- g b/g/r  jump to Board / Graph / Reminders (two-key, 800ms window)
- ?        open a keyboard-shortcuts cheat-sheet overlay
- Esc      blur a focused field / close the cheat-sheet

Ignored while typing in an input/textarea/contenteditable (except Esc).
New stores/ui.ts carries the compose signal; QuickAdd exposes open(); the
board reopens the composer on the signal when already on the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 10:23:44 -04:00
co-authored by Claude Opus 4.8
parent d7c452ca2b
commit 4bd4ce5a1f
4 changed files with 160 additions and 2 deletions
+132 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelsModal from "./LabelsModal.vue"; import LabelsModal from "./LabelsModal.vue";
@@ -12,14 +13,105 @@ const router = useRouter();
const session = useSessionStore(); const session = useSessionStore();
const config = useConfigStore(); const config = useConfigStore();
const labels = useLabelsStore(); const labels = useLabelsStore();
const ui = useUiStore();
const managing = ref(false); const managing = ref(false);
const showShortcuts = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchText = ref(typeof route.query.q === "string" ? route.query.q : ""); const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
let searchTimer: ReturnType<typeof setTimeout> | undefined; let searchTimer: ReturnType<typeof setTimeout> | undefined;
const shortcuts = [
{ label: "New note", keys: ["c"] },
{ label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Save & close (editor)", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
{ label: "Dismiss / close", keys: ["Esc"] },
{ label: "This help", keys: ["?"] },
];
let gPending = false;
let gTimer: ReturnType<typeof setTimeout> | undefined;
function isTyping(e: KeyboardEvent): boolean {
const el = e.target as HTMLElement | null;
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
}
function focusSearch() {
searchInput.value?.focus();
searchInput.value?.select();
}
// `c` composes: jump to the board (which autofocuses its quick-add) or, if
// already there, ask the board to reopen the composer.
function compose() {
if (route.name !== "board") void router.push("/");
else ui.requestCompose();
}
// Global keyboard shortcuts, active only inside the authed shell. Ignored while
// typing in a field (except Esc, which blurs it). `g` starts a two-key jump
// (g b / g g / g r) with a short timeout.
function onKeydown(e: KeyboardEvent) {
if (isTyping(e)) {
if (e.key === "Escape") (e.target as HTMLElement).blur();
return;
}
if (e.key === "Escape" && showShortcuts.value) {
showShortcuts.value = false;
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (gPending) {
gPending = false;
clearTimeout(gTimer);
if (e.key === "b") {
e.preventDefault();
void router.push("/");
return;
}
if (e.key === "g") {
e.preventDefault();
void router.push("/graph");
return;
}
if (e.key === "r") {
e.preventDefault();
void router.push("/reminders");
return;
}
}
if (e.key === "/") {
e.preventDefault();
focusSearch();
return;
}
if (e.key === "c") {
e.preventDefault();
compose();
return;
}
if (e.key === "?") {
e.preventDefault();
showShortcuts.value = true;
return;
}
if (e.key === "g") {
gPending = true;
clearTimeout(gTimer);
gTimer = setTimeout(() => (gPending = false), 800);
}
}
onMounted(() => { onMounted(() => {
if (!labels.loaded) void labels.load(); if (!labels.loaded) void labels.load();
window.addEventListener("keydown", onKeydown);
}); });
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null)); const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
@@ -62,6 +154,7 @@ async function signOut() {
<div class="flex flex-1 justify-center"> <div class="flex flex-1 justify-center">
<input <input
ref="searchInput"
:value="searchText" :value="searchText"
type="search" type="search"
placeholder="Search notes…" placeholder="Search notes…"
@@ -144,5 +237,43 @@ async function signOut() {
</div> </div>
<LabelsModal v-if="managing" @close="managing = false" /> <LabelsModal v-if="managing" @close="managing = false" />
<div
v-if="showShortcuts"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
@click.self="showShortcuts = false"
>
<div
class="w-full max-w-sm rounded-xl border border-neutral-200 bg-white p-5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
<button
type="button"
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
aria-label="Close"
@click="showShortcuts = false"
>
×
</button>
</div>
<dl class="flex flex-col gap-2 text-sm">
<div v-for="s in shortcuts" :key="s.label" class="flex items-center justify-between gap-4">
<dt class="text-neutral-600 dark:text-neutral-300">{{ s.label }}</dt>
<dd class="flex items-center gap-1">
<kbd
v-for="(k, i) in s.keys"
:key="i"
class="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200"
>{{ k }}</kbd
>
</dd>
</div>
</dl>
</div>
</div>
</div> </div>
</template> </template>
+3
View File
@@ -71,6 +71,9 @@ onMounted(() => {
if (props.autofocus) void open(); if (props.autofocus) void open();
}); });
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentMousedown)); onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentMousedown));
// Let the board (via the global `c` shortcut) reopen + focus the composer.
defineExpose({ open });
</script> </script>
<template> <template>
+14
View File
@@ -0,0 +1,14 @@
import { defineStore } from "pinia";
import { ref } from "vue";
// Cross-component UI signals that don't belong to any single view (e.g. a
// global shortcut in the app shell asking the board to act).
export const useUiStore = defineStore("ui", () => {
// Bumped to ask the board to open + focus its quick-add composer.
const composeTick = ref(0);
function requestCompose() {
composeTick.value++;
}
return { composeTick, requestCompose };
});
+11 -1
View File
@@ -2,14 +2,24 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes"; import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import QuickAdd from "../components/QuickAdd.vue"; import QuickAdd from "../components/QuickAdd.vue";
import NoteCard from "../components/NoteCard.vue"; import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue"; import NoteEditor from "../components/NoteEditor.vue";
const notes = useNotesStore(); const notes = useNotesStore();
const route = useRoute(); const route = useRoute();
const ui = useUiStore();
const editing = ref<Note | null>(null); const editing = ref<Note | null>(null);
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
// The global `c` shortcut bumps composeTick; reopen the composer when we're
// already on the board (a fresh navigation autofocuses it via the prop).
watch(
() => ui.composeTick,
() => quickAdd.value?.open(),
);
function viewForRoute(name: unknown): NoteView { function viewForRoute(name: unknown): NoteView {
if (name === "archive") return "archived"; if (name === "archive") return "archived";
@@ -77,7 +87,7 @@ async function onDrop(target: Note) {
<template> <template>
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <div class="mx-auto w-full max-w-6xl px-4 py-6">
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" /> <QuickAdd v-if="isMainBoard" ref="quickAdd" autofocus class="mb-8" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div> <div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>