Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Replace per-keystroke DOM element creation/removal with a persistent off-screen mirror element stored in useRef. The mirror and cursor span are lazily created on first use and reused for all subsequent caret position measurements, eliminating layout thrashing. Cleanup on component unmount removes the element from the DOM. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
"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<HTMLTextAreaElement | null>;
|
|
/**
|
|
* 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 [searchError, setSearchError] = useState<string | null>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
|
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
|
|
|
/**
|
|
* 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<void> => {
|
|
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
|
|
*/
|
|
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, 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 (
|
|
<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: `${String(state.position.top)}px`,
|
|
left: `${String(state.position.left)}px`,
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">Searching...</div>
|
|
) : searchError ? (
|
|
<div className="p-3 text-sm text-amber-600 dark:text-amber-400">{searchError}</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>
|
|
);
|
|
}
|