fix(CQ-WEB-9): Cache DOM measurement element in LinkAutocomplete
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
This commit is contained in:
@@ -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