fix(CQ-WEB-9): Cache DOM measurement element in LinkAutocomplete
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
214139f4d5
commit
952eeb7323
@@ -53,6 +53,8 @@ export function LinkAutocomplete({
|
||||
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.
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user