feat: add wiki-link autocomplete in editor (closes #63)
This commit is contained in:
384
apps/web/src/components/knowledge/LinkAutocomplete.tsx
Normal file
384
apps/web/src/components/knowledge/LinkAutocomplete.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"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<HTMLTextAreaElement>;
|
||||
/**
|
||||
* 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<AutocompleteState>({
|
||||
isOpen: false,
|
||||
query: "",
|
||||
position: { top: 0, left: 0 },
|
||||
triggerIndex: -1,
|
||||
});
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Search for knowledge entries matching the query
|
||||
*/
|
||||
const searchEntries = useCallback(async (query: string): Promise<void> => {
|
||||
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
|
||||
[
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"padding",
|
||||
"border",
|
||||
"boxSizing",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
].forEach((prop) => {
|
||||
mirror.style[prop as keyof CSSStyleDeclaration] = styles[prop as keyof CSSStyleDeclaration] as string;
|
||||
});
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
mirror.style.height = "auto";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
|
||||
// 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 () => {
|
||||
textarea.removeEventListener("input", handleInput);
|
||||
textarea.removeEventListener("keydown", handleKeyDown as unknown as EventListener);
|
||||
};
|
||||
}, [textareaRef, handleInput, handleKeyDown]);
|
||||
|
||||
/**
|
||||
* Cleanup timeout on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!state.isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-64 overflow-y-auto min-w-[300px] max-w-[500px]"
|
||||
style={{
|
||||
top: `${state.position.top}px`,
|
||||
left: `${state.position.left}px`,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Searching...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{state.query ? "No entries found" : "Start typing to search..."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{results.map((result, index) => (
|
||||
<li
|
||||
key={result.id}
|
||||
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||
index === selectedIndex
|
||||
? "bg-blue-50 dark:bg-blue-900/30"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => handleResultClick(result)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.summary && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
{result.summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{result.slug}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="px-3 py-2 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-200 dark:border-gray-700">
|
||||
↑↓ Navigate • Enter Select • Esc Cancel
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user