Add tag autocomplete: suggest existing tags while typing to avoid near-duplicates

This commit is contained in:
Yuri Karamian
2026-06-08 12:51:03 +02:00
parent dc19976568
commit 5defddb35c
2 changed files with 144 additions and 33 deletions
+16 -33
View File
@@ -39,6 +39,7 @@
import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte'; import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
@@ -131,7 +132,7 @@
if (isMobile) { if (isMobile) {
deriveTagsFromNotes(); deriveTagsFromNotes();
} else { } else {
await refreshTags(); try { $tags = await getAllTags(); } catch (_) {}
} }
} }
@@ -143,7 +144,6 @@
let editValue = $state(''); let editValue = $state('');
let movePickerNote = $state<NoteEntry | null>(null); let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null); let tagEditNote = $state<NoteEntry | null>(null);
let tagEditValue = $state('');
let tagEditTags = $state<string[]>([]); let tagEditTags = $state<string[]>([]);
// Multi-select // Multi-select
@@ -532,7 +532,7 @@
function openTagEdit(note: NoteEntry) { function openTagEdit(note: NoteEntry) {
tagEditNote = note; tagEditNote = note;
tagEditTags = [...note.meta.tags]; tagEditTags = [...note.meta.tags];
tagEditValue = ''; refreshTags();
} }
async function addTagToNote(tag: string) { async function addTagToNote(tag: string) {
@@ -540,7 +540,6 @@
const cleaned = tag.trim().toLowerCase(); const cleaned = tag.trim().toLowerCase();
if (tagEditTags.includes(cleaned)) return; if (tagEditTags.includes(cleaned)) return;
tagEditTags = [...tagEditTags, cleaned]; tagEditTags = [...tagEditTags, cleaned];
tagEditValue = '';
await saveTagsForNote(tagEditNote, tagEditTags); await saveTagsForNote(tagEditNote, tagEditTags);
} }
@@ -569,7 +568,7 @@
function openBatchTagEdit() { function openBatchTagEdit() {
batchTagEdit = true; batchTagEdit = true;
tagEditValue = ''; refreshTags();
// Show tags common to ALL selected notes // Show tags common to ALL selected notes
const selectedNotes = $notes.filter(n => selectedPaths.has(n.path)); const selectedNotes = $notes.filter(n => selectedPaths.has(n.path));
if (selectedNotes.length === 0) { tagEditTags = []; return; } if (selectedNotes.length === 0) { tagEditTags = []; return; }
@@ -581,7 +580,6 @@
async function addTagToBatch(tag: string) { async function addTagToBatch(tag: string) {
if (!tag.trim()) return; if (!tag.trim()) return;
const cleaned = tag.trim().toLowerCase(); const cleaned = tag.trim().toLowerCase();
tagEditValue = '';
if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned]; if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned];
try { try {
for (const path of selectedPaths) { for (const path of selectedPaths) {
@@ -940,16 +938,11 @@
</div> </div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag to all..." placeholder="Add tag to all..."
bind:value={tagEditValue} onsubmit={(t) => addTagToBatch(t)}
onkeydown={(e) => { oncancel={() => batchTagEdit = false}
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
@@ -1208,16 +1201,11 @@
<div class="context-sep"></div> <div class="context-sep"></div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag to all..." placeholder="Add tag to all..."
bind:value={tagEditValue} onsubmit={(t) => addTagToBatch(t)}
onkeydown={(e) => { oncancel={() => batchTagEdit = false}
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
@@ -1265,16 +1253,11 @@
<div class="context-sep"></div> <div class="context-sep"></div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag..." placeholder="Add tag..."
bind:value={tagEditValue} onsubmit={(t) => addTagToNote(t)}
onkeydown={(e) => { oncancel={() => tagEditNote = null}
if (e.key === 'Enter') { e.preventDefault(); addTagToNote(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); tagEditNote = null; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tags } from '$lib/stores/app';
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
existing?: string[];
placeholder?: string;
onsubmit: (tag: string) => void;
oncancel: () => void;
} = $props();
let query = $state('');
let selIndex = $state(-1);
let inputEl = $state<HTMLInputElement>(null!);
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
const suggestions = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const taken = new Set(existing);
const names = $tags
.map(([name]) => name)
.filter((name) => !taken.has(name) && name.toLowerCase().includes(q));
names.sort((a, b) => {
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
if (ap !== bp) return ap - bp;
return a.localeCompare(b);
});
return names.slice(0, 8);
});
function submit(tag: string) {
if (!tag.trim()) return;
onsubmit(tag);
query = '';
selIndex = -1;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex + 1) % suggestions.length; }
} else if (e.key === 'ArrowUp') {
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex - 1 + suggestions.length) % suggestions.length; }
} else if (e.key === 'Enter') {
e.preventDefault();
submit(selIndex >= 0 && selIndex < suggestions.length ? suggestions[selIndex] : query);
} else if (e.key === 'Tab') {
// Complete the input to the highlighted suggestion without committing.
if (selIndex >= 0 && selIndex < suggestions.length) { e.preventDefault(); query = suggestions[selIndex]; selIndex = -1; }
} else if (e.key === 'Escape') {
e.preventDefault();
if (selIndex >= 0) selIndex = -1; // first Escape just drops the highlight
else oncancel();
}
}
onMount(() => inputEl?.focus());
</script>
<div class="tag-suggest">
<input
bind:this={inputEl}
type="text"
class="tag-suggest-input"
{placeholder}
bind:value={query}
oninput={() => (selIndex = -1)}
onkeydown={onKeydown}
/>
{#if suggestions.length}
<div class="tag-suggest-list">
{#each suggestions as s, i}
<button
type="button"
class="tag-suggest-item"
class:selected={i === selIndex}
onmouseenter={() => (selIndex = i)}
onmousedown={(e) => { e.preventDefault(); submit(s); }}
>#{s}</button>
{/each}
</div>
{/if}
</div>
<style>
.tag-suggest {
display: flex;
flex-direction: column;
}
.tag-suggest-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
outline: none;
font-family: inherit;
}
.tag-suggest-input:focus {
border-color: var(--accent);
}
.tag-suggest-list {
margin-top: 4px;
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.tag-suggest-item {
text-align: left;
padding: 5px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.tag-suggest-item:hover,
.tag-suggest-item.selected {
background: var(--bg-hover);
color: var(--text-primary);
}
</style>