diff --git a/apps/web/src/components/knowledge/LinkAutocomplete.tsx b/apps/web/src/components/knowledge/LinkAutocomplete.tsx index fe78b60..9af5b9b 100644 --- a/apps/web/src/components/knowledge/LinkAutocomplete.tsx +++ b/apps/web/src/components/knowledge/LinkAutocomplete.tsx @@ -53,6 +53,8 @@ export function LinkAutocomplete({ const dropdownRef = useRef(null); const searchTimeoutRef = useRef(null); const abortControllerRef = useRef(null); + const mirrorRef = useRef(null); + const cursorSpanRef = useRef(null); /** * Search for knowledge entries matching the query. @@ -132,15 +134,37 @@ export function LinkAutocomplete({ ); /** - * Calculate dropdown position relative to cursor + * 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 } => { - // Create a mirror div to measure text position - const mirror = document.createElement("div"); - const styles = window.getComputedStyle(textarea); + // 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; - // Copy relevant styles + 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", @@ -161,31 +185,19 @@ export function LinkAutocomplete({ } }); - 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 + // Update content: text before cursor + cursor marker span 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 }; }, [] @@ -346,7 +358,8 @@ export function LinkAutocomplete({ }, [textareaRef, handleInput, handleKeyDown]); /** - * Cleanup timeout and abort in-flight requests on unmount + * Cleanup timeout, abort in-flight requests, and remove the + * persistent mirror element on unmount */ useEffect(() => { return (): void => { @@ -356,6 +369,11 @@ export function LinkAutocomplete({ if (abortControllerRef.current) { abortControllerRef.current.abort(); } + if (mirrorRef.current) { + document.body.removeChild(mirrorRef.current); + mirrorRef.current = null; + cursorSpanRef.current = null; + } }; }, []);