mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-25 08:15:55 +02:00
Add tag autocomplete: suggest existing tags while typing to avoid near-duplicates
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user