links: [[ autocomplete in the note editor
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 28s

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:
2026-07-20 11:21:53 -04:00
co-authored by Claude Opus 4.8
parent 4c72dfea0b
commit 2a06f7499a
+102 -8
View File
@@ -87,6 +87,77 @@ async function openLink(link: { title: string; id: string | null }) {
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));
function onReminderChange(e: Event) {
@@ -207,14 +278,37 @@ async function act(fn: () => Promise<void>) {
placeholder="Title"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
/>
<textarea
v-if="liveNote.kind === 'text'"
ref="bodyInput"
v-model="body"
rows="8"
placeholder="Take a note…"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
/>
<div v-if="liveNote.kind === 'text'" class="relative">
<textarea
ref="bodyInput"
v-model="body"
rows="8"
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"
@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 />
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">