Vue 3 + TypeScript frontend with Pinia stores, markdown rendering (marked + DOMPurify), wikilink/tag linkification, and autocomplete. Quart async backend with SQLAlchemy 2.0, PostgreSQL ARRAY columns, task-note companion linking, backlinks, and note-to-task conversion. Docker Compose setup with PostgreSQL 16 and Ollama. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
6.6 KiB
TypeScript
260 lines
6.6 KiB
TypeScript
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<HTMLTextAreaElement | null>,
|
|
textRef: Ref<string>,
|
|
fetchTags: (q: string) => Promise<string[]>
|
|
) {
|
|
const acItems = ref<AutocompleteItem[]>([]);
|
|
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<typeof setTimeout>;
|
|
|
|
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<NoteListResponse>(
|
|
`/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,
|
|
};
|
|
}
|