import { ref, type Ref } from "vue"; import { apiGet } from "@/api/client"; import type { NoteListResponse } from "@/types/note"; export interface AutocompleteItem { label: string; value: string; } export function useAutocomplete( textareaRef: Ref, textRef: Ref, fetchTags: (q: string) => Promise ) { const acItems = ref([]); const acVisible = ref(false); const acIndex = ref(0); const acTop = ref(0); const acLeft = ref(0); let acTriggerStart = -1; let acType: "tag" | "wikilink" | null = null; let debounceTimer: ReturnType; function detectTrigger() { const el = textareaRef.value; if (!el) return; const cursor = el.selectionStart; const text = textRef.value; // Check for [[ trigger for (let i = cursor - 1; i >= 1; i--) { if (text[i] === "\n") break; if (text[i - 1] === "[" && text[i] === "[") { const query = text.slice(i + 1, cursor); if (!/\]/.test(query)) { acTriggerStart = i - 1; acType = "wikilink"; debouncedSearch(query); return; } break; } } // Check for # trigger for (let i = cursor - 1; i >= 0; i--) { const ch = text[i]; if (ch === "\n" || ch === " " || ch === "\t") break; if (ch === "#") { // Must be start of line or preceded by whitespace if (i === 0 || /\s/.test(text[i - 1])) { const query = text.slice(i + 1, cursor); if (/^[\w/]*$/.test(query)) { acTriggerStart = i; acType = "tag"; debouncedSearch(query); return; } } break; } } dismiss(); } function debouncedSearch(query: string) { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => doSearch(query), 200); } async function doSearch(query: string) { if (acType === "tag") { try { const tags = await fetchTags(query); acItems.value = tags.slice(0, 8).map((t) => ({ label: `#${t}`, value: t, })); } catch (e) { console.error("Tag autocomplete fetch failed:", e); acItems.value = []; } } else if (acType === "wikilink") { try { const data = await apiGet( `/api/notes?q=${encodeURIComponent(query)}&limit=8` ); acItems.value = data.notes.map((n) => ({ label: n.title || "Untitled", value: n.title || "Untitled", })); } catch (e) { console.error("Wikilink autocomplete fetch failed:", e); acItems.value = []; } } if (acItems.value.length > 0) { acIndex.value = 0; positionDropdown(); acVisible.value = true; } else if (acType === "tag") { // Show "no tags" hint so the user knows the trigger is working acItems.value = [{ label: "No tags yet", value: "" }]; acIndex.value = -1; positionDropdown(); acVisible.value = true; } else { acVisible.value = false; } } function positionDropdown() { const el = textareaRef.value; if (!el) return; // Use a mirror div to measure cursor position const mirror = document.createElement("div"); const style = getComputedStyle(el); const props = [ "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "wordSpacing", "textIndent", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "boxSizing", "whiteSpace", "wordWrap", "overflowWrap", ] as const; mirror.style.position = "absolute"; mirror.style.visibility = "hidden"; mirror.style.overflow = "hidden"; mirror.style.width = style.width; for (const prop of props) { mirror.style[prop] = style[prop]; } const textBefore = el.value.slice(0, el.selectionStart); mirror.textContent = textBefore; const span = document.createElement("span"); span.textContent = "|"; mirror.appendChild(span); document.body.appendChild(mirror); const rect = el.getBoundingClientRect(); const spanRect = span.getBoundingClientRect(); const mirrorRect = mirror.getBoundingClientRect(); acTop.value = rect.top + (spanRect.top - mirrorRect.top) - el.scrollTop + 20; acLeft.value = rect.left + (spanRect.left - mirrorRect.left) - el.scrollLeft; document.body.removeChild(mirror); } function accept(index?: number) { const el = textareaRef.value; if (!el || !acVisible.value) return; const idx = index ?? acIndex.value; const item = acItems.value[idx]; if (!item || !item.value) return; const cursor = el.selectionStart; const text = textRef.value; let replacement: string; let end = cursor; if (acType === "tag") { replacement = `#${item.value} `; } else { replacement = `[[${item.value}]]`; } textRef.value = text.slice(0, acTriggerStart) + replacement + text.slice(end); const newPos = acTriggerStart + replacement.length; dismiss(); // Restore cursor position after Vue updates the DOM requestAnimationFrame(() => { el.setSelectionRange(newPos, newPos); el.focus(); }); } function dismiss() { acVisible.value = false; acItems.value = []; acType = null; acTriggerStart = -1; clearTimeout(debounceTimer); } function onKeydown(e: KeyboardEvent): boolean { if (!acVisible.value) return false; if (e.key === "ArrowDown") { e.preventDefault(); acIndex.value = (acIndex.value + 1) % acItems.value.length; return true; } if (e.key === "ArrowUp") { e.preventDefault(); acIndex.value = (acIndex.value - 1 + acItems.value.length) % acItems.value.length; return true; } if (e.key === "Tab") { e.preventDefault(); // Single match: accept immediately. Multiple: cycle then accept on second Tab. if (acItems.value.length === 1) { accept(); } else { acIndex.value = (acIndex.value + 1) % acItems.value.length; } return true; } if (e.key === "Enter") { e.preventDefault(); accept(); return true; } if (e.key === "Escape") { e.preventDefault(); dismiss(); return true; } return false; } return { acItems, acVisible, acIndex, acTop, acLeft, detectTrigger, accept, dismiss, onKeydown, }; }