Files
stack/apps/web/src/components/knowledge/LinkAutocomplete.tsx
Jason Woltje ac1f2c176f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: Resolve all ESLint errors and warnings in web package
Fixes all 542 ESLint problems in the web package to achieve 0 errors and 0 warnings.

Changes:
- Fixed 144 issues: nullish coalescing, return types, unused variables
- Fixed 118 issues: unnecessary conditions, type safety, template literals
- Fixed 79 issues: non-null assertions, unsafe assignments, empty functions
- Fixed 67 issues: explicit return types, promise handling, enum comparisons
- Fixed 45 final warnings: missing return types, optional chains
- Fixed 25 typecheck-related issues: async/await, type assertions, formatting
- Fixed JSX.Element namespace errors across 90+ files

All Quality Rails violations resolved. Lint and typecheck both pass with 0 problems.

Files modified: 118 components, tests, hooks, and utilities

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 00:10:03 -06:00

389 lines
11 KiB
TypeScript

"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 | 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 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
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 (
<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>
) : 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>
);
}