diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx index f3a19102a0..34665d4292 100644 --- a/app/editor/components/FindAndReplace.tsx +++ b/app/editor/components/FindAndReplace.tsx @@ -1,3 +1,4 @@ +import { debounce } from "es-toolkit/compat"; import { CaretDownIcon, CaretUpIcon, @@ -211,9 +212,31 @@ export default function FindAndReplace({ }); }, [caseSensitive, editor.commands, searchTerm]); + // Searching the document on every keystroke is expensive in long documents – + // it traverses the entire doc and rebuilds highlights – so debounce. + const debouncedFind = React.useMemo( + () => + debounce( + (attrs: { + text: string; + caseSensitive: boolean; + regexEnabled: boolean; + }) => { + editor.commands.find(attrs); + }, + 250 + ), + [editor.commands] + ); + + React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]); + const handleKeyDown = React.useCallback( (ev: React.KeyboardEvent) => { function nextPrevious() { + // Ensure any pending debounced search has run so navigation acts on the + // results for the text currently in the input. + debouncedFind.flush(); if (ev.shiftKey) { editor.commands.prevSearchMatch(); } else { @@ -243,7 +266,7 @@ export default function FindAndReplace({ } } }, - [editor.commands, selectInputText] + [debouncedFind, editor.commands, selectInputText] ); const handleReplace = React.useCallback( @@ -274,13 +297,13 @@ export default function FindAndReplace({ ev.stopPropagation(); setSearchTerm(ev.currentTarget.value); - editor.commands.find({ + debouncedFind({ text: ev.currentTarget.value, caseSensitive, regexEnabled, }); }, - [caseSensitive, editor.commands, regexEnabled] + [caseSensitive, debouncedFind, regexEnabled] ); const handleReplaceKeyDown = React.useCallback( @@ -331,6 +354,9 @@ export default function FindAndReplace({ } else { onClose(); setShowReplace(false); + // Cancel any pending debounced find so it can't reactivate highlights + // after the search has been cleared. + debouncedFind.cancel(); editor.commands.clearSearch(); } // oxlint-disable-next-line react-hooks/exhaustive-deps @@ -346,7 +372,10 @@ export default function FindAndReplace({ > editor.commands.prevSearchMatch()} + onClick={() => { + debouncedFind.flush(); + editor.commands.prevSearchMatch(); + }} aria-label={t("Previous match")} > @@ -355,7 +384,10 @@ export default function FindAndReplace({ editor.commands.nextSearchMatch()} + onClick={() => { + debouncedFind.flush(); + editor.commands.nextSearchMatch(); + }} aria-label={t("Next match")} > diff --git a/app/editor/extensions/FindAndReplace.tsx b/app/editor/extensions/FindAndReplace.tsx index 01ae8505b9..cf30df77a5 100644 --- a/app/editor/extensions/FindAndReplace.tsx +++ b/app/editor/extensions/FindAndReplace.tsx @@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension(); + mergedTextNodes.forEach((node) => { const { text = "", pos, type } = node; try { @@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension r.from === from && r.to === to)) { + // Check if already exists in results, possible because we search + // over `deburr(text) + text` + const key = `${from}:${to}`; + if (seen.has(key)) { continue; } + seen.add(key); this.results.push({ from, to, type }); } @@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension + !range.startContainer.isConnected || !range.endContainer.isConnected + ); + } + private handleEscape = () => { const params = new URLSearchParams(window.location.search); if (params.has("q")) { @@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension { const generation = pluginKey.getState(view.state) as number; - // Rebuild highlights when the results change (generation bump) or, - // while a search is active, on any view update. The CSS Custom - // Highlight API relies on static DOM ranges that become detached - // when the editor re-renders its DOM — e.g. content settling after - // sync when navigating from search results, collaboration cursors, - // or node views mounting — none of which bump the generation. This - // keeps the highlights tracking the live DOM, as decorations do. - if (generation !== lastGeneration || this.searchTerm) { + // The results changed (search ran, doc changed, fold toggled), so + // always rebuild. + if (generation !== lastGeneration) { lastGeneration = generation; this.updateHighlights(); + return; + } + // Results unchanged: only rebuild when the static highlight ranges + // have detached from a DOM re-render that didn't bump the generation. + if (this.searchTerm && this.highlightsStale()) { + this.updateHighlights(); } }, destroy: () => {