keyboard: command palette (Cmd/Ctrl+K)
Fuzzy quick-open overlay: type to jump to any note by title (titles index) or run a command — New note, Go to Board/Graph/Reminders/Archive/Trash, and Open Settings (admins). Arrow keys move the selection, Enter activates, Esc closes; Cmd/Ctrl+K toggles it (works even while typing). Selecting a note navigates to the board with ?open=<id>; BoardView watches that query and opens the editor (fetching the note if it isn't loaded), then clears the param. Added to the ? cheat-sheet. 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:
@@ -5,6 +5,7 @@ import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import CommandPalette from "./CommandPalette.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
|
||||
@@ -17,11 +18,13 @@ const ui = useUiStore();
|
||||
|
||||
const managing = ref(false);
|
||||
const showShortcuts = ref(false);
|
||||
const paletteOpen = 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: "Command palette", keys: ["⌘/Ctrl", "K"] },
|
||||
{ label: "New note", keys: ["c"] },
|
||||
{ label: "Search", keys: ["/"] },
|
||||
{ label: "Go to Board", keys: ["g", "b"] },
|
||||
@@ -57,6 +60,12 @@ function compose() {
|
||||
// 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) {
|
||||
// Cmd/Ctrl+K toggles the command palette — works even while typing.
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
|
||||
e.preventDefault();
|
||||
paletteOpen.value = !paletteOpen.value;
|
||||
return;
|
||||
}
|
||||
if (isTyping(e)) {
|
||||
if (e.key === "Escape") (e.target as HTMLElement).blur();
|
||||
return;
|
||||
@@ -238,6 +247,8 @@ async function signOut() {
|
||||
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||
|
||||
<div
|
||||
v-if="showShortcuts"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useTitlesStore } from "../stores/titles";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const titles = useTitlesStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const query = ref("");
|
||||
const selected = ref(0);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
void router.push(path);
|
||||
emit("close");
|
||||
}
|
||||
function compose() {
|
||||
void router.push("/").then(() => ui.requestCompose());
|
||||
emit("close");
|
||||
}
|
||||
function openNote(id: string) {
|
||||
void router.push({ path: "/", query: { open: id } });
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// Static command actions, filtered by query alongside note titles.
|
||||
const commands = computed<Row[]>(() => {
|
||||
const list: Row[] = [
|
||||
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
|
||||
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
|
||||
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
|
||||
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
||||
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
|
||||
{ id: "cmd:trash", label: "Go to Trash", hint: "Navigate", run: () => go("/trash") },
|
||||
];
|
||||
if (session.user?.is_admin) {
|
||||
list.push({ id: "cmd:settings", label: "Open Settings", hint: "Navigate", run: () => go("/settings") });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const results = computed<Row[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
const cmds = commands.value.filter((c) => !q || c.label.toLowerCase().includes(q));
|
||||
const notes: Row[] = titles.items
|
||||
.filter((t) => t.title && (!q || t.title.toLowerCase().includes(q)))
|
||||
.slice(0, q ? 12 : 6)
|
||||
.map((t) => ({ id: `note:${t.id}`, label: t.title, hint: "Note", run: () => openNote(t.id) }));
|
||||
// When searching text, surface matching notes first; when empty, lead with actions.
|
||||
return q ? [...notes, ...cmds] : [...cmds, ...notes];
|
||||
});
|
||||
|
||||
watch(query, () => (selected.value = 0));
|
||||
|
||||
function activate() {
|
||||
results.value[selected.value]?.run();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selected.value = Math.min(selected.value + 1, results.value.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selected.value = Math.max(selected.value - 1, 0);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await titles.load();
|
||||
await nextTick();
|
||||
input.value?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[12vh]"
|
||||
@mousedown.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Jump to a note or run a command…"
|
||||
aria-label="Jump to a note or run a command"
|
||||
class="w-full border-b border-neutral-200 bg-transparent px-4 py-3 text-sm outline-none placeholder:text-neutral-400 dark:border-neutral-700"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<ul v-if="results.length" class="max-h-80 overflow-y-auto py-1">
|
||||
<li v-for="(row, i) in results" :key="row.id">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm"
|
||||
:class="
|
||||
i === selected
|
||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
"
|
||||
@mousemove="selected = i"
|
||||
@click="
|
||||
selected = i;
|
||||
activate();
|
||||
"
|
||||
>
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
<span class="shrink-0 text-xs text-neutral-400">{{ row.hint }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="px-4 py-6 text-center text-sm text-neutral-400">No matches</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import QuickAdd from "../components/QuickAdd.vue";
|
||||
@@ -10,6 +10,7 @@ import NoteEditor from "../components/NoteEditor.vue";
|
||||
const notes = useNotesStore();
|
||||
const route = useRoute();
|
||||
const ui = useUiStore();
|
||||
const router = useRouter();
|
||||
|
||||
const editing = ref<Note | null>(null);
|
||||
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
|
||||
@@ -21,6 +22,22 @@ watch(
|
||||
() => quickAdd.value?.open(),
|
||||
);
|
||||
|
||||
// The command palette opens a note by navigating here with ?open=<id>.
|
||||
watch(
|
||||
() => route.query.open,
|
||||
(v) => {
|
||||
if (typeof v === "string" && v) void openFromQuery(v);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
async function openFromQuery(id: string) {
|
||||
const found = notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
|
||||
if (found) editing.value = found;
|
||||
const q = { ...route.query };
|
||||
delete q.open;
|
||||
void router.replace({ query: q });
|
||||
}
|
||||
|
||||
function viewForRoute(name: unknown): NoteView {
|
||||
if (name === "archive") return "archived";
|
||||
if (name === "trash") return "trash";
|
||||
|
||||
Reference in New Issue
Block a user