From 2a06f7499a520666c17fde3324966329d475b380 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Jul 2026 11:21:53 -0400 Subject: [PATCH] links: [[ autocomplete in the note editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/components/NoteEditor.vue | 110 +++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index d53baa1..2d3971e 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -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) { placeholder="Title" class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400" /> -