diff --git a/app/editor/extensions/FindAndReplace.tsx b/app/editor/extensions/FindAndReplace.tsx index 50a4b2e277..e69a297509 100644 --- a/app/editor/extensions/FindAndReplace.tsx +++ b/app/editor/extensions/FindAndReplace.tsx @@ -14,6 +14,8 @@ import { ancestors } from "@shared/editor/utils"; import FindAndReplace from "../components/FindAndReplace"; const pluginKey = new PluginKey("find-and-replace"); +const supportsHighlightAPI = + typeof CSS !== "undefined" && CSS.highlights !== undefined; export default class FindAndReplaceExtension extends Extension { public get name() { @@ -22,8 +24,6 @@ export default class FindAndReplaceExtension extends Extension { public get defaultOptions() { return { - resultClassName: "find-result", - resultCurrentClassName: "current-result", caseSensitive: false, regexEnabled: false, }; @@ -105,20 +105,6 @@ export default class FindAndReplaceExtension extends Extension { }; } - private get decorations() { - return this.results.map((deco, index) => { - const decorationType = - deco.type === "node" ? Decoration.node : Decoration.inline; - return decorationType(deco.from, deco.to, { - class: - this.options.resultClassName + - (this.currentResultIndex === index - ? ` ${this.options.resultCurrentClassName}` - : ""), - }); - }); - } - public replace(replace: string): Command { return (state, dispatch) => { // Redo the search to ensure we have the latest results, the document may @@ -232,14 +218,25 @@ export default class FindAndReplaceExtension extends Extension { } private scrollToCurrentMatch() { - const element = window.document.querySelector( - `.${this.options.resultCurrentClassName}` - ); - if (element) { - scrollIntoView(element, { - scrollMode: "if-needed", - block: "center", - }); + if (supportsHighlightAPI) { + if (this.currentHighlightRange) { + const node = this.currentHighlightRange.startContainer; + const element = node instanceof HTMLElement ? node : node.parentElement; + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + block: "center", + }); + } + } + } else { + const element = window.document.querySelector(".current-result"); + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + block: "center", + }); + } } } @@ -407,13 +404,83 @@ export default class FindAndReplaceExtension extends Extension { }); } - private createDeco(doc: Node) { + /** + * Build ProseMirror decorations from search results (fallback for browsers + * without CSS Custom Highlight API support). + */ + private get decorations() { + return this.results.map((deco, index) => { + const decorationType = + deco.type === "node" ? Decoration.node : Decoration.inline; + return decorationType(deco.from, deco.to, { + class: + "find-result" + + (this.currentResultIndex === index ? " current-result" : ""), + }); + }); + } + + /** + * Create a DecorationSet from the current search results. + */ + private createDecorationSet(doc: Node) { this.search(doc); - return this.decorations + return this.decorations.length ? DecorationSet.create(doc, this.decorations) : DecorationSet.empty; } + /** + * Update CSS Custom Highlight API highlights based on current search results. + */ + private updateHighlights() { + const view = this.editor?.view; + if (!view || !this.results.length || !this.searchTerm) { + CSS.highlights.delete("search-results"); + CSS.highlights.delete("search-results-current"); + this.currentHighlightRange = undefined; + return; + } + + const allRanges: StaticRange[] = []; + const currentRanges: StaticRange[] = []; + this.currentHighlightRange = undefined; + + for (let i = 0; i < this.results.length; i++) { + const result = this.results[i]; + try { + const from = view.domAtPos(result.from); + const to = view.domAtPos(result.to); + const range = new StaticRange({ + startContainer: from.node, + startOffset: from.offset, + endContainer: to.node, + endOffset: to.offset, + }); + allRanges.push(range); + + if (i === this.currentResultIndex) { + currentRanges.push(range); + this.currentHighlightRange = range; + } + } catch { + // Position may not be in the visible DOM (e.g. inside folded toggle) + } + } + + CSS.highlights.set("search-results", new Highlight(...allRanges)); + if (currentRanges.length) { + CSS.highlights.set( + "search-results-current", + new Highlight(...currentRanges) + ); + } else { + CSS.highlights.delete("search-results-current"); + } + } + + private currentHighlightRange?: StaticRange; + get allowInReadOnly() { return true; } @@ -423,35 +490,85 @@ export default class FindAndReplaceExtension extends Extension { } get plugins() { - return [ - new Plugin({ - key: pluginKey, - state: { - init: () => DecorationSet.empty, - apply: (tr, decorationSet) => { - const action = tr.getMeta(pluginKey); + if (supportsHighlightAPI) { + return [this.highlightAPIPlugin]; + } + return [this.decorationPlugin]; + } - if (action) { - if (action.open) { - this.open = true; - } - return this.createDeco(tr.doc); + /** Plugin using the CSS Custom Highlight API (no DOM modifications). */ + private get highlightAPIPlugin() { + return new Plugin({ + key: pluginKey, + state: { + init: () => 0, + apply: (tr, generation) => { + const action = tr.getMeta(pluginKey); + + if (action) { + if (action.open) { + this.open = true; } + this.search(tr.doc); + return generation + 1; + } - if (tr.docChanged) { - return decorationSet.map(tr.mapping, tr.doc); + if (tr.docChanged && this.searchTerm) { + this.search(tr.doc); + return generation + 1; + } + + return generation; + }, + }, + view: () => { + let lastGeneration = 0; + return { + update: (view) => { + const generation = pluginKey.getState(view.state) as number; + if (generation !== lastGeneration) { + lastGeneration = generation; + this.updateHighlights(); } + }, + destroy: () => { + CSS.highlights?.delete("search-results"); + CSS.highlights?.delete("search-results-current"); + }, + }; + }, + }); + } - return decorationSet; - }, + /** Fallback plugin using ProseMirror decorations. */ + private get decorationPlugin() { + return new Plugin({ + key: pluginKey, + state: { + init: () => DecorationSet.empty, + apply: (tr, decorationSet) => { + const action = tr.getMeta(pluginKey); + + if (action) { + if (action.open) { + this.open = true; + } + return this.createDecorationSet(tr.doc); + } + + if (tr.docChanged) { + return decorationSet.map(tr.mapping, tr.doc); + } + + return decorationSet; }, - props: { - decorations(state) { - return this.getState(state); - }, + }, + props: { + decorations(state) { + return this.getState(state); }, - }), - ]; + }, + }); } public widget = ({ readOnly }: WidgetProps) => ( diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 7a2b18b7ed..994bfc13cf 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -405,6 +405,16 @@ const diffStyle = (props: Props) => css` `; const findAndReplaceStyle = () => css` + ::highlight(search-results) { + background-color: rgba(255, 213, 0, 0.25); + color: inherit; + } + + ::highlight(search-results-current) { + background-color: rgba(255, 213, 0, 0.75); + color: inherit; + } + .find-result:not(:has(.mention)), .find-result .mention { background: rgba(255, 213, 0, 0.25); @@ -412,9 +422,8 @@ const findAndReplaceStyle = () => css` .find-result.current-result:not(:has(.mention)), .find-result.current-result .mention { - background: rgba(255, 213, 0, 0.75); - animation: ${pulse("rgba(255, 213, 0, 0.75)")} 150ms 1; - } + background: rgba(255, 213, 0, 0.75); + animation: ${pulse("rgba(255, 213, 0, 0.75)")} 150ms 1; } `;