diff --git a/apps/client/src/widgets/find_in_code.ts b/apps/client/src/widgets/find_in_code.ts index 63081bb0b..99d6a335f 100644 --- a/apps/client/src/widgets/find_in_code.ts +++ b/apps/client/src/widgets/find_in_code.ts @@ -31,106 +31,17 @@ export default class FindInCode { } async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { - let findResult: Match[] | null = null; - let totalFound = 0; - let currentFound = -1; + const totalFound = 0; + const currentFound = 0; - // See https://codemirror.net/addon/search/searchcursor.js for tips const codeEditor = await this.getCodeEditor(); if (!codeEditor) { - return { totalFound: 0, currentFound: 0 }; + return { totalFound, currentFound }; } - const doc = codeEditor.doc; - const text = doc.getValue(); + await codeEditor.performFind(searchTerm, matchCase, wholeWord); - // Clear all markers - if (this.findResult) { - codeEditor.operation(() => { - const findResult = this.findResult as Match[]; - for (let i = 0; i < findResult.length; ++i) { - const marker = findResult[i]; - marker.clear(); - } - }); - } - - if (searchTerm !== "") { - searchTerm = utils.escapeRegExp(searchTerm); - - // Find and highlight matches - // Find and highlight matches - // XXX Using \\b and not using the unicode flag probably doesn't - // work with non-ASCII alphabets, findAndReplace uses a more - // complicated regexp, see - // https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/utils.js#L145 - const wholeWordChar = wholeWord ? "\\b" : ""; - const re = new RegExp(wholeWordChar + searchTerm + wholeWordChar, "g" + (matchCase ? "" : "i")); - let curLine = 0; - let curChar = 0; - let curMatch: RegExpExecArray | null = null; - findResult = []; - // All those markText take several seconds on e.g., this ~500-line - // script, batch them inside an operation, so they become - // unnoticeable. Alternatively, an overlay could be used, see - // https://codemirror.net/addon/search/match-highlighter.js ? - codeEditor.operation(() => { - for (let i = 0; i < text.length; ++i) { - // Fetch the next match if it's the first time or if past the current match start - if (curMatch == null || curMatch.index < i) { - curMatch = re.exec(text); - if (curMatch == null) { - // No more matches - break; - } - } - // Create a non-selected highlight marker for the match, the - // selected marker highlight will be done later - if (i === curMatch.index) { - let fromPos = { line: curLine, ch: curChar }; - // If multiline is supported, this needs to recalculate curLine since the match may span lines - let toPos = { line: curLine, ch: curChar + curMatch[0].length }; - // or css = "color: #f3" - let marker = doc.markText(fromPos, toPos, { className: FIND_RESULT_CSS_CLASSNAME }); - findResult?.push(marker); - - // Set the first match beyond the cursor as the current match - if (currentFound === -1) { - const cursorPos = codeEditor.getCursor(); - if (fromPos.line > cursorPos.line || (fromPos.line === cursorPos.line && fromPos.ch >= cursorPos.ch)) { - currentFound = totalFound; - } - } - - totalFound++; - } - // Do line and char position tracking - if (text[i] === "\n") { - curLine++; - curChar = 0; - } else { - curChar++; - } - } - }); - } - - this.findResult = findResult; - - // Calculate curfound if not already, highlight it as selected - if (findResult && totalFound > 0) { - currentFound = Math.max(0, currentFound); - let marker = findResult[currentFound]; - let pos = marker.find(); - codeEditor.scrollIntoView(pos.to); - marker.clear(); - findResult[currentFound] = doc.markText(pos.from, pos.to, { className: FIND_RESULT_SELECTED_CSS_CLASSNAME }); - } - - return { - totalFound, - currentFound: Math.min(currentFound + 1, totalFound) - }; + return { totalFound, currentFound }; } async findNext(direction: number, currentFound: number, nextFound: number) { diff --git a/packages/codemirror/src/find_replace.ts b/packages/codemirror/src/find_replace.ts new file mode 100644 index 000000000..5b1ead702 --- /dev/null +++ b/packages/codemirror/src/find_replace.ts @@ -0,0 +1,45 @@ +import { EditorView, Decoration, MatchDecorator, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { StateEffect, Compartment } from "@codemirror/state"; + +const searchMatchDecoration = Decoration.mark({ class: "cm-searchMatch" }); + +export function createSearchHighlighter(view: EditorView, searchTerm: string, matchCase: boolean, wholeWord: boolean) { + // 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); + + const matcher = new MatchDecorator({ + regexp: regex, + decoration: searchMatchDecoration, + }); + + return ViewPlugin.fromClass(class SearchHighlighter { + matches = matcher.createDeco(view); + + constructor(public view: EditorView) { } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.matches = matcher.createDeco(update.view); + } + } + + destroy() { + // Do nothing. + } + + static deco = (v: SearchHighlighter) => v.matches; + }, { + decorations: v => v.matches + }); +} + + +export const searchMatchHighlightTheme = EditorView.baseTheme({ + ".cm-searchMatch": { + backgroundColor: "rgba(255, 255, 0, 0.4)", + borderRadius: "2px" + } +}); diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts index fa491d074..86f5e3e35 100644 --- a/packages/codemirror/src/index.ts +++ b/packages/codemirror/src/index.ts @@ -7,6 +7,7 @@ import { vim } from "@replit/codemirror-vim"; import byMimeType from "./syntax_highlighting.js"; import smartIndentWithTab from "./extensions/custom_tab.js"; import type { ThemeDefinition } from "./color_themes.js"; +import { createSearchHighlighter, searchMatchHighlightTheme } from "./find_replace.js"; export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js"; @@ -29,12 +30,14 @@ export default class CodeMirror extends EditorView { private historyCompartment: Compartment; private themeCompartment: Compartment; private lineWrappingCompartment: Compartment; + private searchHighlightCompartment: Compartment; constructor(config: EditorConfig) { const languageCompartment = new Compartment(); const historyCompartment = new Compartment(); const themeCompartment = new Compartment(); const lineWrappingCompartment = new Compartment(); + const searchHighlightCompartment = new Compartment(); let extensions: Extension[] = []; @@ -49,6 +52,10 @@ export default class CodeMirror extends EditorView { themeCompartment.of([ syntaxHighlighting(defaultHighlightStyle, { fallback: true }) ]), + + searchMatchHighlightTheme, + searchHighlightCompartment.of([]), + highlightActiveLine(), highlightSelectionMatches(), bracketMatching(), @@ -92,6 +99,7 @@ export default class CodeMirror extends EditorView { this.historyCompartment = historyCompartment; this.themeCompartment = themeCompartment; this.lineWrappingCompartment = lineWrappingCompartment; + this.searchHighlightCompartment = searchHighlightCompartment; } #onDocumentUpdated(v: ViewUpdate) { @@ -163,6 +171,13 @@ export default class CodeMirror extends EditorView { }); } + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { + const plugin = createSearchHighlighter(this, searchTerm, matchCase, wholeWord); + this.dispatch({ + effects: this.searchHighlightCompartment.reconfigure(plugin) + }); + } + async setMimeType(mime: string) { let newExtension: Extension[] = [];