"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { apiGet } from "@/lib/api/client"; import type { KnowledgeEntryWithTags } from "@mosaic/shared"; interface LinkAutocompleteProps { /** * The textarea element to attach autocomplete to */ textareaRef: React.RefObject; /** * Callback when a link is selected */ onInsert: (linkText: string) => void; } interface AutocompleteState { isOpen: boolean; query: string; position: { top: number; left: number }; triggerIndex: number; } interface SearchResult { id: string; slug: string; title: string; summary?: string | null; } /** * LinkAutocomplete - Provides autocomplete for wiki-style links in markdown * * Detects when user types `[[` and shows a dropdown with matching entries. * Arrow keys navigate, Enter selects, Esc cancels. * Inserts `[[slug|title]]` on selection. */ export function LinkAutocomplete({ textareaRef, onInsert, }: LinkAutocompleteProps): React.ReactElement { const [state, setState] = useState({ isOpen: false, query: "", position: { top: 0, left: 0 }, triggerIndex: -1, }); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const dropdownRef = useRef(null); const searchTimeoutRef = useRef(null); /** * Search for knowledge entries matching the query */ const searchEntries = useCallback(async (query: string): Promise => { if (!query.trim()) { setResults([]); return; } setIsLoading(true); try { const response = await apiGet<{ data: KnowledgeEntryWithTags[]; meta: { total: number; page: number; limit: number; totalPages: number; }; }>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`); const searchResults: SearchResult[] = response.data.map((entry) => ({ id: entry.id, slug: entry.slug, title: entry.title, summary: entry.summary, })); setResults(searchResults); setSelectedIndex(0); } catch (error) { console.error("Failed to search entries:", error); setResults([]); } finally { setIsLoading(false); } }, []); /** * Debounced search - waits 300ms after user stops typing */ const debouncedSearch = useCallback( (query: string): void => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } searchTimeoutRef.current = setTimeout(() => { void searchEntries(query); }, 300); }, [searchEntries] ); /** * Calculate dropdown position relative to cursor */ const calculateDropdownPosition = useCallback( (textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => { // Create a mirror div to measure text position const mirror = document.createElement("div"); const styles = window.getComputedStyle(textarea); // Copy relevant styles const stylesToCopy = [ "fontFamily", "fontSize", "fontWeight", "letterSpacing", "lineHeight", "padding", "border", "boxSizing", "whiteSpace", "wordWrap", ] as const; stylesToCopy.forEach((prop) => { const value = styles.getPropertyValue(prop); if (value) { mirror.style.setProperty(prop, value); } }); mirror.style.position = "absolute"; mirror.style.visibility = "hidden"; mirror.style.width = `${String(textarea.clientWidth)}px`; mirror.style.height = "auto"; mirror.style.whiteSpace = "pre-wrap"; // Get text up to cursor const textBeforeCursor = textarea.value.substring(0, cursorIndex); mirror.textContent = textBeforeCursor; // Create a span for the cursor position const cursorSpan = document.createElement("span"); cursorSpan.textContent = "|"; mirror.appendChild(cursorSpan); document.body.appendChild(mirror); const textareaRect = textarea.getBoundingClientRect(); const cursorSpanRect = cursorSpan.getBoundingClientRect(); const top = cursorSpanRect.top - textareaRect.top + textarea.scrollTop + 20; const left = cursorSpanRect.left - textareaRect.left + textarea.scrollLeft; document.body.removeChild(mirror); return { top, left }; }, [] ); /** * Handle input changes in the textarea */ const handleInput = useCallback((): void => { const textarea = textareaRef.current; if (!textarea) return; const cursorPos = textarea.selectionStart; const text = textarea.value; // Look for `[[` before cursor const textBeforeCursor = text.substring(0, cursorPos); const lastTrigger = textBeforeCursor.lastIndexOf("[["); // Check if we're in an autocomplete context if (lastTrigger !== -1) { const textAfterTrigger = textBeforeCursor.substring(lastTrigger + 2); // Check if there's a closing `]]` between trigger and cursor const hasClosing = textAfterTrigger.includes("]]"); if (!hasClosing) { // We're in autocomplete mode const query = textAfterTrigger; const position = calculateDropdownPosition(textarea, cursorPos); setState({ isOpen: true, query, position, triggerIndex: lastTrigger, }); debouncedSearch(query); return; } } // Not in autocomplete mode if (state.isOpen) { setState({ isOpen: false, query: "", position: { top: 0, left: 0 }, triggerIndex: -1, }); setResults([]); } }, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]); /** * Handle keyboard navigation in the dropdown */ const handleKeyDown = useCallback( (e: KeyboardEvent): void => { if (!state.isOpen) return; switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % results.length); break; case "ArrowUp": e.preventDefault(); setSelectedIndex((prev) => (prev - 1 + results.length) % results.length); break; case "Enter": e.preventDefault(); if (results.length > 0 && selectedIndex >= 0) { const selected = results[selectedIndex]; if (selected) { insertLink(selected); } } break; case "Escape": e.preventDefault(); setState({ isOpen: false, query: "", position: { top: 0, left: 0 }, triggerIndex: -1, }); setResults([]); break; } }, [state.isOpen, results, selectedIndex] ); /** * Insert the selected link into the textarea */ const insertLink = useCallback( (result: SearchResult): void => { const textarea = textareaRef.current; if (!textarea) return; const linkText = `[[${result.slug}|${result.title}]]`; const text = textarea.value; const before = text.substring(0, state.triggerIndex); const after = text.substring(textarea.selectionStart); const newText = before + linkText + after; onInsert(newText); // Close autocomplete setState({ isOpen: false, query: "", position: { top: 0, left: 0 }, triggerIndex: -1, }); setResults([]); // Move cursor after the inserted link const newCursorPos = state.triggerIndex + linkText.length; setTimeout(() => { textarea.focus(); textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }, [textareaRef, state.triggerIndex, onInsert] ); /** * Handle click on a result */ const handleResultClick = useCallback( (result: SearchResult): void => { insertLink(result); }, [insertLink] ); /** * Setup event listeners */ useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.addEventListener("input", handleInput); textarea.addEventListener("keydown", handleKeyDown as unknown as EventListener); return (): void => { textarea.removeEventListener("input", handleInput); textarea.removeEventListener("keydown", handleKeyDown as unknown as EventListener); }; }, [textareaRef, handleInput, handleKeyDown]); /** * Cleanup timeout on unmount */ useEffect(() => { return (): void => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } }; }, []); if (!state.isOpen) { return <>; } return (
{isLoading ? (
Searching...
) : results.length === 0 ? (
{state.query ? "No entries found" : "Start typing to search..."}
) : (
    {results.map((result, index) => (
  • { handleResultClick(result); }} onMouseEnter={() => { setSelectedIndex(index); }} >
    {result.title}
    {result.summary && (
    {result.summary}
    )}
    {result.slug}
  • ))}
)}
↑↓ Navigate • Enter Select • Esc Cancel
); }