links: [[ autocomplete in the note editor
Typing `[[` in a text note's body opens a title-suggestion menu from the titles index. Arrow keys move the selection, Enter/Tab inserts `[[Title]]` and places the caret after it, Esc dismisses just the menu (kept from bubbling to the dialog's Esc-close), mouse hover/click also select. The menu appears only while the caret sits inside an unclosed [[… token. 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:
@@ -87,6 +87,77 @@ async function openLink(link: { title: string; id: string | null }) {
|
|||||||
emit("navigate", created.id);
|
emit("navigate", created.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- [[ link autocomplete in the body textarea ---
|
||||||
|
const linkMenu = ref(false);
|
||||||
|
const linkQuery = ref("");
|
||||||
|
const linkStart = ref(-1); // index of the `[[` that opened the current token
|
||||||
|
const linkSelected = ref(0);
|
||||||
|
|
||||||
|
const linkMatches = computed(() => {
|
||||||
|
const q = linkQuery.value.trim().toLowerCase();
|
||||||
|
return titles.items.filter((t) => t.title && t.title.toLowerCase().includes(q)).slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
// On every edit, check whether the caret sits inside an unclosed `[[…` token
|
||||||
|
// and, if so, open the suggestion menu with the partial title as the query.
|
||||||
|
function onBodyInput() {
|
||||||
|
const el = bodyInput.value;
|
||||||
|
if (!el) return;
|
||||||
|
const caret = el.selectionStart ?? 0;
|
||||||
|
const text = body.value.slice(0, caret);
|
||||||
|
const open = text.lastIndexOf("[[");
|
||||||
|
if (open === -1) {
|
||||||
|
linkMenu.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const between = text.slice(open + 2);
|
||||||
|
if (between.includes("]") || between.includes("\n")) {
|
||||||
|
linkMenu.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
linkQuery.value = between;
|
||||||
|
linkStart.value = open;
|
||||||
|
linkSelected.value = 0;
|
||||||
|
linkMenu.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink(title: string) {
|
||||||
|
const el = bodyInput.value;
|
||||||
|
const caret = el?.selectionStart ?? body.value.length;
|
||||||
|
const before = body.value.slice(0, linkStart.value);
|
||||||
|
const after = body.value.slice(caret);
|
||||||
|
const insertion = `[[${title}]]`;
|
||||||
|
body.value = before + insertion + after;
|
||||||
|
linkMenu.value = false;
|
||||||
|
const pos = before.length + insertion.length;
|
||||||
|
void nextTick(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBodyKeydown(e: KeyboardEvent) {
|
||||||
|
if (!linkMenu.value || linkMatches.value.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
linkSelected.value = Math.max(linkSelected.value - 1, 0);
|
||||||
|
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
const m = linkMatches.value[linkSelected.value];
|
||||||
|
if (m) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertLink(m.title);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
// Close only the menu — don't let it bubble to the dialog's Esc-to-close.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
linkMenu.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||||
|
|
||||||
function onReminderChange(e: Event) {
|
function onReminderChange(e: Event) {
|
||||||
@@ -207,14 +278,37 @@ async function act(fn: () => Promise<void>) {
|
|||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<div v-if="liveNote.kind === 'text'" class="relative">
|
||||||
v-if="liveNote.kind === 'text'"
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
rows="8"
|
rows="8"
|
||||||
placeholder="Take a note…"
|
placeholder="Take a note… ([[ to link a note)"
|
||||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
/>
|
@input="onBodyInput"
|
||||||
|
@keydown="onBodyKeydown"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="linkMenu && linkMatches.length"
|
||||||
|
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<li v-for="(m, i) in linkMatches" :key="m.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
|
||||||
|
:class="
|
||||||
|
i === linkSelected
|
||||||
|
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||||
|
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
||||||
|
"
|
||||||
|
@mousemove="linkSelected = i"
|
||||||
|
@mousedown.prevent="insertLink(m.title)"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ m.title }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||||
|
|
||||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user