keyboard: global shortcuts + ? cheat-sheet
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:
@@ -1,9 +1,10 @@
|
||||
<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 { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
|
||||
@@ -12,14 +13,105 @@ const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const labels = useLabelsStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
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 : "");
|
||||
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(() => {
|
||||
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));
|
||||
|
||||
@@ -62,6 +154,7 @@ async function signOut() {
|
||||
|
||||
<div class="flex flex-1 justify-center">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchText"
|
||||
type="search"
|
||||
placeholder="Search notes…"
|
||||
@@ -144,5 +237,43 @@ async function signOut() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -71,6 +71,9 @@ onMounted(() => {
|
||||
if (props.autofocus) void open();
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentMousedown));
|
||||
|
||||
// Let the board (via the global `c` shortcut) reopen + focus the composer.
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user