import { getSearchQuery, RegExpCursor, SearchCursor, SearchQuery, setSearchQuery } from "@codemirror/search"; import { CharCategory, EditorState, findClusterBreak, Text } from "@codemirror/state"; import { SearchVisibilityEffect } from "./state"; import { EditorView } from "@codemirror/view"; import crelt from "crelt"; type Match = { from: number, to: number }; export class CustomSearchPanel { dom!: HTMLElement; searchField!: HTMLInputElement; replaceField!: HTMLInputElement; matchCountField!: HTMLElement; currentMatch!: number; matches!: Match[]; replaceVisibile: boolean = false; matchWord: boolean = false; matchCase: boolean = false; useRegex: boolean = false; private totalMatches: number = 0; searchCursor?: SearchCursor; regexCursor?: RegExpCursor; private codicon: Record = { "downChevron": '', "rightChevron": '', "matchCase": '', "wholeWord": '', "regex": '', "prevMatch": '', "nextMatch": '', "close": '', "replace": '', "replaceAll": '', }; constructor(readonly view: EditorView) { try { this.view = view; this.commit = this.commit.bind(this); // 从现有查询状态初始化匹配选项 const query = getSearchQuery(this.view.state); if (query) { this.matchCase = query.caseSensitive; this.matchWord = query.wholeWord; this.useRegex = query.regexp; } this.buildUI(); this.setVisibility(false); // 挂载到.cm-editor根容器,这样搜索框不会随内容滚动 const editor = this.view.dom.closest('.cm-editor') || this.view.dom.querySelector('.cm-editor'); if (editor) { editor.appendChild(this.dom); } else { // 如果当前DOM就是.cm-editor或者找不到.cm-editor,直接挂载到view.dom this.view.dom.appendChild(this.dom); } } catch (err) { console.warn(`ERROR: ${err}`); } } private updateMatchCount(): void { if (this.totalMatches > 0) { this.matchCountField.textContent = `${this.currentMatch + 1} of ${this.totalMatches}`; } else { this.matchCountField.textContent = `0 of 0`; } } private setSearchFieldError(hasError: boolean): void { if (hasError) { this.searchField.classList.add('error'); } else { this.searchField.classList.remove('error'); } } private charBefore(str: string, index: number) { return str.slice(findClusterBreak(str, index, false), index); } private charAfter(str: string, index: number) { return str.slice(index, findClusterBreak(str, index)); } private stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) { return (from: number, to: number, buf: string, bufPos: number) => { if (bufPos > from || bufPos + buf.length < to) { bufPos = Math.max(0, from - 2); buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2)); } return (categorizer(this.charBefore(buf, from - bufPos)) != CharCategory.Word || categorizer(this.charAfter(buf, from - bufPos)) != CharCategory.Word) && (categorizer(this.charAfter(buf, to - bufPos)) != CharCategory.Word || categorizer(this.charBefore(buf, to - bufPos)) != CharCategory.Word); }; } private regexpWordTest(categorizer: (ch: string) => CharCategory) { return (_from: number, _to: number, match: RegExpExecArray) => !match[0].length || (categorizer(this.charBefore(match.input, match.index)) != CharCategory.Word || categorizer(this.charAfter(match.input, match.index)) != CharCategory.Word) && (categorizer(this.charAfter(match.input, match.index + match[0].length)) != CharCategory.Word || categorizer(this.charBefore(match.input, match.index + match[0].length)) != CharCategory.Word); } /** * Finds all occurrences of a query, logs the total count, * and selects the closest one to the current cursor position. * * @param view - The CodeMirror editor view. * @param query - The search string to look for. */ findMatchesAndSelectClosest(state: EditorState): void { const cursorPos = state.selection.main.head; const query = getSearchQuery(state); if (query.regexp) { try { this.regexCursor = new RegExpCursor(state.doc, query.search); this.searchCursor = undefined; } catch (error) { // 如果正则表达式无效,清空匹配结果并显示错误状态 console.warn("Invalid regular expression:", query.search, error); this.matches = []; this.currentMatch = 0; this.totalMatches = 0; this.updateMatchCount(); this.regexCursor = undefined; this.searchCursor = undefined; this.setSearchFieldError(true); return; } } else { const cursor = new SearchCursor(state.doc, query.search); if (cursor !== this.searchCursor) { this.searchCursor = cursor; this.regexCursor = undefined; } } this.matches = []; if (this.searchCursor) { const matchWord = this.stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)); while (!this.searchCursor.done) { this.searchCursor.next(); if (!this.searchCursor.done) { const { from, to } = this.searchCursor.value; if (!query.wholeWord || matchWord(from, to, "", 0)) { this.matches.push({ from, to }); } } } } else if (this.regexCursor) { try { const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head)); while (!this.regexCursor.done) { this.regexCursor.next(); if (!this.regexCursor.done) { const { from, to, match } = this.regexCursor.value; if (!query.wholeWord || matchWord(from, to, match)) { this.matches.push({ from, to }); } } } } catch (error) { // 如果正则表达式执行时出错,清空匹配结果 console.warn("Error executing regular expression:", error); this.matches = []; } } this.currentMatch = 0; this.totalMatches = this.matches.length; if (this.matches.length === 0) { this.updateMatchCount(); this.setSearchFieldError(false); return; } // Find the match closest to the current cursor let closestDistance = Infinity; for (let i = 0; i < this.totalMatches; i++) { const dist = Math.abs(this.matches[i].from - cursorPos); if (dist < closestDistance) { closestDistance = dist; this.currentMatch = i; } } this.updateMatchCount(); this.setSearchFieldError(false); requestAnimationFrame(() => { const match = this.matches[this.currentMatch]; if (!match) return; this.view.dispatch({ selection: { anchor: match.from, head: match.to }, scrollIntoView: true }); }); } commit() { try { const newQuery = new SearchQuery({ search: this.searchField.value, replace: this.replaceField.value, caseSensitive: this.matchCase, regexp: this.useRegex, wholeWord: this.matchWord, }); const query = getSearchQuery(this.view.state); if (!newQuery.eq(query)) { this.view.dispatch({ effects: setSearchQuery.of(newQuery) }); } } catch (error) { // 如果创建SearchQuery时出错(通常是无效的正则表达式),记录错误但不中断程序 console.warn("Error creating search query:", error); } } private svgIcon(name: keyof CustomSearchPanel['codicon']): HTMLDivElement { const div = crelt("div", {}, ) as HTMLDivElement; div.innerHTML = this.codicon[name]; return div; } public toggleReplace() { this.replaceVisibile = !this.replaceVisibile; const replaceBar = this.dom.querySelector(".replace-bar") as HTMLElement; const replaceButtons = this.dom.querySelector(".replace-buttons") as HTMLElement; const toggleIcon = this.dom.querySelector(".toggle-replace") as HTMLElement; if (replaceBar && toggleIcon && replaceButtons) { replaceBar.style.display = this.replaceVisibile ? "flex" : "none"; replaceButtons.style.display = this.replaceVisibile ? "flex" : "none"; toggleIcon.innerHTML = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron").innerHTML; } } public showReplace() { if (!this.replaceVisibile) { this.toggleReplace(); } } public toggleCase() { this.matchCase = !this.matchCase; const toggleIcon = this.dom.querySelector(".case-sensitive-toggle") as HTMLElement; if (toggleIcon) { toggleIcon.classList.toggle("active"); } this.commit(); // 重新搜索以应用新的匹配规则 setTimeout(() => { this.findMatchesAndSelectClosest(this.view.state); }, 0); } public toggleWord() { this.matchWord = !this.matchWord; const toggleIcon = this.dom.querySelector(".whole-word-toggle") as HTMLElement; if (toggleIcon) { toggleIcon.classList.toggle("active"); } this.commit(); // 重新搜索以应用新的匹配规则 setTimeout(() => { this.findMatchesAndSelectClosest(this.view.state); }, 0); } public toggleRegex() { this.useRegex = !this.useRegex; const toggleIcon = this.dom.querySelector(".regex-toggle") as HTMLElement; if (toggleIcon) { toggleIcon.classList.toggle("active"); } this.commit(); // 重新搜索以应用新的匹配规则 setTimeout(() => { this.findMatchesAndSelectClosest(this.view.state); }, 0); } public matchPrevious() { if (this.totalMatches === 0) return; this.currentMatch = (this.currentMatch - 1 + this.totalMatches) % this.totalMatches; this.updateMatchCount(); // 直接跳转到匹配位置,不调用原生函数 const match = this.matches[this.currentMatch]; if (match) { this.view.dispatch({ selection: { anchor: match.from, head: match.to }, scrollIntoView: true }); } } public matchNext() { if (this.totalMatches === 0) return; this.currentMatch = (this.currentMatch + 1) % this.totalMatches; this.updateMatchCount(); // 直接跳转到匹配位置,不调用原生函数 const match = this.matches[this.currentMatch]; if (match) { this.view.dispatch({ selection: { anchor: match.from, head: match.to }, scrollIntoView: true }); } } public findReplaceMatch() { const query = getSearchQuery(this.view.state); if (query.replace) { this.replace(); } else { this.matchNext(); } } private close() { this.view.dispatch({ effects: SearchVisibilityEffect.of(false) }); } public replace() { if (this.totalMatches === 0) return; const match = this.matches[this.currentMatch]; if (match) { const query = getSearchQuery(this.view.state); if (query.replace) { // 执行替换 this.view.dispatch({ changes: { from: match.from, to: match.to, insert: query.replace }, selection: { anchor: match.from, head: match.from + query.replace.length } }); // 重新查找匹配项 this.findMatchesAndSelectClosest(this.view.state); } } } public replaceAll() { if (this.totalMatches === 0) return; const query = getSearchQuery(this.view.state); if (query.replace) { // 从后往前替换,避免位置偏移问题 const changes = this.matches .slice() .reverse() .map(match => ({ from: match.from, to: match.to, insert: query.replace })); this.view.dispatch({ changes: changes }); // 重新查找匹配项 this.findMatchesAndSelectClosest(this.view.state); } } private buildUI(): void { const query = getSearchQuery(this.view.state); this.searchField = crelt("input", { value: query?.search ?? "", type: "text", placeholder: "Find", class: "find-input", "main-field": "true", onchange: this.commit, onkeyup: this.commit }) as HTMLInputElement; this.replaceField = crelt("input", { value: query?.replace ?? "", type: "text", placeholder: "Replace", class: "replace-input", onchange: this.commit, onkeyup: this.commit }) as HTMLInputElement; const caseField = this.svgIcon("matchCase"); caseField.className = "case-sensitive-toggle"; caseField.title = "Match Case (Alt+C)"; caseField.addEventListener("click", () => { this.toggleCase(); }); const wordField = this.svgIcon("wholeWord"); wordField.className = "whole-word-toggle"; wordField.title = "Match Whole Word (Alt+W)"; wordField.addEventListener("click", () => { this.toggleWord(); }); const reField = this.svgIcon("regex"); reField.className = "regex-toggle"; reField.title = "Use Regular Expression (Alt+R)"; reField.addEventListener("click", () => { this.toggleRegex(); }); const toggleReplaceIcon = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron"); toggleReplaceIcon.className = "toggle-replace"; toggleReplaceIcon.addEventListener("click", () => { this.toggleReplace(); }); this.matchCountField = crelt("span", { class: "match-count" }, "0 of 0"); const prevMatchButton = this.svgIcon("prevMatch"); prevMatchButton.className = "prev-match"; prevMatchButton.title = "Previous Match (Shift+Enter)"; prevMatchButton.addEventListener("click", () => { this.matchPrevious(); }); const nextMatchButton = this.svgIcon("nextMatch"); nextMatchButton.className = "next-match"; nextMatchButton.title = "Next Match (Enter)"; nextMatchButton.addEventListener("click", () => { this.matchNext(); }); const closeButton = this.svgIcon("close"); closeButton.className = "close"; closeButton.title = "Close (Escape)"; closeButton.addEventListener("click", () => { this.close(); }); const replaceButton = this.svgIcon("replace"); replaceButton.className = "replace-button"; replaceButton.title = "Replace (Enter)"; replaceButton.addEventListener("click", () => { this.replace(); }); const replaceAllButton = this.svgIcon("replaceAll"); replaceAllButton.className = "replace-button"; replaceAllButton.title = "Replace All (Ctrl+Alt+Enter)"; replaceAllButton.addEventListener("click", () => { this.replaceAll(); }); const resizeHandle = crelt("div", { class: "resize-handle" }); const toggleSection = crelt("div", { class: "toggle-section" }, resizeHandle, toggleReplaceIcon ); let startX: number; let startWidth: number; const startResize = (e: MouseEvent) => { startX = e.clientX; startWidth = this.dom.offsetWidth; document.addEventListener('mousemove', resize); document.addEventListener('mouseup', stopResize); }; const resize = (e: MouseEvent) => { const width = startWidth + (startX - e.clientX); const container = this.dom as HTMLDivElement; container.style.width = `${Math.max(420, Math.min(800, width))}px`; }; const stopResize = () => { document.removeEventListener('mousemove', resize); document.removeEventListener('mouseup', stopResize); }; resizeHandle.addEventListener('mousedown', startResize); const searchControls = crelt("div", { class: "search-controls" }, caseField, wordField, reField ); const searchBar = crelt("div", { class: "search-bar" }, this.searchField, searchControls ); const replaceBar = crelt("div", { class: "replace-bar", }, this.replaceField ); replaceBar.style.display = this.replaceVisibile ? "flex" : "none"; const inputSection = crelt("div", { class: "input-section" }, searchBar, replaceBar ); const searchIcons = crelt("div", { class: "search-icons" }, prevMatchButton, nextMatchButton, closeButton ); const searchButtons = crelt("div", { class: "button-group" }, this.matchCountField, searchIcons ); const replaceButtons = crelt("div", { class: "replace-buttons", }, replaceButton, replaceAllButton ); replaceButtons.style.display = this.replaceVisibile ? "flex" : "none"; const actionSection = crelt("div", { class: "actions-section" }, searchButtons, replaceButtons ); this.dom = crelt("div", { class: "find-replace-container", "data-keymap-scope": "search" }, toggleSection, inputSection, actionSection ); // 根据当前状态设置按钮的active状态 if (this.matchCase) { caseField.classList.add("active"); } if (this.matchWord) { wordField.classList.add("active"); } if (this.useRegex) { reField.classList.add("active"); } } setVisibility(visible: boolean) { this.dom.style.display = visible ? "flex" : "none"; if (visible) { // 使用 setTimeout 确保DOM已经渲染 setTimeout(() => { this.searchField.focus(); this.searchField.select(); }, 0); } } mount() { this.searchField.select(); } destroy?(): void { throw new Error("Method not implemented."); } get pos() { return 80; } }