Keyboard navigation improvements across all editors

TiptapEditor.vue (central — applies to all three editors):
- Escape inside editor blurs it and emits 'escape' event to parent

TagInput.vue (central — applies everywhere tags are used):
- ArrowUp/ArrowDown navigate autocomplete suggestion list with visual highlight
- Enter confirms the keyboard-selected suggestion instead of typed text

NoteEditorView, TaskEditorView, WorkspaceNoteEditor (per-editor wiring):
- @escape on TiptapEditor returns focus to the title input (ref="titleRef")
- Ctrl+E from title input or editor main column jumps focus into editor body
- Ctrl+S on title input saves (was already on editor area; now consistent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:46:08 -05:00
parent 0e9ab84afb
commit 659c08def5
5 changed files with 48 additions and 10 deletions
+22 -6
View File
@@ -14,6 +14,7 @@ const inputValue = ref("");
const suggestions = ref<string[]>([]); const suggestions = ref<string[]>([]);
const showSuggestions = ref(false); const showSuggestions = ref(false);
const inputRef = ref<HTMLInputElement | null>(null); const inputRef = ref<HTMLInputElement | null>(null);
const selectedIndex = ref(-1);
let fetchDebounce: ReturnType<typeof setTimeout> | null = null; let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
@@ -37,11 +38,23 @@ function removeTag(tag: string) {
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (showSuggestions.value && e.key === "ArrowDown") {
e.preventDefault();
selectedIndex.value = Math.min(selectedIndex.value + 1, suggestions.value.length - 1);
return;
}
if (showSuggestions.value && e.key === "ArrowUp") {
e.preventDefault();
selectedIndex.value = Math.max(selectedIndex.value - 1, -1);
return;
}
if (e.key === "Enter" || e.key === ",") { if (e.key === "Enter" || e.key === ",") {
e.preventDefault(); e.preventDefault();
const val = inputValue.value.replace(/,$/, ""); if (showSuggestions.value && selectedIndex.value >= 0) {
if (val.trim()) { addTag(suggestions.value[selectedIndex.value]);
addTag(val); } else {
const val = inputValue.value.replace(/,$/, "");
if (val.trim()) addTag(val);
} }
} else if (e.key === "Backspace" && !inputValue.value) { } else if (e.key === "Backspace" && !inputValue.value) {
const tags = props.modelValue; const tags = props.modelValue;
@@ -50,6 +63,7 @@ function onKeydown(e: KeyboardEvent) {
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
showSuggestions.value = false; showSuggestions.value = false;
selectedIndex.value = -1;
} }
} }
@@ -66,6 +80,7 @@ function onInput() {
const results = await props.fetchTags(q); const results = await props.fetchTags(q);
suggestions.value = results.filter((t) => !props.modelValue.includes(t)); suggestions.value = results.filter((t) => !props.modelValue.includes(t));
showSuggestions.value = suggestions.value.length > 0; showSuggestions.value = suggestions.value.length > 0;
selectedIndex.value = -1;
} catch { } catch {
suggestions.value = []; suggestions.value = [];
} }
@@ -120,9 +135,9 @@ function focusInput() {
/> />
<ul v-if="showSuggestions" class="tag-autocomplete"> <ul v-if="showSuggestions" class="tag-autocomplete">
<li <li
v-for="s in suggestions" v-for="(s, i) in suggestions"
:key="s" :key="s"
class="tag-autocomplete-item" :class="['tag-autocomplete-item', { selected: i === selectedIndex }]"
@mousedown.prevent="addTag(s)" @mousedown.prevent="addTag(s)"
> >
#{{ s }} #{{ s }}
@@ -205,7 +220,8 @@ function focusInput() {
cursor: pointer; cursor: pointer;
color: var(--color-text); color: var(--color-text);
} }
.tag-autocomplete-item:hover { .tag-autocomplete-item:hover,
.tag-autocomplete-item.selected {
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent)); background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
color: var(--color-primary); color: var(--color-primary);
} }
+9
View File
@@ -24,6 +24,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
"update:modelValue": [value: string]; "update:modelValue": [value: string];
selectionChange: [payload: { text: string; start: number; end: number }]; selectionChange: [payload: { text: string; start: number; end: number }];
escape: [];
}>(); }>();
let updatingFromProp = false; let updatingFromProp = false;
@@ -41,6 +42,14 @@ try {
content: markdownToHtml(props.modelValue), content: markdownToHtml(props.modelValue),
editable: true, editable: true,
editorProps: { editorProps: {
handleKeyDown: (_view, event) => {
if (event.key === "Escape") {
editor?.commands.blur();
emit("escape");
return true;
}
return false;
},
handlePaste: (_view, event) => { handlePaste: (_view, event) => {
const text = event.clipboardData?.getData("text/plain"); const text = event.clipboardData?.getData("text/plain");
if (!text || !editor) return false; if (!text || !editor) return false;
@@ -47,6 +47,7 @@ const saving = ref(false);
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; }); const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
// TipTap editor ref (for MarkdownToolbar) // TipTap editor ref (for MarkdownToolbar)
const titleRef = ref<HTMLInputElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => const tiptapEditor = computed<Editor | null>(() =>
(editorRef.value?.editor as Editor | undefined) ?? null (editorRef.value?.editor as Editor | undefined) ?? null
@@ -252,10 +253,12 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<div class="note-title-row"> <div class="note-title-row">
<input <input
ref="titleRef"
v-model="noteTitle" v-model="noteTitle"
class="note-title-input" class="note-title-input"
placeholder="Note title" placeholder="Note title"
@keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.s.prevent="saveNote"
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
/> />
</div> </div>
@@ -308,8 +311,8 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<button class="btn-dismiss-suggestions" @click="linkSuggestions = []"></button> <button class="btn-dismiss-suggestions" @click="linkSuggestions = []"></button>
</div> </div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote"> <div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
<TiptapEditor ref="editorRef" v-model="noteBody" /> <TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
</div> </div>
</template> </template>
+6 -1
View File
@@ -36,6 +36,7 @@ const showPreview = ref(false);
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
const showHistory = ref(false); const showHistory = ref(false);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => { const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null; return (editorRef.value?.editor as Editor | undefined) ?? null;
}); });
@@ -325,11 +326,14 @@ onUnmounted(() => assist.clearSelection());
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button> <button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
</div> </div>
<input <input
ref="titleRef"
v-model="title" v-model="title"
type="text" type="text"
placeholder="Title" placeholder="Title"
class="title-input" class="title-input"
@input="markDirty" @input="markDirty"
@keydown.ctrl.s.prevent="save"
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
/> />
</div> </div>
@@ -337,7 +341,7 @@ onUnmounted(() => assist.clearSelection());
<div class="note-body"> <div class="note-body">
<!-- Main column --> <!-- Main column -->
<div class="note-main"> <div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row"> <div class="body-tabs-row">
<div class="editor-tabs"> <div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button> <button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
@@ -366,6 +370,7 @@ onUnmounted(() => assist.clearSelection());
placeholder="Write your note in Markdown..." placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange" @selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/> />
</div> </div>
<div <div
+6 -1
View File
@@ -100,6 +100,7 @@ async function toggleSubTask(sub: SubTask) {
const showPreview = ref(true); const showPreview = ref(true);
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null); const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => { const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null; return (editorRef.value?.editor as Editor | undefined) ?? null;
}); });
@@ -392,8 +393,11 @@ useEditorGuards(dirty, save);
v-model="title" v-model="title"
type="text" type="text"
placeholder="Task title" placeholder="Task title"
ref="titleRef"
class="title-input" class="title-input"
@input="markDirty" @input="markDirty"
@keydown.ctrl.s.prevent="save"
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
/> />
</div><!-- /editor-header: title only --> </div><!-- /editor-header: title only -->
@@ -402,7 +406,7 @@ useEditorGuards(dirty, save);
<div class="task-body"> <div class="task-body">
<!-- Main column --> <!-- Main column -->
<div class="task-main"> <div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<!-- Write / Preview tabs + toolbar sit above the editor --> <!-- Write / Preview tabs + toolbar sit above the editor -->
<div class="body-tabs-row"> <div class="body-tabs-row">
@@ -434,6 +438,7 @@ useEditorGuards(dirty, save);
placeholder="Describe this task..." placeholder="Describe this task..."
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange" @selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/> />
</div> </div>
<div <div