diff --git a/frontend/bindings/voidraft/internal/models/models.ts b/frontend/bindings/voidraft/internal/models/models.ts index 1ceb050..7714cae 100644 --- a/frontend/bindings/voidraft/internal/models/models.ts +++ b/frontend/bindings/voidraft/internal/models/models.ts @@ -415,25 +415,48 @@ export enum ExtensionID { * 颜色选择器 */ ExtensionColorSelector = "colorSelector", - ExtensionFold = "fold", - ExtensionTextHighlight = "textHighlight", /** - * 选择框 + * 代码折叠 */ - ExtensionCheckbox = "checkbox", + ExtensionFold = "fold", /** * 划词翻译 */ ExtensionTranslator = "translator", + /** + * Markdown渲染 + */ + ExtensionMarkdown = "markdown", + + /** + * 显示空白字符 + */ + ExtensionHighlightWhitespace = "highlightWhitespace", + + /** + * 高亮行尾空白 + */ + ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace", + /** * UI增强扩展 * 小地图 */ ExtensionMinimap = "minimap", + /** + * 行号显示 + */ + ExtensionLineNumbers = "lineNumbers", + + /** + * 上下文菜单 + */ + ExtensionContextMenu = "contextMenu", + /** * 工具扩展 * 搜索功能 @@ -810,31 +833,6 @@ export enum KeyBindingCommand { */ HideSearchCommand = "hideSearch", - /** - * 搜索切换大小写 - */ - SearchToggleCaseCommand = "searchToggleCase", - - /** - * 搜索切换整词 - */ - SearchToggleWordCommand = "searchToggleWord", - - /** - * 搜索切换正则 - */ - SearchToggleRegexCommand = "searchToggleRegex", - - /** - * 显示替换 - */ - SearchShowReplaceCommand = "searchShowReplace", - - /** - * 替换全部 - */ - SearchReplaceAllCommand = "searchReplaceAll", - /** * 代码块扩展相关 * 块内选择全部 @@ -1073,12 +1071,6 @@ export enum KeyBindingCommand { * 重做选择 */ HistoryRedoSelectionCommand = "historyRedoSelection", - - /** - * 文本高亮扩展相关 - * 切换文本高亮 - */ - TextHighlightToggleCommand = "textHighlightToggle", }; /** diff --git a/frontend/src/assets/styles/variables.css b/frontend/src/assets/styles/variables.css index a413247..2c90903 100644 --- a/frontend/src/assets/styles/variables.css +++ b/frontend/src/assets/styles/variables.css @@ -70,6 +70,25 @@ --cm-table-header-bg: rgba(46, 51, 69, 0.7); --cm-table-border: rgba(75, 85, 99, 0.35); --cm-table-row-hover: rgba(55, 62, 78, 0.5); + + /* Search Panel - Dark Theme */ + --search-panel-bg: #252526; + --search-panel-text: #cccccc; + --search-panel-border: #454545; + --search-input-bg: #3c3c3c; + --search-input-text: #cccccc; + --search-input-border: #3c3c3c; + --search-focus-border: #0078d4; + --search-btn-hover: rgba(255, 255, 255, 0.1); + --search-btn-active-bg: rgba(0, 120, 212, 0.4); + --search-btn-active-text: #ffffff; + --search-error-border: #f14c4c; + --search-error-bg: #5a1d1d; + + /* Search Match Highlight - Dark Theme (VSCode style) */ + --search-match-bg: rgba(250, 220, 81, 0.85); + --search-match-selected-bg: rgba(81, 175, 255, 0.5); + --search-match-selected-border: #74b0f4; } /* 亮色主题 */ @@ -137,6 +156,25 @@ --cm-table-header-bg: oklch(94% 0.01 255); --cm-table-border: oklch(88% 0.008 255); --cm-table-row-hover: oklch(95% 0.008 255); + + /* Search Panel - Light Theme */ + --search-panel-bg: #f3f3f3; + --search-panel-text: #616161; + --search-panel-border: #c8c8c8; + --search-input-bg: #ffffff; + --search-input-text: #616161; + --search-input-border: #cecece; + --search-focus-border: #0078d4; + --search-btn-hover: rgba(0, 0, 0, 0.1); + --search-btn-active-bg: rgba(0, 120, 212, 0.2); + --search-btn-active-text: #0078d4; + --search-error-border: #e51400; + --search-error-bg: #fdeceb; + + /* Search Match Highlight - Light Theme (VSCode style) */ + --search-match-bg: rgba(250, 220, 81, 0.85); + --search-match-selected-bg: rgba(38, 143, 255, 0.3); + --search-match-selected-border: #268fff; } /* 跟随系统的浅色偏好 */ @@ -205,5 +243,24 @@ --cm-table-header-bg: oklch(94% 0.01 255); --cm-table-border: oklch(88% 0.008 255); --cm-table-row-hover: oklch(95% 0.008 255); + + /* Search Panel - Light Theme (auto) */ + --search-panel-bg: #f3f3f3; + --search-panel-text: #616161; + --search-panel-border: #c8c8c8; + --search-input-bg: #ffffff; + --search-input-text: #616161; + --search-input-border: #cecece; + --search-focus-border: #0078d4; + --search-btn-hover: rgba(0, 0, 0, 0.1); + --search-btn-active-bg: rgba(0, 120, 212, 0.2); + --search-btn-active-text: #0078d4; + --search-error-border: #e51400; + --search-error-bg: #fdeceb; + + /* Search Match Highlight - Light Theme auto (VSCode style) */ + --search-match-bg: rgba(250, 220, 81, 0.85); + --search-match-selected-bg: rgba(38, 143, 255, 0.3); + --search-match-selected-border: #268fff; } } diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 041256d..37f4de4 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -111,7 +111,6 @@ export default { deleteCharForward: 'Delete character forward', deleteGroupBackward: 'Delete group backward', deleteGroupForward: 'Delete group forward', - textHighlightToggle: 'Toggle text highlight', } }, tabs: { @@ -257,7 +256,7 @@ export default { }, colorSelector: { name: 'Color Selector', - description: 'Visual color picker and color value display' + description: 'CSS code block visual color picker and color value display' }, translator: { name: 'Text Translator', @@ -275,19 +274,29 @@ export default { name: 'Code Folding', description: 'Collapse and expand code sections for better readability' }, - textHighlight: { - name: 'Text Highlight', - description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)', - backgroundColor: 'Background Color', - opacity: 'Opacity' - }, - checkbox: { - name: 'Checkbox', - description: 'Render [x] and [ ] as interactive checkboxes' + markdown: { + name: 'Markdown Renderer', + description: 'Render Markdown elements, "what you see is what you get"' }, codeblock: { name: 'Code Block', description: 'Code block related functionality' + }, + lineNumbers: { + name: 'Line Numbers', + description: 'Display line numbers on the left side of the editor and highlight the current line' + }, + contextMenu: { + name: 'Context Menu', + description: 'Show context menu when right-clicking in the editor' + }, + highlightWhitespace: { + name: 'Highlight Whitespace', + description: 'Display whitespace characters such as spaces and tabs in the editor' + }, + highlightTrailingWhitespace: { + name: 'Highlight Trailing Whitespace', + description: 'Highlight trailing whitespace at the end of lines' } }, monitor: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index fb73e94..ab7861d 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -111,7 +111,6 @@ export default { deleteCharForward: '向前删除字符', deleteGroupBackward: '向后删除组', deleteGroupForward: '向前删除组', - textHighlightToggle: '切换文本高亮', } }, tabs: { @@ -259,7 +258,7 @@ export default { }, colorSelector: { name: '颜色选择器', - description: '颜色值的可视化和选择' + description: 'CSS代码块颜色值的可视化和选择' }, translator: { name: '划词翻译', @@ -277,19 +276,29 @@ export default { name: '代码折叠', description: '折叠和展开代码段以提高代码可读性' }, - textHighlight: { - name: '文本高亮', - description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)', - backgroundColor: '背景颜色', - opacity: '透明度' - }, - checkbox: { - name: '选择框', - description: '将 [x] 和 [ ] 渲染为可交互的选择框' + markdown: { + name: 'Markdown 渲染', + description: '渲染 Markdown 元素,“所见即所得”' }, codeblock: { name: '代码块', description: '代码块相关功能' + }, + lineNumbers: { + name: '行号显示', + description: '在编辑器左侧显示行号,并高亮当前行' + }, + contextMenu: { + name: '上下文菜单', + description: '在编辑器中右键点击时显示上下文菜单' + }, + highlightWhitespace: { + name: '显示空白字符', + description: '在编辑器中显示空格和制表符等空白字符' + }, + highlightTrailingWhitespace: { + name: '高亮行尾空白', + description: '高亮显示行尾的多余空白字符' } }, monitor: { diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index afb1791..cf05188 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -30,7 +30,6 @@ import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils'; import {EDITOR_CONFIG} from '@/common/constant/editor'; import {createHttpClientExtension} from "@/views/editor/extensions/httpclient"; import {createDebounce} from '@/common/utils/debounce'; -import markdownExtensions from "@/views/editor/extensions/markdown"; export interface DocumentStats { lines: number; @@ -298,7 +297,6 @@ export const useEditorStore = defineStore('editor', () => { codeBlockExtension, ...dynamicExtensions, ...httpExtension, - markdownExtensions ]; // 创建编辑器状态 diff --git a/frontend/src/views/editor/basic/basicSetup.ts b/frontend/src/views/editor/basic/basicSetup.ts index 8bb1a69..8cda900 100644 --- a/frontend/src/views/editor/basic/basicSetup.ts +++ b/frontend/src/views/editor/basic/basicSetup.ts @@ -5,30 +5,20 @@ import { dropCursor, EditorView, highlightActiveLine, - highlightActiveLineGutter, highlightSpecialChars, keymap, - lineNumbers, rectangularSelection, + scrollPastEnd } from '@codemirror/view'; -import { - bracketMatching, - defaultHighlightStyle, - foldGutter, - indentOnInput, - syntaxHighlighting, -} from '@codemirror/language'; +import {bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting,} from '@codemirror/language'; import {history} from '@codemirror/commands'; import {highlightSelectionMatches} from '@codemirror/search'; -import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete'; -import createEditorContextMenu from '../contextMenu'; +import {closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete'; // 基本编辑器设置 export const createBasicSetup = (): Extension[] => { return [ // 基础UI - lineNumbers(), - highlightActiveLineGutter(), highlightSpecialChars(), dropCursor(), EditorView.lineWrapping, @@ -36,9 +26,6 @@ export const createBasicSetup = (): Extension[] => { // 历史记录 history(), - // 代码折叠 - foldGutter(), - // 选择与高亮 drawSelection(), highlightActiveLine(), @@ -52,11 +39,7 @@ export const createBasicSetup = (): Extension[] => { bracketMatching(), closeBrackets(), - // 自动完成 - autocompletion(), - - // 上下文菜单 - createEditorContextMenu(), + scrollPastEnd(), // 键盘映射 keymap.of([ diff --git a/frontend/src/views/editor/extensions/checkbox/index.ts b/frontend/src/views/editor/extensions/checkbox/index.ts deleted file mode 100644 index 811f41b..0000000 --- a/frontend/src/views/editor/extensions/checkbox/index.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { EditorView, Decoration } from "@codemirror/view"; -import { WidgetType } from "@codemirror/view"; -import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view"; -import { Extension, StateEffect } from "@codemirror/state"; - -// 创建字体变化效果 -const fontChangeEffect = StateEffect.define(); - -/** - * 复选框小部件类 - */ -class CheckboxWidget extends WidgetType { - constructor(readonly checked: boolean) { - super(); - } - - eq(other: CheckboxWidget) { - return other.checked == this.checked; - } - - toDOM() { - const wrap = document.createElement("span"); - wrap.setAttribute("aria-hidden", "true"); - wrap.className = "cm-checkbox-toggle"; - - const box = document.createElement("input"); - box.type = "checkbox"; - box.checked = this.checked; - box.tabIndex = -1; - box.style.margin = "0"; - box.style.padding = "0"; - box.style.cursor = "pointer"; - box.style.position = "relative"; - box.style.top = "0.1em"; - box.style.marginRight = "0.5em"; - // 设置相对单位,让复选框跟随字体大小变化 - box.style.width = "1em"; - box.style.height = "1em"; - - wrap.appendChild(box); - return wrap; - } - - ignoreEvent() { - return false; - } -} - -/** - * 查找并创建复选框装饰 - */ -function findCheckboxes(view: EditorView) { - const widgets: any = []; - const doc = view.state.doc; - - for (const { from, to } of view.visibleRanges) { - // 使用正则表达式查找 [x] 或 [ ] 模式 - const text = doc.sliceString(from, to); - const checkboxRegex = /\[([ x])\]/gi; - let match; - - while ((match = checkboxRegex.exec(text)) !== null) { - const matchPos = from + match.index; - const matchEnd = matchPos + match[0].length; - - // 检查前面是否有 "- " 模式 - const beforeTwoChars = matchPos >= 2 ? doc.sliceString(matchPos - 2, matchPos) : ""; - const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : ""; - - // 只有当前面是 "- " 且后面跟空格或行尾时才渲染 - if (beforeTwoChars === "- " && - (afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) { - - const isChecked = match[1].toLowerCase() === "x"; - const deco = Decoration.replace({ - widget: new CheckboxWidget(isChecked), - inclusive: false, - }); - // 替换整个 "- [ ]" 或 "- [x]" 模式,包括前面的 "- " - widgets.push(deco.range(matchPos - 2, matchEnd)); - } - } - } - - return Decoration.set(widgets); -} - -/** - * 切换复选框状态 - */ -function toggleCheckbox(view: EditorView, pos: number) { - const doc = view.state.doc; - - // 查找当前位置附近的复选框模式(需要前面有 "- ") - for (let offset = -5; offset <= 0; offset++) { - const checkPos = pos + offset; - if (checkPos >= 2 && checkPos + 3 <= doc.length) { - // 检查是否有 "- " 前缀 - const prefix = doc.sliceString(checkPos - 2, checkPos); - const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase(); - - if (prefix === "- ") { - let change; - - if (text === "[x]") { - // 替换整个 "- [x]" 为 "- [ ]" - change = { from: checkPos - 2, to: checkPos + 3, insert: "- [ ]" }; - } else if (text === "[ ]") { - // 替换整个 "- [ ]" 为 "- [x]" - change = { from: checkPos - 2, to: checkPos + 3, insert: "- [x]" }; - } - - if (change) { - view.dispatch({ changes: change }); - return true; - } - } - } - } - return false; -} - -// 创建字体变化效果的便捷函数 -export const triggerFontChange = (view: EditorView) => { - view.dispatch({ - effects: fontChangeEffect.of(undefined) - }); -}; - -/** - * 创建复选框扩展 - */ -export function createCheckboxExtension(): Extension { - return [ - // 主要的复选框插件 - ViewPlugin.fromClass(class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = findCheckboxes(view); - } - - update(update: ViewUpdate) { - // 检查是否需要重新渲染复选框 - const shouldUpdate = update.docChanged || - update.viewportChanged || - update.geometryChanged || - update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect))); - - if (shouldUpdate) { - this.decorations = findCheckboxes(update.view); - } - } - }, { - decorations: v => v.decorations, - - eventHandlers: { - mousedown: (e, view) => { - const target = e.target as HTMLElement; - if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) { - const pos = view.posAtDOM(target); - return toggleCheckbox(view, pos); - } - } - } - }), - - // 复选框样式 - EditorView.theme({ - ".cm-checkbox-toggle": { - display: "inline-block", - verticalAlign: "baseline", - }, - ".cm-checkbox-toggle input[type=checkbox]": { - margin: "0", - padding: "0", - verticalAlign: "baseline", - cursor: "pointer", - // 确保复选框大小跟随字体 - fontSize: "inherit", - } - }) - ]; -} - -// 默认导出 -export const checkboxExtension = createCheckboxExtension(); - -// 导出类型和工具函数 -export { - CheckboxWidget, - toggleCheckbox, - findCheckboxes -}; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/codeblock/index.ts b/frontend/src/views/editor/extensions/codeblock/index.ts index 0dae098..97a8b4c 100644 --- a/frontend/src/views/editor/extensions/codeblock/index.ts +++ b/frontend/src/views/editor/extensions/codeblock/index.ts @@ -79,6 +79,7 @@ const blockLineNumbers = lineNumbers({ /** * 创建代码块扩展 + * 注意:blockLineNumbers 已移至动态扩展管理,通过 ExtensionLineNumbers 控制 */ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension { const { @@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens // 核心状态管理 blockState, - // 块内行号 - blockLineNumbers, - // 语言解析支持 ...getCodeBlockLanguageExtension(), diff --git a/frontend/src/views/editor/extensions/fold/foldExtension.ts b/frontend/src/views/editor/extensions/fold/foldExtension.ts deleted file mode 100644 index e4c70cb..0000000 --- a/frontend/src/views/editor/extensions/fold/foldExtension.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {foldService} from '@codemirror/language'; - -export const foldingOnIndent = foldService.of((state, from, to) => { - const line = state.doc.lineAt(from); // First line - const lines = state.doc.lines; // Number of lines in the document - const indent = line.text.search(/\S|$/); // Indent level of the first line - let foldStart = from; // Start of the fold - let foldEnd = to; // End of the fold - - // Check the next line if it is on a deeper indent level - // If it is, check the next line and so on - // If it is not, go on with the foldEnd - let nextLine = line; - while (nextLine.number < lines) { - nextLine = state.doc.line(nextLine.number + 1); // Next line - const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line - - // If the next line is on a deeper indent level, add it to the fold - if (nextIndent > indent) { - foldEnd = nextLine.to; // Set the fold end to the end of the next line - } else { - break; // If the next line is not on a deeper indent level, stop - } - } - - // If the fold is only one line, don't fold it - if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) { - return null; - } - - // Set the fold start to the end of the first line - // With this, the fold will not include the first line - foldStart = line.to; - - // Return a fold that covers the entire indent level - return {from: foldStart, to: foldEnd}; -}); diff --git a/frontend/src/views/editor/extensions/hyperlink/index.ts b/frontend/src/views/editor/extensions/hyperlink/index.ts index 4c707ab..2506868 100644 --- a/frontend/src/views/editor/extensions/hyperlink/index.ts +++ b/frontend/src/views/editor/extensions/hyperlink/index.ts @@ -3,15 +3,42 @@ import { EditorView, Decoration, DecorationSet, - MatchDecorator, WidgetType, ViewUpdate, } from '@codemirror/view'; -import { Extension, Range } from '@codemirror/state'; +import { Extension, ChangeSet } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import * as runtime from "@wailsio/runtime"; + const pathStr = ``; -const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi; +const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/g; + +/** Stored hyperlink info for incremental updates */ +interface HyperLinkInfo { + url: string; + from: number; + to: number; +} + +/** + * Check if document changes affect any of the given link regions. + */ +function changesAffectLinks(changes: ChangeSet, links: HyperLinkInfo[]): boolean { + if (links.length === 0) return true; + + let affected = false; + changes.iterChanges((fromA, toA) => { + if (affected) return; + for (const link of links) { + // Check if change overlaps with link region (with some buffer for insertions) + if (fromA <= link.to && toA >= link.from) { + affected = true; + return; + } + } + }); + return affected; +} // Markdown link parent nodes that should be excluded from hyperlink decoration const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']); @@ -38,6 +65,45 @@ function isInMarkdownLink(view: EditorView, from: number, to: number): boolean { return inLink; } +/** + * Extract hyperlinks from visible ranges only. + * This is the key optimization - we only scan what's visible. + */ +function extractVisibleLinks(view: EditorView): HyperLinkInfo[] { + const result: HyperLinkInfo[] = []; + const seen = new Set(); // Dedupe by position key + + for (const { from, to } of view.visibleRanges) { + // Get the text for this visible range + const rangeText = view.state.sliceDoc(from, to); + + // Reset regex lastIndex for each range + const regex = new RegExp(defaultRegexp.source, 'gi'); + let match; + + while ((match = regex.exec(rangeText)) !== null) { + const linkFrom = from + match.index; + const linkTo = linkFrom + match[0].length; + const key = `${linkFrom}:${linkTo}`; + + // Skip duplicates + if (seen.has(key)) continue; + seen.add(key); + + // Skip URLs inside markdown link syntax + if (isInMarkdownLink(view, linkFrom, linkTo)) continue; + + result.push({ + url: match[0], + from: linkFrom, + to: linkTo + }); + } + } + + return result; +} + export interface HyperLinkState { at: number; url: string; @@ -70,96 +136,80 @@ class HyperLinkIcon extends WidgetType { } } -function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) { - const widgets: Array> = []; - const doc = view.state.doc.toString(); - let match; - - while ((match = defaultRegexp.exec(doc)) !== null) { - const from = match.index; - const to = from + match[0].length; - - // Skip URLs that are inside markdown link syntax - if (isInMarkdownLink(view, from, to)) { - continue; - } - - const linkMark = Decoration.mark({ +/** + * Build decorations from extracted link info. + */ +function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet { + const decorations: ReturnType[] = []; + + for (const link of links) { + // Add text decoration + decorations.push(Decoration.mark({ class: 'cm-hyper-link-text' - }); - widgets.push(linkMark.range(from, to)); + }).range(link.from, link.to)); - const widget = Decoration.widget({ + // Add icon widget + decorations.push(Decoration.widget({ widget: new HyperLinkIcon({ - at: to, - url: match[0], + at: link.to, + url: link.url, anchor, }), side: 1, - }); - widgets.push(widget.range(to)); + }).range(link.to)); } - - return Decoration.set(widgets); + + return Decoration.set(decorations, true); } -const linkDecorator = ( - regexp?: RegExp, - matchData?: Record, - matchFn?: (str: string, input: string, from: number, to: number) => string, - anchor?: HyperLinkExtensionOptions['anchor'], -) => - new MatchDecorator({ - regexp: regexp || defaultRegexp, - decorate: (add, from, to, match, view) => { - // Skip URLs that are inside markdown link syntax - if (isInMarkdownLink(view, from, to)) { - return; - } - - const url = match[0]; - let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url; - if (matchData && matchData[url]) { - urlStr = matchData[url]; - } - const start = to, - end = to; - const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor }); - - add(from, to, Decoration.mark({ - class: 'cm-hyper-link-text cm-hyper-link-underline' - })); - add(start, end, Decoration.widget({ widget: linkIcon, side: 1 })); - }, - }); - export type HyperLinkExtensionOptions = { - regexp?: RegExp; - match?: Record; - handle?: (value: string, input: string, from: number, to: number) => string; + /** Custom anchor element transformer */ anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement; - showIcon?: boolean; }; -export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) { +/** + * Optimized hyperlink extension with visible-range-only scanning. + * + * Performance optimizations: + * 1. Only scans visible ranges (not the entire document) + * 2. Incremental updates: maps positions when changes don't affect links + * 3. Caches link info to avoid redundant re-extraction + */ +export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) { return ViewPlugin.fromClass( class HyperLinkView { - decorator?: MatchDecorator; decorations: DecorationSet; + links: HyperLinkInfo[] = []; + constructor(view: EditorView) { - if (regexp) { - this.decorator = linkDecorator(regexp, match, handle, anchor); - this.decorations = this.decorator.createDeco(view); - } else { - this.decorations = hyperLinkDecorations(view, anchor); - } + this.links = extractVisibleLinks(view); + this.decorations = buildDecorations(this.links, anchor); } + update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { - if (regexp && this.decorator) { - this.decorations = this.decorator.updateDeco(update, this.decorations); + // Always rebuild on viewport change (new content visible) + if (update.viewportChanged) { + this.links = extractVisibleLinks(update.view); + this.decorations = buildDecorations(this.links, anchor); + return; + } + + // For document changes, check if they affect link regions + if (update.docChanged) { + const needsRebuild = changesAffectLinks(update.changes, this.links); + + if (needsRebuild) { + // Changes affect links, full rebuild + this.links = extractVisibleLinks(update.view); + this.decorations = buildDecorations(this.links, anchor); } else { - this.decorations = hyperLinkDecorations(update.view, anchor); + // Just update positions of existing decorations + this.decorations = this.decorations.map(update.changes); + this.links = this.links.map(link => ({ + ...link, + from: update.changes.mapPos(link.from), + to: update.changes.mapPos(link.to) + })); } } } @@ -207,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({ '.cm-hyper-link-icon svg': { display: 'block', - width: '14px', - height: '14px', + width: 'inherit', + height: 'inherit', }, '.cm-editor.cm-focused .cm-hyper-link-text': { diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts index 8a804c7..626fbbb 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-styles.ts @@ -171,11 +171,11 @@ export const inlineStylesTheme = EditorView.baseTheme({ '.cm-superscript': { verticalAlign: 'super', fontSize: '0.75em', - color: 'var(--cm-superscript-color, inherit)' + color: 'inherit' }, '.cm-subscript': { verticalAlign: 'sub', fontSize: '0.75em', - color: 'var(--cm-subscript-color, inherit)' + color: 'inherit' } }); diff --git a/frontend/src/views/editor/extensions/rainbowBracket/index.ts b/frontend/src/views/editor/extensions/rainbowBracket/index.ts index 9926140..4639abb 100644 --- a/frontend/src/views/editor/extensions/rainbowBracket/index.ts +++ b/frontend/src/views/editor/extensions/rainbowBracket/index.ts @@ -1,67 +1,88 @@ import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view'; import { Range } from '@codemirror/state'; -// 生成彩虹颜色数组 -function generateColors(): string[] { - return ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet' - ]; -} +// 彩虹颜色数组 +const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']; +const OPEN_BRACKETS = new Set(['(', '[', '{']); +const CLOSE_BRACKETS = new Set([')', ']', '}']); +const BRACKET_PAIRS: Record = { ')': '(', ']': '[', '}': '{' }; + +/** + * 彩虹括号插件 + */ class RainbowBracketsView { decorations: DecorationSet; constructor(view: EditorView) { - this.decorations = this.getBracketDecorations(view); + this.decorations = this.buildDecorations(view); } update(update: ViewUpdate): void { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = this.getBracketDecorations(update.view); + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); } } - private getBracketDecorations(view: EditorView): DecorationSet { - const { doc } = view.state; + private buildDecorations(view: EditorView): DecorationSet { const decorations: Range[] = []; - const stack: { type: string; from: number }[] = []; - const colors = generateColors(); - - // 遍历文档内容 - for (let pos = 0; pos < doc.length; pos++) { + const doc = view.state.doc; + + const visibleRanges = view.visibleRanges; + if (visibleRanges.length === 0) { + return Decoration.set([]); + } + + const visibleFrom = visibleRanges[0].from; + const visibleTo = visibleRanges[visibleRanges.length - 1].to; + + // 阶段1: 预计算到可视范围开始位置的栈状态(只维护栈,不创建装饰) + const stack: { char: string; from: number }[] = []; + + for (let pos = 0; pos < visibleFrom && pos < doc.length; pos++) { const char = doc.sliceString(pos, pos + 1); - - // 遇到开括号 - if (char === '(' || char === '[' || char === '{') { - stack.push({ type: char, from: pos }); - } - // 遇到闭括号 - else if (char === ')' || char === ']' || char === '}') { + + if (OPEN_BRACKETS.has(char)) { + stack.push({ char, from: pos }); + } else if (CLOSE_BRACKETS.has(char)) { const open = stack.pop(); - const matchingBracket = this.getMatchingBracket(char); - - if (open && open.type === matchingBracket) { - const color = colors[stack.length % colors.length]; - const className = `cm-rainbow-bracket-${color}`; - - // 为开括号和闭括号添加装饰 - decorations.push( - Decoration.mark({ class: className }).range(open.from, open.from + 1), - Decoration.mark({ class: className }).range(pos, pos + 1) - ); + if (open && open.char !== BRACKET_PAIRS[char]) { + stack.push(open); // 不匹配,放回 } } } - - return Decoration.set(decorations.sort((a, b) => a.from - b.from)); - } - - private getMatchingBracket(closingBracket: string): string | null { - switch (closingBracket) { - case ')': return '('; - case ']': return '['; - case '}': return '{'; - default: return null; + + // 阶段2: 处理可视范围内的括号(创建装饰) + for (let pos = visibleFrom; pos < visibleTo && pos < doc.length; pos++) { + const char = doc.sliceString(pos, pos + 1); + + if (OPEN_BRACKETS.has(char)) { + const depth = stack.length; + stack.push({ char, from: pos }); + + // 添加开括号装饰 + const color = COLORS[depth % COLORS.length]; + decorations.push( + Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1) + ); + } else if (CLOSE_BRACKETS.has(char)) { + const open = stack.pop(); + + if (open && open.char === BRACKET_PAIRS[char]) { + const depth = stack.length; + const color = COLORS[depth % COLORS.length]; + + // 添加闭括号装饰 + decorations.push( + Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1) + ); + } else if (open) { + stack.push(open); // 不匹配,放回 + } + } } + + return Decoration.set(decorations.sort((a, b) => a.from - b.from)); } } @@ -69,7 +90,7 @@ const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, { decorations: (v) => v.decorations, }); -export default function index() { +export default function rainbowBrackets() { return [ rainbowBracketsPlugin, EditorView.baseTheme({ @@ -83,4 +104,4 @@ export default function index() { '.cm-rainbow-bracket-violet': { color: '#9B5DE5' }, }), ]; -} \ No newline at end of file +} diff --git a/frontend/src/views/editor/extensions/textHighlight/index.ts b/frontend/src/views/editor/extensions/textHighlight/index.ts deleted file mode 100644 index c8b2829..0000000 --- a/frontend/src/views/editor/extensions/textHighlight/index.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { EditorState, StateEffect, StateField, Facet } from "@codemirror/state"; -import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; - -// 高亮配置接口 -export interface TextHighlightConfig { - backgroundColor?: string; - opacity?: number; -} - -// 默认配置 -const DEFAULT_CONFIG: Required = { - backgroundColor: '#FFD700', // 金黄色 - opacity: 0.3 -}; - -// 定义添加和移除高亮的状态效果 -const addHighlight = StateEffect.define<{from: number, to: number}>({ - map: ({from, to}, change) => ({ - from: change.mapPos(from), - to: change.mapPos(to) - }) -}); - -const removeHighlight = StateEffect.define<{from: number, to: number}>({ - map: ({from, to}, change) => ({ - from: change.mapPos(from), - to: change.mapPos(to) - }) -}); - - - -// 配置facet -const highlightConfigFacet = Facet.define>({ - combine: (configs) => { - const result = { ...DEFAULT_CONFIG }; - for (const config of configs) { - if (config.backgroundColor !== undefined) { - result.backgroundColor = config.backgroundColor; - } - if (config.opacity !== undefined) { - result.opacity = config.opacity; - } - } - return result; - } -}); - -// 创建高亮装饰 -function createHighlightMark(config: Required): Decoration { - const { backgroundColor, opacity } = config; - const rgbaColor = hexToRgba(backgroundColor, opacity); - - return Decoration.mark({ - attributes: { - style: `background-color: ${rgbaColor}; border-radius: 2px;` - } - }); -} - -// 将十六进制颜色转换为RGBA -function hexToRgba(hex: string, opacity: number): string { - // 移除 # 符号 - hex = hex.replace('#', ''); - - // 处理短格式 (如 #FFF -> #FFFFFF) - if (hex.length === 3) { - hex = hex.split('').map(char => char + char).join(''); - } - - const r = parseInt(hex.substr(0, 2), 16); - const g = parseInt(hex.substr(2, 2), 16); - const b = parseInt(hex.substr(4, 2), 16); - - return `rgba(${r}, ${g}, ${b}, ${opacity})`; -} - -// 存储高亮范围的状态字段 - 支持撤销 -const highlightState = StateField.define({ - create() { - return Decoration.none; - }, - update(decorations, tr) { - // 映射现有装饰以适应文档变化 - decorations = decorations.map(tr.changes); - - // 处理效果 - for (const effect of tr.effects) { - if (effect.is(addHighlight)) { - const { from, to } = effect.value; - const config = tr.state.facet(highlightConfigFacet); - const highlightMark = createHighlightMark(config); - - decorations = decorations.update({ - add: [highlightMark.range(from, to)] - }); - } - else if (effect.is(removeHighlight)) { - const { from, to } = effect.value; - decorations = decorations.update({ - filter: (rangeFrom, rangeTo) => { - // 移除与指定范围重叠的装饰 - return !(rangeFrom < to && rangeTo > from); - } - }); - } - } - - return decorations; - }, - provide: field => EditorView.decorations.from(field) -}); - -// 查找与给定范围重叠的所有高亮 -function findHighlightsInRange(state: EditorState, from: number, to: number): Array<{from: number, to: number}> { - const highlights: Array<{from: number, to: number}> = []; - - state.field(highlightState).between(from, to, (rangeFrom, rangeTo) => { - if (rangeFrom < to && rangeTo > from) { - highlights.push({ from: rangeFrom, to: rangeTo }); - } - }); - - return highlights; -} - -// 查找指定位置包含的高亮 -function findHighlightsAt(state: EditorState, pos: number): Array<{from: number, to: number}> { - const highlights: Array<{from: number, to: number}> = []; - - state.field(highlightState).between(pos, pos, (from, to) => { - highlights.push({ from, to }); - }); - - return highlights; -} - -// 添加高亮范围 -function addHighlightRange(view: EditorView, from: number, to: number): boolean { - if (from === to) return false; // 不高亮空选择 - - // 检查是否已经完全高亮 - const overlappingHighlights = findHighlightsInRange(view.state, from, to); - const isFullyHighlighted = overlappingHighlights.some(range => - range.from <= from && range.to >= to - ); - - if (isFullyHighlighted) return false; - - view.dispatch({ - effects: addHighlight.of({from, to}) - }); - - return true; -} - -// 移除高亮范围 -function removeHighlightRange(view: EditorView, from: number, to: number): boolean { - const highlights = findHighlightsInRange(view.state, from, to); - - if (highlights.length === 0) return false; - - view.dispatch({ - effects: removeHighlight.of({from, to}) - }); - - return true; -} - -// 切换高亮状态 -function toggleHighlight(view: EditorView): boolean { - const selection = view.state.selection.main; - - // 如果有选择文本 - if (!selection.empty) { - const {from, to} = selection; - - // 检查选择范围内是否已经有高亮 - const highlights = findHighlightsInRange(view.state, from, to); - - if (highlights.length > 0) { - // 如果已有高亮,则移除 - return removeHighlightRange(view, from, to); - } else { - // 如果没有高亮,则添加 - return addHighlightRange(view, from, to); - } - } - // 如果是光标 - else { - const pos = selection.from; - const highlightsAtCursor = findHighlightsAt(view.state, pos); - - if (highlightsAtCursor.length > 0) { - // 移除光标位置的高亮 - const highlight = highlightsAtCursor[0]; - return removeHighlightRange(view, highlight.from, highlight.to); - } - } - - return false; -} - -// 导出文本高亮切换命令,供快捷键系统使用 -export const textHighlightToggleCommand = toggleHighlight; - -// 创建文本高亮扩展 -export function createTextHighlighter(config: TextHighlightConfig = {}) { - return [ - highlightConfigFacet.of(config), - highlightState - ]; -} \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/vscodeSearch/FindReplaceControl.ts b/frontend/src/views/editor/extensions/vscodeSearch/FindReplaceControl.ts deleted file mode 100644 index b3ebdff..0000000 --- a/frontend/src/views/editor/extensions/vscodeSearch/FindReplaceControl.ts +++ /dev/null @@ -1,612 +0,0 @@ -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; } -} - - diff --git a/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue b/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue new file mode 100644 index 0000000..ab0f3e2 --- /dev/null +++ b/frontend/src/views/editor/extensions/vscodeSearch/SearchPanel.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/views/editor/extensions/vscodeSearch/commands.ts b/frontend/src/views/editor/extensions/vscodeSearch/commands.ts deleted file mode 100644 index af2af2c..0000000 --- a/frontend/src/views/editor/extensions/vscodeSearch/commands.ts +++ /dev/null @@ -1,171 +0,0 @@ -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) => { - 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/extensions/vscodeSearch/index.ts b/frontend/src/views/editor/extensions/vscodeSearch/index.ts index b4eec06..f2b5390 100644 --- a/frontend/src/views/editor/extensions/vscodeSearch/index.ts +++ b/frontend/src/views/editor/extensions/vscodeSearch/index.ts @@ -1,4 +1 @@ -export { VSCodeSearch, vscodeSearch} from "./plugin"; -export { searchVisibilityField, SearchVisibilityEffect } from "./state"; -export { searchBaseTheme } from "./theme"; -export * from "./commands"; \ No newline at end of file +export { vscodeSearch, VSCodeSearch } from "./plugin"; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/vscodeSearch/plugin.ts b/frontend/src/views/editor/extensions/vscodeSearch/plugin.ts index d4fac7e..f20da92 100644 --- a/frontend/src/views/editor/extensions/vscodeSearch/plugin.ts +++ b/frontend/src/views/editor/extensions/vscodeSearch/plugin.ts @@ -1,80 +1,64 @@ -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"; +import { search } from "@codemirror/search"; +import { EditorView, Panel } from "@codemirror/view"; +import { StateEffect } from "@codemirror/state"; +import { createApp, App } from "vue"; +import SearchPanel from "./SearchPanel.vue"; - -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); +/** + * Create custom search panel using Vue component + * This integrates directly with CodeMirror's search extension + */ +function createSearchPanel(view: EditorView): Panel { + const dom = document.createElement("div"); + dom.className = "vscode-search-container"; + + let app: App | null = null; + + return { + dom, + top: true, + mount() { + // Mount Vue component after panel is added to DOM + app = createApp(SearchPanel, { view }); + app.mount(dom); + }, + destroy() { + // Cleanup Vue component + app?.unmount(); + app = null; } - this.prevQuery = currentQuery; - - for (const tr of update.transactions) { - for (const 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); +/** + * Custom scroll behavior - scroll match to center of viewport + * This is called automatically by findNext/findPrevious + */ +function scrollMatchToCenter(range: { from: number }, view: EditorView): StateEffect { + return EditorView.scrollIntoView(range.from, { y: 'center' }); +} +/** + * VSCode-style search extension + * Uses CodeMirror's built-in search with custom Vue UI + * + * Config options set default values for search query: + * - caseSensitive: false (default) - match case + * - wholeWord: false (default) - match whole word + * - regexp: false (default) - use regular expression + * - literal: false (default) - literal string search + */ export const vscodeSearch = [ - search({}), - VSCodeSearch, - searchBaseTheme -]; \ No newline at end of file + search({ + createPanel: createSearchPanel, + top: true, + scrollToMatch: scrollMatchToCenter, + caseSensitive: false, + wholeWord: false, + regexp: false, + literal: false, + }), +]; + +// Re-export for backwards compatibility +export { vscodeSearch as VSCodeSearch }; diff --git a/frontend/src/views/editor/extensions/vscodeSearch/state.ts b/frontend/src/views/editor/extensions/vscodeSearch/state.ts deleted file mode 100644 index 8c19281..0000000 --- a/frontend/src/views/editor/extensions/vscodeSearch/state.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 (const 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/extensions/vscodeSearch/theme.ts b/frontend/src/views/editor/extensions/vscodeSearch/theme.ts deleted file mode 100644 index e6af04c..0000000 --- a/frontend/src/views/editor/extensions/vscodeSearch/theme.ts +++ /dev/null @@ -1,263 +0,0 @@ -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)", - }, - ".find-input.error": { - borderColor: "#ff4444 !important", - backgroundColor: "#fff5f5 !important", - }, - ".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)", - }, - ".find-input.error": { - borderColor: "#ff6b6b !important", - backgroundColor: "#3d2626 !important", - }, - ".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/extensions/vscodeSearch/utility.ts b/frontend/src/views/editor/extensions/vscodeSearch/utility.ts deleted file mode 100644 index d74c54f..0000000 --- a/frontend/src/views/editor/extensions/vscodeSearch/utility.ts +++ /dev/null @@ -1,26 +0,0 @@ -export function simulateBackspace(input: HTMLInputElement, direction: "backward" | "forward" = "backward") { - let start = input.selectionStart ?? 0; - const 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 diff --git a/frontend/src/views/editor/keymap/commands.ts b/frontend/src/views/editor/keymap/commands.ts index 61cd2ff..db4e1a2 100644 --- a/frontend/src/views/editor/keymap/commands.ts +++ b/frontend/src/views/editor/keymap/commands.ts @@ -1,13 +1,8 @@ import {KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models'; import { - hideSearchVisibilityCommand, - searchReplaceAll, - searchShowReplace, - searchToggleCase, - searchToggleRegex, - searchToggleWholeWord, - showSearchVisibilityCommand -} from '../extensions/vscodeSearch/commands'; + openSearchPanel, + closeSearchPanel, +} from '@codemirror/search'; import { addNewBlockAfterCurrent, addNewBlockAfterLast, @@ -26,7 +21,6 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine'; import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines'; import {transposeChars} from '../extensions/codeblock'; import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste'; -import {textHighlightToggleCommand} from '../extensions/textHighlight'; import { copyLineDown, copyLineUp, @@ -68,34 +62,13 @@ const defaultEditorOptions = { */ export const commands = { [KeyBindingCommand.ShowSearchCommand]: { - handler: showSearchVisibilityCommand, + handler: openSearchPanel, descriptionKey: 'keybindings.commands.showSearch' }, [KeyBindingCommand.HideSearchCommand]: { - handler: hideSearchVisibilityCommand, + handler: closeSearchPanel, descriptionKey: 'keybindings.commands.hideSearch' }, - [KeyBindingCommand.SearchToggleCaseCommand]: { - handler: searchToggleCase, - descriptionKey: 'keybindings.commands.searchToggleCase' - }, - [KeyBindingCommand.SearchToggleWordCommand]: { - handler: searchToggleWholeWord, - descriptionKey: 'keybindings.commands.searchToggleWord' - }, - [KeyBindingCommand.SearchToggleRegexCommand]: { - handler: searchToggleRegex, - descriptionKey: 'keybindings.commands.searchToggleRegex' - }, - [KeyBindingCommand.SearchShowReplaceCommand]: { - handler: searchShowReplace, - descriptionKey: 'keybindings.commands.searchShowReplace' - }, - [KeyBindingCommand.SearchReplaceAllCommand]: { - handler: searchReplaceAll, - descriptionKey: 'keybindings.commands.searchReplaceAll' - }, - // 代码块操作命令 [KeyBindingCommand.BlockSelectAllCommand]: { handler: selectAll, @@ -285,12 +258,6 @@ export const commands = { handler: deleteGroupForward, descriptionKey: 'keybindings.commands.deleteGroupForward' }, - - // 文本高亮扩展命令 - [KeyBindingCommand.TextHighlightToggleCommand]: { - handler: textHighlightToggleCommand, - descriptionKey: 'keybindings.commands.textHighlightToggle' - }, } as const; /** diff --git a/frontend/src/views/editor/manager/extensions.ts b/frontend/src/views/editor/manager/extensions.ts index 9ea970a..3310398 100644 --- a/frontend/src/views/editor/manager/extensions.ts +++ b/frontend/src/views/editor/manager/extensions.ts @@ -2,16 +2,19 @@ import {Manager} from './manager'; import {ExtensionID} from '@/../bindings/voidraft/internal/models/models'; import i18n from '@/i18n'; import {ExtensionDefinition} from './types'; +import {Prec} from '@codemirror/state'; -import index from '../extensions/rainbowBracket'; -import {createTextHighlighter} from '../extensions/textHighlight'; +import rainbowBrackets from '../extensions/rainbowBracket'; import {color} from '../extensions/colorSelector'; import {hyperLink} from '../extensions/hyperlink'; import {minimap} from '../extensions/minimap'; import {vscodeSearch} from '../extensions/vscodeSearch'; -import {createCheckboxExtension} from '../extensions/checkbox'; import {createTranslatorExtension} from '../extensions/translator'; -import {foldingOnIndent} from '../extensions/fold/foldExtension'; +import markdownExtensions from '../extensions/markdown'; +import {foldGutter} from "@codemirror/language"; +import {highlightActiveLineGutter, highlightWhitespace, highlightTrailingWhitespace} from "@codemirror/view"; +import createEditorContextMenu from '../contextMenu'; +import {blockLineNumbers} from '../extensions/codeblock'; type ExtensionEntry = { definition: ExtensionDefinition @@ -28,7 +31,7 @@ const defineExtension = (create: (config: any) => any, defaultConfig: Record = { [ExtensionID.ExtensionRainbowBrackets]: { - definition: defineExtension(() => index()), + definition: defineExtension(() => rainbowBrackets()), displayNameKey: 'extensions.rainbowBrackets.name', descriptionKey: 'extensions.rainbowBrackets.description' }, @@ -66,25 +69,34 @@ const EXTENSION_REGISTRY: Record = { descriptionKey: 'extensions.search.description' }, [ExtensionID.ExtensionFold]: { - definition: defineExtension(() => foldingOnIndent), + definition: defineExtension(() => Prec.low(foldGutter())), displayNameKey: 'extensions.fold.name', descriptionKey: 'extensions.fold.description' }, - [ExtensionID.ExtensionTextHighlight]: { - definition: defineExtension((config: any) => createTextHighlighter({ - backgroundColor: config?.backgroundColor ?? '#FFD700', - opacity: config?.opacity ?? 0.3 - }), { - backgroundColor: '#FFD700', - opacity: 0.3 - }), - displayNameKey: 'extensions.textHighlight.name', - descriptionKey: 'extensions.textHighlight.description' + [ExtensionID.ExtensionMarkdown]: { + definition: defineExtension(() => markdownExtensions), + displayNameKey: 'extensions.markdown.name', + descriptionKey: 'extensions.markdown.description' }, - [ExtensionID.ExtensionCheckbox]: { - definition: defineExtension(() => createCheckboxExtension()), - displayNameKey: 'extensions.checkbox.name', - descriptionKey: 'extensions.checkbox.description' + [ExtensionID.ExtensionLineNumbers]: { + definition: defineExtension(() => Prec.high([blockLineNumbers, highlightActiveLineGutter()])), + displayNameKey: 'extensions.lineNumbers.name', + descriptionKey: 'extensions.lineNumbers.description' + }, + [ExtensionID.ExtensionContextMenu]: { + definition: defineExtension(() => createEditorContextMenu()), + displayNameKey: 'extensions.contextMenu.name', + descriptionKey: 'extensions.contextMenu.description' + }, + [ExtensionID.ExtensionHighlightWhitespace]: { + definition: defineExtension(() => highlightWhitespace()), + displayNameKey: 'extensions.highlightWhitespace.name', + descriptionKey: 'extensions.highlightWhitespace.description' + }, + [ExtensionID.ExtensionHighlightTrailingWhitespace]: { + definition: defineExtension(() => highlightTrailingWhitespace()), + displayNameKey: 'extensions.highlightTrailingWhitespace.name', + descriptionKey: 'extensions.highlightTrailingWhitespace.description' } } as const; diff --git a/frontend/src/views/editor/theme/base.ts b/frontend/src/views/editor/theme/base.ts index 449439e..7117ba0 100644 --- a/frontend/src/views/editor/theme/base.ts +++ b/frontend/src/views/editor/theme/base.ts @@ -62,6 +62,17 @@ export function createBaseTheme(colors: ThemeColors): Extension { outline: `0.5px solid ${colors.matchingBracket}`, }, + // 搜索匹配 + '.cm-searchMatch': { + backgroundColor: `${colors.searchMatch} !important`, + borderRadius: '2px', + }, + '.cm-searchMatch-selected': { + backgroundColor: `${colors.searchMatchSelected} !important`, + outline: `2px solid ${colors.searchMatchSelectedOutline}`, + borderRadius: '2px', + }, + // 代码块层(自定义) '.code-blocks-layer': { width: '100%', diff --git a/frontend/src/views/editor/theme/dark/aura.ts b/frontend/src/views/editor/theme/dark/aura.ts index 67ce831..4ca367c 100644 --- a/frontend/src/views/editor/theme/dark/aura.ts +++ b/frontend/src/views/editor/theme/dark/aura.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#3b334b', matchingBracket: '#a394f033', + // 搜索匹配 - Aura 紫青色调 + searchMatch: 'rgba(162, 119, 255, 0.4)', + searchMatchSelected: 'rgba(97, 255, 202, 0.45)', + searchMatchSelectedOutline: '#61ffca', + comment: '#6d6d6d', lineComment: '#5c5c5c', blockComment: '#5a5a5a', diff --git a/frontend/src/views/editor/theme/dark/default-dark.ts b/frontend/src/views/editor/theme/dark/default-dark.ts index f1ff7f9..d0e9963 100644 --- a/frontend/src/views/editor/theme/dark/default-dark.ts +++ b/frontend/src/views/editor/theme/dark/default-dark.ts @@ -22,6 +22,11 @@ export const defaultDarkColors: ThemeColors = { borderColor: '#1e222a', matchingBracket: '#ffffff19', + // 搜索匹配 - 金黄色调 + searchMatch: 'rgba(250, 220, 81, 0.7)', + searchMatchSelected: 'rgba(255, 140, 0, 0.85)', + searchMatchSelectedOutline: '#ff6600', + // 语法标签色值 comment: '#6272a4', lineComment: '#5c6b99', diff --git a/frontend/src/views/editor/theme/dark/dracula.ts b/frontend/src/views/editor/theme/dark/dracula.ts index 3ecc8b0..60f57f0 100644 --- a/frontend/src/views/editor/theme/dark/dracula.ts +++ b/frontend/src/views/editor/theme/dark/dracula.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#191a21', matchingBracket: '#44475a', + // 搜索匹配 - Dracula 紫粉色调 + searchMatch: 'rgba(189, 147, 249, 0.45)', + searchMatchSelected: 'rgba(255, 121, 198, 0.65)', + searchMatchSelectedOutline: '#ff79c6', + comment: '#6272a4', lineComment: '#55608c', blockComment: '#4f597f', diff --git a/frontend/src/views/editor/theme/dark/github-dark.ts b/frontend/src/views/editor/theme/dark/github-dark.ts index d98bf12..b0b9904 100644 --- a/frontend/src/views/editor/theme/dark/github-dark.ts +++ b/frontend/src/views/editor/theme/dark/github-dark.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#1b1f23', matchingBracket: '#17e5e650', + // 搜索匹配 - GitHub 蓝色调 + searchMatch: 'rgba(121, 184, 255, 0.4)', + searchMatchSelected: 'rgba(51, 146, 255, 0.6)', + searchMatchSelectedOutline: '#58a6ff', + comment: '#6a737d', lineComment: '#596068', blockComment: '#4f555c', diff --git a/frontend/src/views/editor/theme/dark/material-dark.ts b/frontend/src/views/editor/theme/dark/material-dark.ts index 1cfbcb7..dfab47d 100644 --- a/frontend/src/views/editor/theme/dark/material-dark.ts +++ b/frontend/src/views/editor/theme/dark/material-dark.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#ffffff10', matchingBracket: '#263238', + // 搜索匹配 - Material 青绿色调 + searchMatch: 'rgba(137, 221, 255, 0.4)', + searchMatchSelected: 'rgba(130, 170, 255, 0.55)', + searchMatchSelectedOutline: '#82aaff', + comment: '#546e7a', lineComment: '#4b606a', blockComment: '#455962', diff --git a/frontend/src/views/editor/theme/dark/one-dark.ts b/frontend/src/views/editor/theme/dark/one-dark.ts index d416caf..44da958 100644 --- a/frontend/src/views/editor/theme/dark/one-dark.ts +++ b/frontend/src/views/editor/theme/dark/one-dark.ts @@ -34,6 +34,11 @@ export const config: ThemeColors = { borderColor: darkBackground, matchingBracket: '#bad0f847', + // 搜索匹配 - One Dark 蓝橙色调 + searchMatch: 'rgba(97, 175, 239, 0.4)', + searchMatchSelected: 'rgba(229, 192, 123, 0.55)', + searchMatchSelectedOutline: '#e5c07b', + comment: stone, lineComment: '#6c7484', blockComment: '#606775', diff --git a/frontend/src/views/editor/theme/dark/solarized-dark.ts b/frontend/src/views/editor/theme/dark/solarized-dark.ts index 6e026f3..64cda36 100644 --- a/frontend/src/views/editor/theme/dark/solarized-dark.ts +++ b/frontend/src/views/editor/theme/dark/solarized-dark.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#073642', matchingBracket: '#073642', + // 搜索匹配 - Solarized 黄橙色调 + searchMatch: 'rgba(181, 137, 0, 0.45)', + searchMatchSelected: 'rgba(203, 75, 22, 0.55)', + searchMatchSelectedOutline: '#cb4b16', + comment: '#586e75', lineComment: '#4f646a', blockComment: '#46595e', diff --git a/frontend/src/views/editor/theme/dark/tokyo-night-storm.ts b/frontend/src/views/editor/theme/dark/tokyo-night-storm.ts index d6c1c57..3ddbb40 100644 --- a/frontend/src/views/editor/theme/dark/tokyo-night-storm.ts +++ b/frontend/src/views/editor/theme/dark/tokyo-night-storm.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#1f2335', matchingBracket: '#1f2335', + // 搜索匹配 - Tokyo Night Storm 紫蓝色调 + searchMatch: 'rgba(187, 154, 247, 0.4)', + searchMatchSelected: 'rgba(122, 162, 247, 0.55)', + searchMatchSelectedOutline: '#7aa2f7', + comment: '#565f89', lineComment: '#4d567b', blockComment: '#454e6f', diff --git a/frontend/src/views/editor/theme/dark/tokyo-night.ts b/frontend/src/views/editor/theme/dark/tokyo-night.ts index bda81e9..db40688 100644 --- a/frontend/src/views/editor/theme/dark/tokyo-night.ts +++ b/frontend/src/views/editor/theme/dark/tokyo-night.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#16161e', matchingBracket: '#16161e', + // 搜索匹配 - Tokyo Night 紫蓝色调 + searchMatch: 'rgba(187, 154, 247, 0.4)', + searchMatchSelected: 'rgba(122, 162, 247, 0.55)', + searchMatchSelectedOutline: '#7aa2f7', + comment: '#444b6a', lineComment: '#3d4360', blockComment: '#373d55', diff --git a/frontend/src/views/editor/theme/light/default-light.ts b/frontend/src/views/editor/theme/light/default-light.ts index 74c0d1a..b7c5ab9 100644 --- a/frontend/src/views/editor/theme/light/default-light.ts +++ b/frontend/src/views/editor/theme/light/default-light.ts @@ -20,6 +20,11 @@ export const defaultLightColors: ThemeColors = { borderColor: '#d8dee4', matchingBracket: '#00000019', + // 搜索匹配 - 金黄色调 + searchMatch: 'rgba(255, 200, 0, 0.55)', + searchMatchSelected: 'rgba(255, 140, 0, 0.75)', + searchMatchSelectedOutline: '#ff8c00', + comment: '#6a737d', lineComment: '#808a95', blockComment: '#5c646f', diff --git a/frontend/src/views/editor/theme/light/github-light.ts b/frontend/src/views/editor/theme/light/github-light.ts index a1844ff..283060f 100644 --- a/frontend/src/views/editor/theme/light/github-light.ts +++ b/frontend/src/views/editor/theme/light/github-light.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#e1e4e8', matchingBracket: '#34d05840', + // 搜索匹配 - GitHub 蓝色调 + searchMatch: 'rgba(3, 102, 214, 0.25)', + searchMatchSelected: 'rgba(3, 102, 214, 0.45)', + searchMatchSelectedOutline: '#0366d6', + comment: '#6a737d', lineComment: '#5e6873', blockComment: '#4f5864', diff --git a/frontend/src/views/editor/theme/light/material-light.ts b/frontend/src/views/editor/theme/light/material-light.ts index c534ee9..d8dee64 100644 --- a/frontend/src/views/editor/theme/light/material-light.ts +++ b/frontend/src/views/editor/theme/light/material-light.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#00000010', matchingBracket: '#fafafa', + // 搜索匹配 - Material 紫色调 + searchMatch: 'rgba(124, 77, 255, 0.25)', + searchMatchSelected: 'rgba(145, 184, 89, 0.45)', + searchMatchSelectedOutline: '#91b859', + comment: '#90a4ae', lineComment: '#8598a3', blockComment: '#788b97', diff --git a/frontend/src/views/editor/theme/light/solarized-light.ts b/frontend/src/views/editor/theme/light/solarized-light.ts index e4e3e8c..1bcefe6 100644 --- a/frontend/src/views/editor/theme/light/solarized-light.ts +++ b/frontend/src/views/editor/theme/light/solarized-light.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#eee8d5', matchingBracket: '#eee8d5', + // 搜索匹配 - Solarized 黄橙色调 + searchMatch: 'rgba(181, 137, 0, 0.35)', + searchMatchSelected: 'rgba(38, 139, 210, 0.4)', + searchMatchSelectedOutline: '#268bd2', + comment: '#93a1a1', lineComment: '#82939d', blockComment: '#7a8b95', diff --git a/frontend/src/views/editor/theme/light/tokyo-night-day.ts b/frontend/src/views/editor/theme/light/tokyo-night-day.ts index 56f4824..7c092ea 100644 --- a/frontend/src/views/editor/theme/light/tokyo-night-day.ts +++ b/frontend/src/views/editor/theme/light/tokyo-night-day.ts @@ -21,6 +21,11 @@ export const config: ThemeColors = { borderColor: '#e9e9ec', matchingBracket: '#e9e9ec', + // 搜索匹配 - Tokyo Night Day 紫蓝色调 + searchMatch: 'rgba(152, 84, 241, 0.25)', + searchMatchSelected: 'rgba(46, 125, 233, 0.4)', + searchMatchSelectedOutline: '#2e7de9', + comment: '#9da3c2', lineComment: '#8b90a8', blockComment: '#7e849d', diff --git a/frontend/src/views/editor/theme/types.ts b/frontend/src/views/editor/theme/types.ts index 578bc2e..106ccc8 100644 --- a/frontend/src/views/editor/theme/types.ts +++ b/frontend/src/views/editor/theme/types.ts @@ -192,5 +192,10 @@ export interface ThemeColors extends ThemeTagColors { borderColor: string; // 边框颜色 matchingBracket: string; // 匹配括号颜色 + + // 搜索匹配颜色 + searchMatch: string; // 搜索匹配背景色 + searchMatchSelected: string; // 当前选中匹配背景色 + searchMatchSelectedOutline: string; // 当前选中匹配边框色 } diff --git a/internal/models/extensions.go b/internal/models/extensions.go index 436631c..ded9353 100644 --- a/internal/models/extensions.go +++ b/internal/models/extensions.go @@ -15,16 +15,19 @@ type ExtensionID string const ( // 编辑增强扩展 - ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号 - ExtensionHyperlink ExtensionID = "hyperlink" // 超链接 - ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器 - ExtensionFold ExtensionID = "fold" - ExtensionTextHighlight ExtensionID = "textHighlight" - ExtensionCheckbox ExtensionID = "checkbox" // 选择框 - ExtensionTranslator ExtensionID = "translator" // 划词翻译 + ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号 + ExtensionHyperlink ExtensionID = "hyperlink" // 超链接 + ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器 + ExtensionFold ExtensionID = "fold" // 代码折叠 + ExtensionTranslator ExtensionID = "translator" // 划词翻译 + ExtensionMarkdown ExtensionID = "markdown" // Markdown渲染 + ExtensionHighlightWhitespace ExtensionID = "highlightWhitespace" // 显示空白字符 + ExtensionHighlightTrailingWhitespace ExtensionID = "highlightTrailingWhitespace" // 高亮行尾空白 // UI增强扩展 - ExtensionMinimap ExtensionID = "minimap" // 小地图 + ExtensionMinimap ExtensionID = "minimap" // 小地图 + ExtensionLineNumbers ExtensionID = "lineNumbers" // 行号显示 + ExtensionContextMenu ExtensionID = "contextMenu" // 上下文菜单 // 工具扩展 ExtensionSearch ExtensionID = "search" // 搜索功能 @@ -87,21 +90,6 @@ func NewDefaultExtensions() []Extension { IsDefault: true, Config: ExtensionConfig{}, }, - { - ID: ExtensionTextHighlight, - Enabled: true, - IsDefault: true, - Config: ExtensionConfig{ - "backgroundColor": "#FFD700", - "opacity": 0.3, - }, - }, - { - ID: ExtensionCheckbox, - Enabled: true, - IsDefault: true, - Config: ExtensionConfig{}, - }, { ID: ExtensionTranslator, Enabled: true, @@ -112,6 +100,24 @@ func NewDefaultExtensions() []Extension { "maxTranslationLength": 5000, }, }, + { + ID: ExtensionMarkdown, + Enabled: true, + IsDefault: true, + Config: ExtensionConfig{}, + }, + { + ID: ExtensionHighlightWhitespace, + Enabled: true, + IsDefault: true, + Config: ExtensionConfig{}, + }, + { + ID: ExtensionHighlightTrailingWhitespace, + Enabled: true, + IsDefault: true, + Config: ExtensionConfig{}, + }, // UI增强扩展 { @@ -124,6 +130,18 @@ func NewDefaultExtensions() []Extension { "autohide": false, }, }, + { + ID: ExtensionLineNumbers, + Enabled: true, + IsDefault: true, + Config: ExtensionConfig{}, + }, + { + ID: ExtensionContextMenu, + Enabled: true, + IsDefault: true, + Config: ExtensionConfig{}, + }, // 工具扩展 { diff --git a/internal/models/key_bindings.go b/internal/models/key_bindings.go index 50c297c..d6f46aa 100644 --- a/internal/models/key_bindings.go +++ b/internal/models/key_bindings.go @@ -16,13 +16,8 @@ type KeyBindingCommand string const ( // 搜索扩展相关 - ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索 - HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索 - SearchToggleCaseCommand KeyBindingCommand = "searchToggleCase" // 搜索切换大小写 - SearchToggleWordCommand KeyBindingCommand = "searchToggleWord" // 搜索切换整词 - SearchToggleRegexCommand KeyBindingCommand = "searchToggleRegex" // 搜索切换正则 - SearchShowReplaceCommand KeyBindingCommand = "searchShowReplace" // 显示替换 - SearchReplaceAllCommand KeyBindingCommand = "searchReplaceAll" // 替换全部 + ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索 + HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索 // 代码块扩展相关 BlockSelectAllCommand KeyBindingCommand = "blockSelectAll" // 块内选择全部 @@ -78,9 +73,6 @@ const ( HistoryRedoCommand KeyBindingCommand = "historyRedo" // 重做 HistoryUndoSelectionCommand KeyBindingCommand = "historyUndoSelection" // 撤销选择 HistoryRedoSelectionCommand KeyBindingCommand = "historyRedoSelection" // 重做选择 - - // 文本高亮扩展相关 - TextHighlightToggleCommand KeyBindingCommand = "textHighlightToggle" // 切换文本高亮 ) // KeyBindingMetadata 快捷键配置元数据 @@ -124,41 +116,6 @@ func NewDefaultKeyBindings() []KeyBinding { Enabled: true, IsDefault: true, }, - { - Command: SearchToggleCaseCommand, - Extension: ExtensionSearch, - Key: "Alt-c", - Enabled: true, - IsDefault: true, - }, - { - Command: SearchToggleWordCommand, - Extension: ExtensionSearch, - Key: "Alt-w", - Enabled: true, - IsDefault: true, - }, - { - Command: SearchToggleRegexCommand, - Extension: ExtensionSearch, - Key: "Alt-r", - Enabled: true, - IsDefault: true, - }, - { - Command: SearchShowReplaceCommand, - Extension: ExtensionSearch, - Key: "Mod-h", - Enabled: true, - IsDefault: true, - }, - { - Command: SearchReplaceAllCommand, - Extension: ExtensionSearch, - Key: "Mod-Alt-Enter", - Enabled: true, - IsDefault: true, - }, // 代码块核心功能快捷键 { @@ -496,15 +453,6 @@ func NewDefaultKeyBindings() []KeyBinding { Enabled: true, IsDefault: true, }, - - // 文本高亮扩展快捷键 - { - Command: TextHighlightToggleCommand, - Extension: ExtensionTextHighlight, - Key: "Mod-Shift-h", - Enabled: true, - IsDefault: true, - }, } } diff --git a/internal/services/extension_service.go b/internal/services/extension_service.go index ccf19dc..14c1c8a 100644 --- a/internal/services/extension_service.go +++ b/internal/services/extension_service.go @@ -121,6 +121,12 @@ func (es *ExtensionService) initDatabase() error { es.logger.Error("Failed to insert default extensions", "error", err) return err } + } else { + // 检查并补充缺失的扩展 + if err := es.syncExtensions(); err != nil { + es.logger.Error("Failed to ensure all extensions exist", "error", err) + return err + } } return nil @@ -153,6 +159,80 @@ func (es *ExtensionService) insertDefaultExtensions() error { return nil } +// syncExtensions 确保数据库中的扩展与代码定义同步 +func (es *ExtensionService) syncExtensions() error { + defaultSettings := models.NewDefaultExtensionSettings() + now := time.Now().Format("2006-01-02 15:04:05") + + // 构建代码中定义的扩展ID集合 + definedExtensions := make(map[string]bool) + for _, ext := range defaultSettings.Extensions { + definedExtensions[string(ext.ID)] = true + } + + // 1. 添加缺失的扩展 + for _, ext := range defaultSettings.Extensions { + var exists int + err := es.databaseService.db.QueryRow("SELECT COUNT(*) FROM extensions WHERE id = ?", string(ext.ID)).Scan(&exists) + if err != nil { + return &ExtensionError{"check_extension_exists", string(ext.ID), err} + } + + if exists == 0 { + configJSON, err := json.Marshal(ext.Config) + if err != nil { + return &ExtensionError{"marshal_config", string(ext.ID), err} + } + + _, err = es.databaseService.db.Exec(sqlInsertExtension, + string(ext.ID), + ext.Enabled, + ext.IsDefault, + string(configJSON), + now, + now, + ) + if err != nil { + return &ExtensionError{"insert_missing_extension", string(ext.ID), err} + } + es.logger.Info("Added missing extension to database", "id", ext.ID) + } + } + + // 2. 删除数据库中已不存在于代码定义的扩展 + rows, err := es.databaseService.db.Query("SELECT id FROM extensions") + if err != nil { + return &ExtensionError{"query_all_extension_ids", "", err} + } + defer rows.Close() + + var toDelete []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return &ExtensionError{"scan_extension_id", "", err} + } + if !definedExtensions[id] { + toDelete = append(toDelete, id) + } + } + + if err = rows.Err(); err != nil { + return &ExtensionError{"iterate_extension_ids", "", err} + } + + // 删除不再定义的扩展 + for _, id := range toDelete { + _, err := es.databaseService.db.Exec("DELETE FROM extensions WHERE id = ?", id) + if err != nil { + return &ExtensionError{"delete_obsolete_extension", id, err} + } + es.logger.Info("Removed obsolete extension from database", "id", id) + } + + return nil +} + // ServiceStartup 启动时调用 func (es *ExtensionService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { es.ctx = ctx