keyboard: board card navigation (j/k/arrows + actions)
Focused-card selection on the board: j/k or arrows move it, Enter opens, e archives, # pins, x trashes the focused card (actions gated off the Trash view). The selection is a brand ring on the card; it scrolls into view and clamps as the list/view changes. A window keydown listener on BoardView, ignored while typing, on a button/link, or while the editor modal is open. Added the card keys 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:
@@ -30,6 +30,9 @@ const shortcuts = [
|
|||||||
{ label: "Go to Board", keys: ["g", "b"] },
|
{ label: "Go to Board", keys: ["g", "b"] },
|
||||||
{ label: "Go to Graph", keys: ["g", "g"] },
|
{ label: "Go to Graph", keys: ["g", "g"] },
|
||||||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||||||
|
{ label: "Move card focus", keys: ["j", "k"] },
|
||||||
|
{ label: "Open focused card", keys: ["Enter"] },
|
||||||
|
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
|
||||||
{ label: "Save & close (editor)", keys: ["⌘/Ctrl", "Enter"] },
|
{ label: "Save & close (editor)", keys: ["⌘/Ctrl", "Enter"] },
|
||||||
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
|
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
|
||||||
{ label: "Dismiss / close", keys: ["Esc"] },
|
{ label: "Dismiss / close", keys: ["Esc"] },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
import type { Note } from "../stores/notes";
|
import type { Note } from "../stores/notes";
|
||||||
@@ -7,7 +8,7 @@ import LinkedText from "./LinkedText.vue";
|
|||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import { formatReminder, isOverdue } from "../notes/datetime";
|
import { formatReminder, isOverdue } from "../notes/datetime";
|
||||||
|
|
||||||
defineProps<{ note: Note; reorderable?: boolean }>();
|
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "open", note: Note): void;
|
(e: "open", note: Note): void;
|
||||||
(e: "dragstart", note: Note): void;
|
(e: "dragstart", note: Note): void;
|
||||||
@@ -15,6 +16,15 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
|
||||||
|
// When this card becomes the keyboard-focused card, scroll it into view.
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(a) => {
|
||||||
|
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function cardClass(color: NoteColor): string {
|
function cardClass(color: NoteColor): string {
|
||||||
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
||||||
}
|
}
|
||||||
@@ -22,8 +32,9 @@ function cardClass(color: NoteColor): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="root"
|
||||||
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
||||||
:class="cardClass(note.color)"
|
:class="[cardClass(note.color), active ? 'ring-2 ring-brand' : '']"
|
||||||
:draggable="reorderable && !note.trashed"
|
:draggable="reorderable && !note.trashed"
|
||||||
@dragstart="emit('dragstart', note)"
|
@dragstart="emit('dragstart', note)"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
@@ -52,6 +52,63 @@ const isMainBoard = computed(() => route.name === "board");
|
|||||||
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
|
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
|
||||||
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
|
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
|
||||||
|
|
||||||
|
// --- Keyboard card navigation: j/k or arrows move a selection; Enter opens;
|
||||||
|
// e archive, # pin, x trash the focused card. ---
|
||||||
|
const orderedNotes = computed<Note[]>(() =>
|
||||||
|
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
|
||||||
|
);
|
||||||
|
const focusedIndex = ref(-1);
|
||||||
|
const inTrash = computed(() => currentView.value === "trash");
|
||||||
|
|
||||||
|
function moveFocus(delta: number) {
|
||||||
|
const count = orderedNotes.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
focusedIndex.value =
|
||||||
|
focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBoardKey(e: KeyboardEvent) {
|
||||||
|
if (editing.value) return; // the editor modal owns the keyboard while open
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (["INPUT", "TEXTAREA", "BUTTON", "A"].includes(el.tagName) || el.isContentEditable)) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const key = e.key;
|
||||||
|
if (key === "j" || key === "ArrowDown" || key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "k" || key === "ArrowUp" || key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = orderedNotes.value[focusedIndex.value];
|
||||||
|
if (!note) return;
|
||||||
|
if (key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
openEditor(note);
|
||||||
|
} else if (key === "x" && !inTrash.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
void notes.trash(note.id);
|
||||||
|
} else if (key === "e" && !inTrash.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
void notes.setArchived(note.id, !note.archived);
|
||||||
|
} else if (key === "#" && !inTrash.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
void notes.setPinned(note.id, !note.pinned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the selection valid as the list changes / the view switches.
|
||||||
|
watch(
|
||||||
|
() => orderedNotes.value.length,
|
||||||
|
(len) => {
|
||||||
|
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch([currentView, currentLabel], () => (focusedIndex.value = -1));
|
||||||
|
|
||||||
const emptyState = computed(() => {
|
const emptyState = computed(() => {
|
||||||
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
||||||
if (currentView.value === "archived")
|
if (currentView.value === "archived")
|
||||||
@@ -64,7 +121,11 @@ async function reload() {
|
|||||||
await notes.load(currentView.value, currentLabel.value);
|
await notes.load(currentView.value, currentLabel.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload);
|
onMounted(() => {
|
||||||
|
void reload();
|
||||||
|
window.addEventListener("keydown", onBoardKey);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
|
||||||
watch([currentView, currentLabel], reload);
|
watch([currentView, currentLabel], reload);
|
||||||
|
|
||||||
function openEditor(note: Note) {
|
function openEditor(note: Note) {
|
||||||
@@ -119,9 +180,10 @@ async function onDrop(target: Note) {
|
|||||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||||
<NoteCard
|
<NoteCard
|
||||||
v-for="n in pinnedNotes"
|
v-for="(n, i) in pinnedNotes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
:note="n"
|
:note="n"
|
||||||
|
:active="focusedIndex === i"
|
||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@@ -135,9 +197,10 @@ async function onDrop(target: Note) {
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||||
<NoteCard
|
<NoteCard
|
||||||
v-for="n in otherNotes"
|
v-for="(n, i) in otherNotes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
:note="n"
|
:note="n"
|
||||||
|
:active="focusedIndex === pinnedNotes.length + i"
|
||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@@ -149,9 +212,10 @@ async function onDrop(target: Note) {
|
|||||||
|
|
||||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||||
<NoteCard
|
<NoteCard
|
||||||
v-for="n in notes.items"
|
v-for="(n, i) in notes.items"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
:note="n"
|
:note="n"
|
||||||
|
:active="focusedIndex === i"
|
||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
|
|||||||
Reference in New Issue
Block a user