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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user