mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 03:29:02 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			176 lines
		
	
	
		
			5.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			176 lines
		
	
	
		
			5.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { EditorView, Decoration, MatchDecorator, ViewPlugin, ViewUpdate } from "@codemirror/view";
 | |
| import { foldState, unfoldEffect } from "@codemirror/language";
 | |
| import { Range, RangeSet, StateEffect } from "@codemirror/state";
 | |
| 
 | |
| const searchMatchDecoration = Decoration.mark({ class: "cm-searchMatch" });
 | |
| const activeMatchDecoration = Decoration.mark({ class: "cm-activeMatch" });
 | |
| 
 | |
| interface Match {
 | |
|     from: number;
 | |
|     to: number;
 | |
| }
 | |
| 
 | |
| export class SearchHighlighter {
 | |
|     matches: RangeSet<Decoration>;
 | |
|     activeMatch?: Range<Decoration>;
 | |
| 
 | |
|     currentFound: number;
 | |
|     totalFound: number;
 | |
|     matcher?: MatchDecorator;
 | |
|     searchRegexp?: RegExp;
 | |
|     private parsedMatches: Match[];
 | |
| 
 | |
|     constructor(public view: EditorView) {
 | |
|         this.parsedMatches = [];
 | |
|         this.currentFound = 0;
 | |
|         this.totalFound = 0;
 | |
| 
 | |
|         this.matches = RangeSet.empty;
 | |
|     }
 | |
| 
 | |
|     searchFor(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
 | |
|         if (!searchTerm) {
 | |
|             this.matches = RangeSet.empty;
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // 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,
 | |
|         });
 | |
|         this.searchRegexp = regex;
 | |
|         this.#updateSearchData(this.view);
 | |
|         this.#scrollToMatchNearestSelection();
 | |
|     }
 | |
| 
 | |
|     replaceActiveMatch(replacementText: string) {
 | |
|         if (!this.parsedMatches.length || this.currentFound === 0) return;
 | |
| 
 | |
|         const matchIndex = this.currentFound - 1;
 | |
|         const match = this.parsedMatches[matchIndex];
 | |
| 
 | |
|         this.view.dispatch({
 | |
|             changes: { from: match.from, to: match.to, insert: replacementText }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     replaceAll(replacementText: string) {
 | |
|         if (!this.parsedMatches.length) return;
 | |
| 
 | |
|         this.view.dispatch({
 | |
|             changes: this.parsedMatches.map(change => ({
 | |
|                 from: change.from,
 | |
|                 to: change.to,
 | |
|                 insert: replacementText
 | |
|             }))
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     scrollToMatch(matchIndex: number) {
 | |
|         if (this.parsedMatches.length <= matchIndex) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const match = this.parsedMatches[matchIndex];
 | |
|         this.currentFound = matchIndex + 1;
 | |
|         this.activeMatch = activeMatchDecoration.range(match.from, match.to);
 | |
| 
 | |
|         // Check if the match is inside a folded region.
 | |
|         const unfoldEffects: StateEffect<unknown>[] = [];
 | |
|         const folded = this.view.state.field(foldState);
 | |
|         const iter = folded.iter();
 | |
|         while (iter.value) {
 | |
|             if (match.from >= iter.from && match.to <= iter.to) {
 | |
|                 unfoldEffects.push(unfoldEffect.of({ from: iter.from, to: iter.to }));
 | |
|             }
 | |
|             iter.next();
 | |
|         }
 | |
| 
 | |
|         this.view.dispatch({
 | |
|             effects: [
 | |
|                 ...unfoldEffects,
 | |
|                 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) {
 | |
|         if (!this.matcher) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // Create the match decorator which will automatically highlight matches in the document.
 | |
|         this.matches = this.matcher.createDeco(view);
 | |
| 
 | |
|         // Manually search for matches in the current document in order to get the total number of matches.
 | |
|         const parsedMatches: Match[] = [];
 | |
|         const text = view.state.doc.toString();
 | |
|         let match: RegExpExecArray | null | undefined;
 | |
|         while ((match = this.searchRegexp?.exec(text))) {
 | |
|             const from = match.index ?? 0;
 | |
|             const to = from + match[0].length;
 | |
| 
 | |
|             parsedMatches.push({ from, to });
 | |
|         }
 | |
| 
 | |
|         this.parsedMatches = parsedMatches;
 | |
|         this.totalFound = this.parsedMatches.length;
 | |
|     }
 | |
| 
 | |
|     #scrollToMatchNearestSelection() {
 | |
|         const cursorPos = this.view.state.selection.main.head;
 | |
|         let index = 0;
 | |
|         for (const match of this.parsedMatches) {
 | |
|             if (match.from >= cursorPos) {
 | |
|                 this.scrollToMatch(index);
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             index++;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     static deco = (v: SearchHighlighter) => v.matches;
 | |
| }
 | |
| 
 | |
| export function createSearchHighlighter() {
 | |
|     return ViewPlugin.fromClass(SearchHighlighter, {
 | |
|         decorations: v => {
 | |
|             if (v.activeMatch) {
 | |
|                 return v.matches.update({ add: [v.activeMatch] });
 | |
|             } else {
 | |
|                 return v.matches;
 | |
|             }
 | |
|         }
 | |
|     });
 | |
| }
 | |
| 
 | |
| export const searchMatchHighlightTheme = EditorView.baseTheme({
 | |
|     ".cm-searchMatch": {
 | |
|         backgroundColor: "rgba(255, 255, 0, 0.4)",
 | |
|         borderRadius: "2px"
 | |
|     },
 | |
|     ".cm-activeMatch": {
 | |
|         backgroundColor: "rgba(255, 165, 0, 0.6)",
 | |
|         borderRadius: "2px",
 | |
|         outline: "2px solid orange"
 | |
|     }
 | |
| });
 | 
