/** * WikiLinkAutocomplete - Dropdown autocomplete for wiki-style [[link]] syntax. * * Aesthetic: "Scholarly Dusk" with vintage card catalog styling * - Brass/old gold accents reminiscent of library fixtures * - Teal primary for interactive highlights * - Monospace typography for document references * - Embossed shadows evoking index cards * * Triggers when user types [[, shows fuzzy-matched doc titles, * supports keyboard navigation, and offers "Create new" for non-existent targets. */ import { FilePlusIcon, FileTextIcon, LinkIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { cn } from "../lib/utils"; /** Document info for autocomplete */ export interface WikiLinkDoc { title: string; uri: string; docid: string; collection?: string; } export interface WikiLinkAutocompleteProps { /** Whether dropdown is visible */ isOpen: boolean; /** Screen position for dropdown */ position: { x: number; y: number }; /** Text after [[ trigger */ searchQuery: string; /** Available documents to search */ docs: WikiLinkDoc[]; /** Called when user selects a document */ onSelect: (title: string, displayText?: string) => void; /** Called when user wants to create new note */ onCreateNew?: (title: string) => void; /** Called when dropdown should close */ onDismiss: () => void; /** Active index for keyboard nav (controlled externally) */ activeIndex?: number; /** Callback when active index changes */ onActiveIndexChange?: (index: number) => void; /** Additional classes */ className?: string; } /** Maximum results to display */ const MAX_RESULTS = 8; /** * Fuzzy match score - returns -1 if no match, else score (higher = better) * Prefers: exact match > prefix > word boundary > substring > scattered */ function fuzzyScore(text: string, query: string): number { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); // Exact match if (lowerText === lowerQuery) return 1000; // Prefix match if (lowerText.startsWith(lowerQuery)) return 900 + (query.length / text.length) * 50; // Contains as substring const substringIdx = lowerText.indexOf(lowerQuery); if (substringIdx !== -1) { // Bonus for word boundary const previousChar = substringIdx > 0 ? text.charAt(substringIdx - 1) : ""; if (substringIdx === 0 || /\W/.test(previousChar)) { return 800 + (query.length / text.length) * 50; } return 700 + (query.length / text.length) * 50; } // Scattered character match let score = 0; let textIdx = 0; let consecutiveBonus = 0; for (const char of lowerQuery) { const foundIdx = lowerText.indexOf(char, textIdx); if (foundIdx === -1) return -1; // No match // Consecutive chars get bonus if (foundIdx === textIdx) { consecutiveBonus += 10; } else { consecutiveBonus = 0; } // Word boundary bonus const previousChar = foundIdx > 0 ? text.charAt(foundIdx - 1) : ""; if (foundIdx === 0 || /\W/.test(previousChar)) { score += 20; } score += 10 + consecutiveBonus; textIdx = foundIdx + 1; } return score; } /** * Get indices of matching characters for highlighting */ function getMatchIndices(text: string, query: string): number[] { const indices: number[] = []; const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); // Check for substring match first const substringIdx = lowerText.indexOf(lowerQuery); if (substringIdx !== -1) { for (let i = 0; i < query.length; i++) { indices.push(substringIdx + i); } return indices; } // Fall back to scattered match let textIdx = 0; for (const char of lowerQuery) { const foundIdx = lowerText.indexOf(char, textIdx); if (foundIdx !== -1) { indices.push(foundIdx); textIdx = foundIdx + 1; } } return indices; } /** Highlight component for matched characters */ function HighlightedText({ text, matchIndices, }: { text: string; matchIndices: number[]; }) { if (matchIndices.length === 0) { return {text}; } const indexSet = new Set(matchIndices); const parts: React.ReactNode[] = []; let currentRun = ""; let isHighlighted = false; for (let i = 0; i < text.length; i++) { const currentChar = text.charAt(i); const charIsHighlighted = indexSet.has(i); if (charIsHighlighted !== isHighlighted) { // Flush current run if (currentRun) { parts.push( isHighlighted ? ( {currentRun} ) : ( {currentRun} ) ); } currentRun = currentChar; isHighlighted = charIsHighlighted; } else { currentRun += currentChar; } } // Flush final run if (currentRun) { parts.push( isHighlighted ? ( {currentRun} ) : ( {currentRun} ) ); } return <>{parts}; } export function WikiLinkAutocomplete({ isOpen, position, searchQuery, docs, onSelect, onCreateNew, onDismiss, activeIndex = -1, onActiveIndexChange, className, }: WikiLinkAutocompleteProps) { const listRef = useRef(null); const listboxId = "wikilink-autocomplete-listbox"; // Filter and score docs const filteredDocs = useMemo(() => { if (!searchQuery.trim()) { return docs.slice(0, MAX_RESULTS).map((doc) => ({ doc, score: 0, matchIndices: [] as number[], })); } const scored = docs .map((doc) => ({ doc, score: fuzzyScore(doc.title, searchQuery), matchIndices: getMatchIndices(doc.title, searchQuery), })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, MAX_RESULTS); return scored; }, [docs, searchQuery]); // Check if exact match exists const hasExactMatch = useMemo(() => { const query = searchQuery.toLowerCase().trim(); return docs.some((doc) => doc.title.toLowerCase() === query); }, [docs, searchQuery]); // Show create option if query has content and no exact match const showCreateOption = searchQuery.trim().length > 0 && !hasExactMatch && onCreateNew; // Total selectable items (docs + create option) const totalItems = filteredDocs.length + (showCreateOption ? 1 : 0); const createOptionIndex = filteredDocs.length; // Handle keyboard navigation const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (!isOpen) return; switch (e.key) { case "ArrowDown": e.preventDefault(); e.stopPropagation(); onActiveIndexChange?.( activeIndex < totalItems - 1 ? activeIndex + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); e.stopPropagation(); onActiveIndexChange?.( activeIndex > 0 ? activeIndex - 1 : totalItems - 1 ); break; case "Enter": e.preventDefault(); e.stopPropagation(); if (activeIndex >= 0 && activeIndex < filteredDocs.length) { const activeDoc = filteredDocs.at(activeIndex); if (activeDoc) { onSelect(activeDoc.doc.title); } } else if (activeIndex === createOptionIndex && showCreateOption) { onCreateNew?.(searchQuery.trim()); } else if (filteredDocs.length > 0) { // Default to first result if nothing selected const firstDoc = filteredDocs.at(0); if (firstDoc) { onSelect(firstDoc.doc.title); } } else if (showCreateOption) { onCreateNew?.(searchQuery.trim()); } break; case "Escape": e.preventDefault(); e.stopPropagation(); onDismiss(); break; case "Tab": // Allow tab to dismiss onDismiss(); break; } }, [ isOpen, activeIndex, totalItems, filteredDocs, createOptionIndex, showCreateOption, searchQuery, onActiveIndexChange, onSelect, onCreateNew, onDismiss, ] ); // Attach keyboard listener useEffect(() => { if (isOpen) { document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); } }, [isOpen, handleKeyDown]); // Scroll active option into view useEffect(() => { if (activeIndex >= 0 && listRef.current) { const optionEl = listRef.current.querySelector( `[data-index="${activeIndex}"]` ); optionEl?.scrollIntoView({ block: "nearest" }); } }, [activeIndex]); // Click outside to dismiss useEffect(() => { if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (listRef.current && !listRef.current.contains(target)) { onDismiss(); } }; // Delay to avoid immediate dismissal const timer = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside); }, 0); return () => { clearTimeout(timer); document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen, onDismiss]); if (!isOpen) return null; const hasResults = filteredDocs.length > 0 || showCreateOption; return (
); }