From c79cba48c26caf2944df13b971685ef11320d992 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Tue, 10 Jun 2025 19:17:29 +0800 Subject: [PATCH] :sparkles: Added search and replace --- .../src/views/editor/extensions/basicSetup.ts | 10 +- .../views/editor/extensions/tabExtension.ts | 2 +- .../vscodeSearch/FindReplaceControl.ts | 583 ++++++++++++++++++ .../editor/plugins/vscodeSearch/commands.ts | 172 ++++++ .../editor/plugins/vscodeSearch/index.ts | 5 + .../editor/plugins/vscodeSearch/keymap.ts | 81 +++ .../editor/plugins/vscodeSearch/plugin.ts | 80 +++ .../editor/plugins/vscodeSearch/state.ts | 19 + .../editor/plugins/vscodeSearch/theme.ts | 255 ++++++++ .../editor/plugins/vscodeSearch/utility.ts | 26 + 10 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/commands.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/index.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/keymap.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/plugin.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/state.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/theme.ts create mode 100644 frontend/src/views/editor/plugins/vscodeSearch/utility.ts diff --git a/frontend/src/views/editor/extensions/basicSetup.ts b/frontend/src/views/editor/extensions/basicSetup.ts index dca727d..36d08c9 100644 --- a/frontend/src/views/editor/extensions/basicSetup.ts +++ b/frontend/src/views/editor/extensions/basicSetup.ts @@ -10,6 +10,7 @@ import { keymap, lineNumbers, rectangularSelection, + KeyBinding, } from '@codemirror/view'; import { bracketMatching, @@ -20,14 +21,17 @@ import { syntaxHighlighting, } from '@codemirror/language'; import {defaultKeymap, history, historyKeymap,} from '@codemirror/commands'; -import {highlightSelectionMatches, searchKeymap} from '@codemirror/search'; +import {highlightSelectionMatches} from '@codemirror/search'; import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete'; import {lintKeymap} from '@codemirror/lint'; - +import { vscodeSearch, customSearchKeymap, searchVisibilityField } from '../plugins/vscodeSearch'; // 基本编辑器设置,包含常用扩展 export const createBasicSetup = (): Extension[] => { return [ + vscodeSearch, + searchVisibilityField, + // 基础UI lineNumbers(), highlightActiveLineGutter(), @@ -59,9 +63,9 @@ export const createBasicSetup = (): Extension[] => { // 键盘映射 keymap.of([ + ...customSearchKeymap as KeyBinding[], ...closeBracketsKeymap, ...defaultKeymap, - ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap, diff --git a/frontend/src/views/editor/extensions/tabExtension.ts b/frontend/src/views/editor/extensions/tabExtension.ts index ac3c5cc..1bf7045 100644 --- a/frontend/src/views/editor/extensions/tabExtension.ts +++ b/frontend/src/views/editor/extensions/tabExtension.ts @@ -2,7 +2,7 @@ import {Compartment, Extension} from '@codemirror/state'; import {EditorView, keymap} from '@codemirror/view'; import {indentSelection} from '@codemirror/commands'; import {indentUnit} from '@codemirror/language'; -import {TabType} from '../../../../bindings/voidraft/internal/models/models'; +import {TabType} from '@/../bindings/voidraft/internal/models/models'; // Tab设置相关的compartment export const tabSizeCompartment = new Compartment(); diff --git a/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts b/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts new file mode 100644 index 0000000..01342d7 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts @@ -0,0 +1,583 @@ +import { findNext, findPrevious, getSearchQuery, RegExpCursor, replaceAll, replaceNext, 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 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; + let query = getSearchQuery(state); + + + if (query.regexp) { + this.regexCursor = new RegExpCursor(state.doc, query.search) + this.searchCursor = undefined; + } + else { + + let 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) { + + 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 }); + } + + } + } + } + + this.currentMatch = 0; + this.totalMatches = this.matches.length; + + if (this.matches.length === 0) { + this.updateMatchCount(); + 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(); + + + requestAnimationFrame(() => { + const match = this.matches[this.currentMatch]; + if (!match) return; + + this.view.dispatch({ + selection: { anchor: match.from, head: match.to }, + scrollIntoView: true + }); + }); + } + + commit() { + const newQuery = new SearchQuery({ + search: this.searchField.value, + replace: this.replaceField.value, + caseSensitive: this.matchCase, + regexp: this.useRegex, + wholeWord: this.matchWord, + }) + + let query = getSearchQuery(this.view.state) + if (!newQuery.eq(query)) { + this.view.dispatch({ + effects: setSearchQuery.of(newQuery) + }) + } + } + + private svgIcon(name: keyof CustomSearchPanel['codicon']): HTMLDivElement { + let 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() { + let 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 { + + let 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; + + + let caseField = this.svgIcon("matchCase"); + caseField.className = "case-sensitive-toggle"; + caseField.title = "Match Case (Alt+C)"; + caseField.addEventListener("click", () => { + this.toggleCase(); + }); + + let wordField = this.svgIcon("wholeWord"); + wordField.className = "whole-word-toggle"; + wordField.title = "Match Whole Word (Alt+W)"; + wordField.addEventListener("click", () => { + this.toggleWord(); + }); + + + let reField = this.svgIcon("regex"); + reField.className = "regex-toggle"; + reField.title = "Use Regular Expression (Alt+R)"; + reField.addEventListener("click", () => { + this.toggleRegex(); + }); + + let 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") + + let prevMatchButton = this.svgIcon("prevMatch"); + prevMatchButton.className = "prev-match"; + prevMatchButton.title = "Previous Match (Shift+Enter)"; + prevMatchButton.addEventListener("click", () => { + this.matchPrevious(); + }); + + let nextMatchButton = this.svgIcon("nextMatch"); + nextMatchButton.className = "next-match"; + nextMatchButton.title = "Next Match (Enter)"; + nextMatchButton.addEventListener("click", () => { + this.matchNext(); + }); + + let closeButton = this.svgIcon("close"); + closeButton.className = "close"; + closeButton.title = "Close (Escape)" + closeButton.addEventListener("click", () => { + this.close(); + }); + + let replaceButton = this.svgIcon("replace"); + replaceButton.className = "replace-button"; + replaceButton.title = "Replace (Enter)"; + replaceButton.addEventListener("click", () => { + this.replace(); + }); + + let 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 } +} + + diff --git a/frontend/src/views/editor/plugins/vscodeSearch/commands.ts b/frontend/src/views/editor/plugins/vscodeSearch/commands.ts new file mode 100644 index 0000000..ecc5552 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/commands.ts @@ -0,0 +1,172 @@ +import { Command } from "@codemirror/view"; +import { simulateBackspace } from "./utility"; +import { cursorCharLeft, cursorCharRight, deleteCharBackward, deleteCharForward } from "@codemirror/commands"; +import { SearchVisibilityEffect } from "./state"; +import { VSCodeSearch } from "./plugin"; + +const isSearchActive = () : boolean => { + if (document.activeElement){ + return document.activeElement.classList.contains('find-input'); + } + return false; +} + +const isReplaceActive = () : boolean => { + if (document.activeElement){ + return document.activeElement.classList.contains('replace-input'); + } + return false; +} + +export const selectAllCommand: Command = (view) => { + if (isSearchActive() || isReplaceActive()) { + (document.activeElement as HTMLInputElement).select(); + return true; + } + else { + view.dispatch({ + selection: { anchor: 0, head: view.state.doc.length } + }) + return true; + } +}; + +export const deleteCharacterBackwards: Command = (view) => { + if (isSearchActive() || isReplaceActive()) { + simulateBackspace(document.activeElement as HTMLInputElement); + return true; + } + else { + deleteCharBackward(view) + return true; + } +}; + +export const deleteCharacterFowards: Command = (view) => { + if (isSearchActive() || isReplaceActive()) { + simulateBackspace(document.activeElement as HTMLInputElement, "forward"); + return true; + } + else { + deleteCharForward(view) + return true; + } +}; + +export const showSearchVisibilityCommand: Command = (view) => { + console.log("SHOW"); + view.dispatch({ + effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search + }); + + // 延迟聚焦,确保DOM已经更新 + setTimeout(() => { + const searchInput = view.dom.querySelector('.find-input') as HTMLInputElement; + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + }, 10); + + return true; +}; + +export const searchMoveCursorLeft: Command = (view) => { + if (isSearchActive() || isReplaceActive()) { + const input = document.activeElement as HTMLInputElement + const pos = input.selectionStart ?? 0; + if (pos > 0) { + input.selectionStart = input.selectionEnd = pos - 1; + } + return true; + } + else { + cursorCharLeft(view) + return true; + } +} + +export const searchMoveCursorRight: Command = (view) => { + if (isSearchActive() || isReplaceActive()) { + const input = document.activeElement as HTMLInputElement + const pos = input.selectionStart ?? 0; + if (pos < input.value.length) { + input.selectionStart = input.selectionEnd = pos + 1; + } + return true; + } + else { + cursorCharRight(view) + return true; + } +} + +export const hideSearchVisibilityCommand: Command = (view) => { + view.dispatch({ + effects: SearchVisibilityEffect.of(false) // Dispatch the effect to hide the search + }); + return true; +}; + +export const searchToggleCase: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.toggleCaseInsensitive(); + return true; +} + +export const searchToggleWholeWord: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.toggleWholeWord(); + return true; +} + +export const searchToggleRegex: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.toggleRegex(); + return true; +} + +export const searchShowReplace: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.showReplace(); + return true; +} + +export const searchFindReplaceMatch: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.findReplaceMatch(); + return true; +} + +export const searchFindPrevious: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.findPrevious(); + return true; +} + +export const searchReplaceAll: Command = (view) => { + const plugin = view.plugin(VSCodeSearch) + + if (!plugin) return false; + + plugin.replaceAll(); + return true; +} \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/index.ts b/frontend/src/views/editor/plugins/vscodeSearch/index.ts new file mode 100644 index 0000000..6be10b7 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/index.ts @@ -0,0 +1,5 @@ +export { VSCodeSearch, vscodeSearch} from "./plugin"; +export { searchVisibilityField, SearchVisibilityEffect } from "./state"; +export { customSearchKeymap } from "./keymap"; +export { searchBaseTheme } from "./theme" +export * from "./commands" \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/keymap.ts b/frontend/src/views/editor/plugins/vscodeSearch/keymap.ts new file mode 100644 index 0000000..7aa6161 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/keymap.ts @@ -0,0 +1,81 @@ +import { KeyBinding } from "@codemirror/view"; +import { deleteCharacterBackwards, deleteCharacterFowards, hideSearchVisibilityCommand, searchFindPrevious, searchFindReplaceMatch, searchMoveCursorLeft, searchMoveCursorRight, searchReplaceAll, searchShowReplace, searchToggleCase, searchToggleRegex, searchToggleWholeWord, selectAllCommand, showSearchVisibilityCommand } from "./commands"; + +export const customSearchKeymap: KeyBinding[] = [ + // 全局快捷键 - 不需要 scope 限制 + { + key: 'Mod-f', + run: showSearchVisibilityCommand, + }, + // 添加备用快捷键绑定,确保兼容性 + { + key: 'Ctrl-f', + run: showSearchVisibilityCommand, + }, + // 搜索面板内的快捷键 - 需要 scope 限制 + { + key: 'Mod-a', + run: selectAllCommand, + scope: 'search' + }, + { + key: 'Escape', + run: hideSearchVisibilityCommand, + scope: 'search' + }, + { + key: 'Alt-c', + run: searchToggleCase, + scope: 'search' + }, + { + key: 'Alt-w', + run: searchToggleWholeWord, + scope: 'search' + }, + { + key: 'Alt-r', + run: searchToggleRegex, + scope: 'search' + }, + { + key: 'Mod-h', + run: searchShowReplace, + scope: 'search' + }, + { + key: 'Enter', + run: searchFindReplaceMatch, + scope: 'search' + }, + { + key: 'Shift-Enter', + run: searchFindPrevious, + scope: 'search' + }, + { + key: 'Mod-Alt-Enter', + run: searchReplaceAll, + scope: 'search' + }, + { + key: 'Backspace', + run: deleteCharacterBackwards, + scope: 'search' + }, + { + key: 'Delete', + run: deleteCharacterFowards, + scope: 'search' + }, + { + key: "ArrowLeft", + run: searchMoveCursorLeft, + scope: 'search' + }, + { + key: "ArrowRight", + run: searchMoveCursorRight, + scope: 'search' + }, +]; \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/plugin.ts b/frontend/src/views/editor/plugins/vscodeSearch/plugin.ts new file mode 100644 index 0000000..33a2f64 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/plugin.ts @@ -0,0 +1,80 @@ +import { getSearchQuery, search, SearchQuery } from "@codemirror/search"; +import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { CustomSearchPanel } from "./FindReplaceControl"; +import { SearchVisibilityEffect } from "./state"; +import { searchBaseTheme } from "./theme"; + + +export class SearchPlugin { + private searchControl: CustomSearchPanel; + private prevQuery: SearchQuery | null = null; + + constructor(view: EditorView) { + this.searchControl = new CustomSearchPanel(view); + } + + update(update: ViewUpdate) { + const currentQuery = getSearchQuery(update.state); + if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) { + this.searchControl.findMatchesAndSelectClosest(update.state); + } + this.prevQuery = currentQuery; + + for (let tr of update.transactions) { + for (let e of tr.effects) { + if (e.is(SearchVisibilityEffect)) { + this.searchControl.setVisibility(e.value); + } + } + } + } + + destroy() { + this.searchControl.dom.remove(); // Clean up + } + + toggleCaseInsensitive() { + this.searchControl.toggleCase(); + } + + toggleWholeWord() { + this.searchControl.toggleWord(); + } + + toggleRegex() { + this.searchControl.toggleRegex(); + } + + showReplace() { + this.searchControl.setVisibility(true); + this.searchControl.showReplace(); + } + + findReplaceMatch() { + this.searchControl.findReplaceMatch(); + } + + findNext() { + this.searchControl.matchNext() + } + + replace() { + this.searchControl.replace() + } + + replaceAll() { + this.searchControl.replaceAll() + } + + findPrevious() { + this.searchControl.matchPrevious() + } +} + +export const VSCodeSearch = ViewPlugin.fromClass(SearchPlugin); + +export const vscodeSearch = [ + search({}), + VSCodeSearch, + searchBaseTheme +] \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/state.ts b/frontend/src/views/editor/plugins/vscodeSearch/state.ts new file mode 100644 index 0000000..d8f8a2a --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/state.ts @@ -0,0 +1,19 @@ +import { StateEffect, StateField } from "@codemirror/state"; + +// Define an effect to update the visibility state +export const SearchVisibilityEffect = StateEffect.define(); + +// Create a state field to store the visibility state +export const searchVisibilityField = StateField.define({ + create() { + return false; + }, + update(value, tr) { + for (let e of tr.effects) { + if (e.is(SearchVisibilityEffect)) { + return e.value; + } + } + return value; + } +}); \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/theme.ts b/frontend/src/views/editor/plugins/vscodeSearch/theme.ts new file mode 100644 index 0000000..f876f9d --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/theme.ts @@ -0,0 +1,255 @@ +import { EditorView } from "@codemirror/view"; + +type Theme = { + [key: string]: { + [property: string]: string | number; + }; +}; + +const sharedTheme: Theme = { + ".cm-editor": { + position: "relative", + overflow: "visible", + }, + ".find-replace-container": { + borderRadius: "6px", + boxShadow: "0 2px 8px rgba(34, 33, 33, 0.25)", + top: "10px", + right: "20px", + position: "absolute !important", + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + minWidth: "420px", + maxWidth: "calc(100% - 30px)", + display: "flex", + height: "auto", + zIndex: "9999", + pointerEvents: "auto", + }, + ".resize-handle": { + width: "4px", + background: "transparent", + cursor: "col-resize", + position: "absolute", + left: "0", + top: "0", + bottom: "0", + }, + ".resize-handle:hover": { + background: "#007acc", + }, + ".toggle-section": { + display: "flex", + flexDirection: "column", + padding: "8px 4px", + position: "relative", + flex: "0 0 auto" + }, + ".toggle-replace": { + background: "transparent", + border: "none", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "0", + width: "15px", + height: "100%", + }, + ".inputs-section": { + display: "flex", + flexDirection: "column", + gap: "8px", + padding: "8px 0", + minWidth: "0", + }, + ".input-row": { + display: "flex", + alignItems: "center", + height: "24px", + }, + ".input-section": { + alignContent: "center", + flex: "1 1 auto" + }, + ".input-container": { + position: "relative", + flex: "1", + minWidth: "0", + }, + ".search-bar": { + display: "flex", + position: "relative", + margin: "2px", + }, + ".find-input, .replace-input": { + width: "100%", + borderRadius: "4px", + padding: "4px 80px 4px 8px", + outline: "none", + fontSize: "13px", + height: "24px", + }, + ".replace-input": { + padding: "4px 8px 4px 8px", + }, + ".find-input:focus, .replace-input:focus": { + boxShadow: "none" + }, + ".search-controls": { + display: "flex", + position: "absolute", + right: "10px", + top: "10%" + }, + ".search-controls div": { + borderRadius: "5px", + alignContent: "center", + margin: "2px 3px", + cursor: "pointer", + padding: "2px 4px", + border: "1px solid transparent", + transition: "all 0.2s ease", + }, + ".search-controls svg": { + margin: "0px 2px" + }, + ".actions-section": { + alignContent: "center", + marginRight: "10px", + flex: "0 0 auto" + }, + ".button-group": { + display: "grid", + gridTemplateColumns: "1fr 1fr", + height: "24px", + alignContent: "center", + }, + ".search-icons": { + display: "flex", + }, + ".search-icons div": { + cursor: "pointer", + borderRadius: "4px", + }, + ".replace-bar": { + margin: "2px", + }, + ".replace-buttons": { + display: "flex", + height: "24px", + }, + ".replace-button": { + border: "none", + padding: "4px 4px", + borderRadius: "4px", + fontSize: "12px", + cursor: "pointer", + height: "24px", + }, + ".match-count": { + fontSize: "12px", + marginLeft: "8px", + whiteSpace: "nowrap", + }, + ".search-options": { + position: "absolute", + right: "4px", + top: "50%", + transform: "translateY(-50%)", + display: "flex", + alignItems: "center", + gap: "2px", + }, +} + +const lightTheme: Theme = { + ".find-replace-container": { + backgroundColor: "var(--cm-background, #f3f3f3)", + color: "var(--cm-foreground, #454545)", + border: "1px solid var(--cm-caret, #d4d4d4)", + }, + ".toggle-replace:hover": { + backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)", + }, + ".find-input, .replace-input": { + background: "var(--cm-gutter-background, #ffffff)", + color: "var(--cm-foreground, #454545)", + border: "1px solid var(--cm-gutter-foreground, #e1e1e1)", + }, + ".find-input:focus, .replace-input:focus": { + borderColor: "var(--cm-caret, #1e51db)", + }, + ".search-controls div:hover": { + backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)" + }, + ".search-controls div.active": { + backgroundColor: "#007acc !important", + color: "#ffffff !important", + border: "1px solid #007acc !important" + }, + ".search-controls div.active svg": { + fill: "#ffffff !important" + }, + ".search-icons div:hover": { + backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)" + }, + ".replace-button:hover": { + backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)" + }, +}; + +const darkTheme = { + ".find-replace-container": { + backgroundColor: "var(--cm-background, #252526)", + color: "var(--cm-foreground, #c4c4c4)", + border: "1px solid var(--cm-caret, #454545)", + }, + ".toggle-replace:hover": { + backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)", + }, + ".find-input, .replace-input": { + background: "var(--cm-gutter-background, #3c3c3c)", + color: "var(--cm-foreground, #b4b4b4)", + border: "1px solid var(--cm-gutter-foreground, #3c3c3c)", + }, + ".find-input:focus, .replace-input:focus": { + borderColor: "var(--cm-caret, #1e51db)", + }, + ".search-controls div:hover": { + backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)" + }, + ".search-controls div.active": { + backgroundColor: "#007acc !important", + color: "#ffffff !important", + border: "1px solid #007acc !important" + }, + ".search-controls div.active svg": { + fill: "#ffffff !important" + }, + ".search-icons div:hover": { + backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)" + }, + ".replace-button:hover": { + backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)" + }, +}; + + +const prependThemeSelector = (theme: Theme, selector: string): Theme => { + const updatedTheme : Theme= {}; + + Object.keys(theme).forEach( (key) => { + + const updatedKey = key.split(',').map(part => `${selector} ${part.trim()}`).join(', '); + // Prepend the selector to each key and assign the original style + updatedTheme[updatedKey] = theme[key]; + }); + + return updatedTheme; +} + +export const searchBaseTheme = EditorView.baseTheme({ + ...sharedTheme, + ...prependThemeSelector(lightTheme, "&light"), + ...prependThemeSelector(darkTheme, "&dark"), +}); \ No newline at end of file diff --git a/frontend/src/views/editor/plugins/vscodeSearch/utility.ts b/frontend/src/views/editor/plugins/vscodeSearch/utility.ts new file mode 100644 index 0000000..311af37 --- /dev/null +++ b/frontend/src/views/editor/plugins/vscodeSearch/utility.ts @@ -0,0 +1,26 @@ +export function simulateBackspace(input: HTMLInputElement, direction: "backward" | "forward" = "backward") { + let start = input.selectionStart ?? 0; + let end = input.selectionEnd ?? 0; + + // Do nothing if at boundaries + if (direction === "backward" && start === 0 && end === 0) return; + if (direction === "forward" && start === input.value.length && end === input.value.length) return; + + if (start === end) { + // No selection - simulate Backspace or Delete + if (direction === "backward") { + input.value = input.value.slice(0, start - 1) + input.value.slice(end); + start -= 1; + } else { + input.value = input.value.slice(0, start) + input.value.slice(end + 1); + } + input.selectionStart = input.selectionEnd = start; + } else { + // Text is selected, remove selection regardless of direction + input.value = input.value.slice(0, start) + input.value.slice(end); + input.selectionStart = input.selectionEnd = start; + } + + // Dispatch input event to notify listeners + input.dispatchEvent(new Event("input", { bubbles: true })); +} \ No newline at end of file