Use CSS highlights instead of editor decorations when available

This commit is contained in:
Tom Moor
2026-04-01 20:35:35 -04:00
parent c3c5f148b7
commit 517b0fb3ec
2 changed files with 177 additions and 51 deletions
+165 -48
View File
@@ -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) => (
+12 -3
View File
@@ -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;
}
`;