"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { apiRequest } 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 [searchError, setSearchError] = useState(null); const dropdownRef = useRef(null); const searchTimeoutRef = useRef(null); const abortControllerRef = useRef(null); const mirrorRef = useRef(null); const cursorSpanRef = useRef(null); // Refs for event handler to avoid stale closures when effects re-attach listeners const stateRef = useRef(state); const resultsRef = useRef(results); const selectedIndexRef = useRef(selectedIndex); const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null); stateRef.current = state; resultsRef.current = results; selectedIndexRef.current = selectedIndex; /** * Search for knowledge entries matching the query. * Accepts an AbortSignal to allow cancellation of in-flight requests, * preventing stale results from overwriting newer ones. */ const searchEntries = useCallback(async (query: string, signal: AbortSignal): Promise => { if (!query.trim()) { setResults([]); return; } setIsLoading(true); try { const response = await apiRequest<{ data: KnowledgeEntryWithTags[]; meta: { total: number; page: number; limit: number; totalPages: number; }; }>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`, { method: "GET", signal, }); const searchResults: SearchResult[] = response.data.map((entry) => ({ id: entry.id, slug: entry.slug, title: entry.title, summary: entry.summary, })); setResults(searchResults); setSelectedIndex(0); setSearchError(null); } catch (error) { // Ignore aborted requests - a newer search has superseded this one if (error instanceof DOMException && error.name === "AbortError") { return; } console.error("Failed to search entries:", error); setResults([]); setSearchError("Search unavailable — please try again"); } finally { if (!signal.aborted) { setIsLoading(false); } } }, []); /** * Debounced search - waits 300ms after user stops typing. * Cancels any in-flight request via AbortController before firing a new one, * preventing race conditions where older results overwrite newer ones. */ const debouncedSearch = useCallback( (query: string): void => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } // Abort any in-flight request from a previous search if (abortControllerRef.current) { abortControllerRef.current.abort(); } searchTimeoutRef.current = setTimeout(() => { // Create a new AbortController for this search request const controller = new AbortController(); abortControllerRef.current = controller; void searchEntries(query, controller.signal); }, 300); }, [searchEntries] ); /** * Calculate dropdown position relative to cursor. * Uses a persistent off-screen mirror element (via refs) to avoid * creating and removing DOM nodes on every keystroke, which causes * layout thrashing. */ const calculateDropdownPosition = useCallback( (textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => { // Lazily create the mirror element once, then reuse it if (!mirrorRef.current) { const mirror = document.createElement("div"); mirror.style.position = "absolute"; mirror.style.visibility = "hidden"; mirror.style.height = "auto"; mirror.style.whiteSpace = "pre-wrap"; mirror.style.pointerEvents = "none"; document.body.appendChild(mirror); mirrorRef.current = mirror; const span = document.createElement("span"); span.textContent = "|"; cursorSpanRef.current = span; } const mirror = mirrorRef.current; const cursorSpan = cursorSpanRef.current; if (!cursorSpan) { return { top: 0, left: 0 }; } // Sync styles from the textarea so measurement is accurate const styles = window.getComputedStyle(textarea); 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.width = `${String(textarea.clientWidth)}px`; // Update content: text before cursor + cursor marker span const textBeforeCursor = textarea.value.substring(0, cursorIndex); mirror.textContent = textBeforeCursor; mirror.appendChild(cursorSpan); 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; 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. * Reads from refs to avoid stale closures when the effect * that attaches this listener hasn't re-run yet. */ const handleKeyDown = useCallback((e: KeyboardEvent): void => { if (!stateRef.current.isOpen) return; const currentResults = resultsRef.current; switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % currentResults.length); break; case "ArrowUp": e.preventDefault(); setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length); break; case "Enter": e.preventDefault(); if (currentResults.length > 0 && selectedIndexRef.current >= 0) { const selected = currentResults[selectedIndexRef.current]; if (selected) { insertLinkRef.current?.(selected); } } break; case "Escape": e.preventDefault(); setState({ isOpen: false, query: "", position: { top: 0, left: 0 }, triggerIndex: -1, }); setResults([]); break; } }, []); /** * 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] ); insertLinkRef.current = insertLink; /** * 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, abort in-flight requests, and remove the * persistent mirror element on unmount */ useEffect(() => { return (): void => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } if (abortControllerRef.current) { abortControllerRef.current.abort(); } if (mirrorRef.current) { document.body.removeChild(mirrorRef.current); mirrorRef.current = null; cursorSpanRef.current = null; } }; }, []); if (!state.isOpen) { return <>; } return (
{isLoading ? (
Searching...
) : searchError ? (
{searchError}
) : 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
); }