2025-05-12 18:47:50 +03:00
|
|
|
import { EditorView, Decoration, MatchDecorator, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
2025-05-12 20:59:46 +03:00
|
|
|
import { RangeSet, RangeSetBuilder } from "@codemirror/state";
|
2025-05-12 18:47:50 +03:00
|
|
|
|
|
|
|
const searchMatchDecoration = Decoration.mark({ class: "cm-searchMatch" });
|
|
|
|
|
2025-05-12 20:26:48 +03:00
|
|
|
interface Match {
|
|
|
|
from: number;
|
|
|
|
to: number;
|
|
|
|
}
|
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
export class SearchHighlighter {
|
|
|
|
matches: RangeSet<Decoration>;
|
|
|
|
currentFound: number;
|
|
|
|
totalFound: number;
|
|
|
|
matcher?: MatchDecorator;
|
|
|
|
private parsedMatches: Match[];
|
|
|
|
|
|
|
|
constructor(public view: EditorView) {
|
|
|
|
this.parsedMatches = [];
|
|
|
|
this.currentFound = 0;
|
|
|
|
this.totalFound = 0;
|
2025-05-12 22:11:51 +03:00
|
|
|
this.matches = RangeSet.empty;
|
2025-05-12 20:59:46 +03:00
|
|
|
}
|
2025-05-12 18:47:50 +03:00
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
searchFor(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
|
2025-05-12 22:11:51 +03:00
|
|
|
if (!searchTerm) {
|
|
|
|
this.matches = RangeSet.empty;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
// Escape the search term for use in RegExp
|
|
|
|
const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
const wordBoundary = wholeWord ? "\\b" : "";
|
|
|
|
const flags = matchCase ? "g" : "gi";
|
|
|
|
const regex = new RegExp(`${wordBoundary}${escapedTerm}${wordBoundary}`, flags);
|
|
|
|
|
|
|
|
this.matcher = new MatchDecorator({
|
|
|
|
regexp: regex,
|
|
|
|
decoration: searchMatchDecoration,
|
|
|
|
});
|
2025-05-12 21:21:46 +03:00
|
|
|
this.#updateSearchData(this.view);
|
|
|
|
this.#scrollToMatchNearestSelection();
|
2025-05-12 20:59:46 +03:00
|
|
|
}
|
2025-05-12 18:47:50 +03:00
|
|
|
|
2025-05-12 21:21:46 +03:00
|
|
|
scrollToMatch(matchIndex: number) {
|
|
|
|
if (this.parsedMatches.length <= matchIndex) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const match = this.parsedMatches[matchIndex];
|
|
|
|
this.currentFound = matchIndex + 1;
|
|
|
|
this.view.dispatch({
|
|
|
|
effects: EditorView.scrollIntoView(match.from, { y: "center" }),
|
|
|
|
scrollIntoView: true
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
update(update: ViewUpdate) {
|
|
|
|
if (update.docChanged || update.viewportChanged) {
|
|
|
|
this.#updateSearchData(update.view);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
// Do nothing.
|
|
|
|
}
|
|
|
|
|
|
|
|
#updateSearchData(view: EditorView) {
|
2025-05-12 20:59:46 +03:00
|
|
|
if (!this.matcher) {
|
|
|
|
return;
|
2025-05-12 20:26:48 +03:00
|
|
|
}
|
2025-05-12 18:47:50 +03:00
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
const matches = this.matcher.createDeco(view);
|
|
|
|
const cursor = matches.iter();
|
|
|
|
while (cursor.value) {
|
|
|
|
this.parsedMatches.push({
|
|
|
|
from: cursor.from,
|
|
|
|
to: cursor.to
|
|
|
|
});
|
|
|
|
cursor.next();
|
|
|
|
}
|
2025-05-12 20:26:48 +03:00
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
this.matches = matches;
|
|
|
|
this.totalFound = this.parsedMatches.length;
|
|
|
|
}
|
|
|
|
|
2025-05-12 21:21:46 +03:00
|
|
|
#scrollToMatchNearestSelection() {
|
2025-05-12 20:59:46 +03:00
|
|
|
const cursorPos = this.view.state.selection.main.head;
|
|
|
|
let index = 0;
|
|
|
|
for (const match of this.parsedMatches) {
|
|
|
|
if (match.from >= cursorPos) {
|
|
|
|
this.scrollToMatch(index);
|
2025-05-12 20:26:48 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
index++;
|
2025-05-12 20:39:09 +03:00
|
|
|
}
|
2025-05-12 20:59:46 +03:00
|
|
|
}
|
2025-05-12 20:39:09 +03:00
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
static deco = (v: SearchHighlighter) => v.matches;
|
|
|
|
}
|
2025-05-12 18:47:50 +03:00
|
|
|
|
2025-05-12 20:59:46 +03:00
|
|
|
export function createSearchHighlighter() {
|
|
|
|
return ViewPlugin.fromClass(SearchHighlighter, {
|
2025-05-12 19:35:07 +03:00
|
|
|
decorations: v => v.matches,
|
|
|
|
provide: (plugin) => plugin
|
2025-05-12 18:47:50 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export const searchMatchHighlightTheme = EditorView.baseTheme({
|
|
|
|
".cm-searchMatch": {
|
|
|
|
backgroundColor: "rgba(255, 255, 0, 0.4)",
|
|
|
|
borderRadius: "2px"
|
|
|
|
}
|
|
|
|
});
|