♻️ Refactor search

This commit is contained in:
2025-12-08 23:20:37 +08:00
parent 281f53c049
commit 3660d13d7d
42 changed files with 1003 additions and 1953 deletions

View File

@@ -415,25 +415,48 @@ export enum ExtensionID {
* 颜色选择器 * 颜色选择器
*/ */
ExtensionColorSelector = "colorSelector", ExtensionColorSelector = "colorSelector",
ExtensionFold = "fold",
ExtensionTextHighlight = "textHighlight",
/** /**
* 选择框 * 代码折叠
*/ */
ExtensionCheckbox = "checkbox", ExtensionFold = "fold",
/** /**
* 划词翻译 * 划词翻译
*/ */
ExtensionTranslator = "translator", ExtensionTranslator = "translator",
/**
* Markdown渲染
*/
ExtensionMarkdown = "markdown",
/**
* 显示空白字符
*/
ExtensionHighlightWhitespace = "highlightWhitespace",
/**
* 高亮行尾空白
*/
ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace",
/** /**
* UI增强扩展 * UI增强扩展
* 小地图 * 小地图
*/ */
ExtensionMinimap = "minimap", ExtensionMinimap = "minimap",
/**
* 行号显示
*/
ExtensionLineNumbers = "lineNumbers",
/**
* 上下文菜单
*/
ExtensionContextMenu = "contextMenu",
/** /**
* 工具扩展 * 工具扩展
* 搜索功能 * 搜索功能
@@ -810,31 +833,6 @@ export enum KeyBindingCommand {
*/ */
HideSearchCommand = "hideSearch", HideSearchCommand = "hideSearch",
/**
* 搜索切换大小写
*/
SearchToggleCaseCommand = "searchToggleCase",
/**
* 搜索切换整词
*/
SearchToggleWordCommand = "searchToggleWord",
/**
* 搜索切换正则
*/
SearchToggleRegexCommand = "searchToggleRegex",
/**
* 显示替换
*/
SearchShowReplaceCommand = "searchShowReplace",
/**
* 替换全部
*/
SearchReplaceAllCommand = "searchReplaceAll",
/** /**
* 代码块扩展相关 * 代码块扩展相关
* 块内选择全部 * 块内选择全部
@@ -1073,12 +1071,6 @@ export enum KeyBindingCommand {
* 重做选择 * 重做选择
*/ */
HistoryRedoSelectionCommand = "historyRedoSelection", HistoryRedoSelectionCommand = "historyRedoSelection",
/**
* 文本高亮扩展相关
* 切换文本高亮
*/
TextHighlightToggleCommand = "textHighlightToggle",
}; };
/** /**

View File

@@ -70,6 +70,25 @@
--cm-table-header-bg: rgba(46, 51, 69, 0.7); --cm-table-header-bg: rgba(46, 51, 69, 0.7);
--cm-table-border: rgba(75, 85, 99, 0.35); --cm-table-border: rgba(75, 85, 99, 0.35);
--cm-table-row-hover: rgba(55, 62, 78, 0.5); --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-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255); --cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 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-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255); --cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 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;
} }
} }

View File

@@ -111,7 +111,6 @@ export default {
deleteCharForward: 'Delete character forward', deleteCharForward: 'Delete character forward',
deleteGroupBackward: 'Delete group backward', deleteGroupBackward: 'Delete group backward',
deleteGroupForward: 'Delete group forward', deleteGroupForward: 'Delete group forward',
textHighlightToggle: 'Toggle text highlight',
} }
}, },
tabs: { tabs: {
@@ -257,7 +256,7 @@ export default {
}, },
colorSelector: { colorSelector: {
name: 'Color Selector', name: 'Color Selector',
description: 'Visual color picker and color value display' description: 'CSS code block visual color picker and color value display'
}, },
translator: { translator: {
name: 'Text Translator', name: 'Text Translator',
@@ -275,19 +274,29 @@ export default {
name: 'Code Folding', name: 'Code Folding',
description: 'Collapse and expand code sections for better readability' description: 'Collapse and expand code sections for better readability'
}, },
textHighlight: { markdown: {
name: 'Text Highlight', name: 'Markdown Renderer',
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)', description: 'Render Markdown elements, "what you see is what you get"'
backgroundColor: 'Background Color',
opacity: 'Opacity'
},
checkbox: {
name: 'Checkbox',
description: 'Render [x] and [ ] as interactive checkboxes'
}, },
codeblock: { codeblock: {
name: 'Code Block', name: 'Code Block',
description: 'Code block related functionality' 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: { monitor: {

View File

@@ -111,7 +111,6 @@ export default {
deleteCharForward: '向前删除字符', deleteCharForward: '向前删除字符',
deleteGroupBackward: '向后删除组', deleteGroupBackward: '向后删除组',
deleteGroupForward: '向前删除组', deleteGroupForward: '向前删除组',
textHighlightToggle: '切换文本高亮',
} }
}, },
tabs: { tabs: {
@@ -259,7 +258,7 @@ export default {
}, },
colorSelector: { colorSelector: {
name: '颜色选择器', name: '颜色选择器',
description: '颜色值的可视化和选择' description: 'CSS代码块颜色值的可视化和选择'
}, },
translator: { translator: {
name: '划词翻译', name: '划词翻译',
@@ -277,19 +276,29 @@ export default {
name: '代码折叠', name: '代码折叠',
description: '折叠和展开代码段以提高代码可读性' description: '折叠和展开代码段以提高代码可读性'
}, },
textHighlight: { markdown: {
name: '文本高亮', name: 'Markdown 渲染',
description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)', description: '渲染 Markdown 元素,“所见即所得”'
backgroundColor: '背景颜色',
opacity: '透明度'
},
checkbox: {
name: '选择框',
description: '将 [x] 和 [ ] 渲染为可交互的选择框'
}, },
codeblock: { codeblock: {
name: '代码块', name: '代码块',
description: '代码块相关功能' description: '代码块相关功能'
},
lineNumbers: {
name: '行号显示',
description: '在编辑器左侧显示行号,并高亮当前行'
},
contextMenu: {
name: '上下文菜单',
description: '在编辑器中右键点击时显示上下文菜单'
},
highlightWhitespace: {
name: '显示空白字符',
description: '在编辑器中显示空格和制表符等空白字符'
},
highlightTrailingWhitespace: {
name: '高亮行尾空白',
description: '高亮显示行尾的多余空白字符'
} }
}, },
monitor: { monitor: {

View File

@@ -30,7 +30,6 @@ import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor'; import {EDITOR_CONFIG} from '@/common/constant/editor';
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient"; import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
import {createDebounce} from '@/common/utils/debounce'; import {createDebounce} from '@/common/utils/debounce';
import markdownExtensions from "@/views/editor/extensions/markdown";
export interface DocumentStats { export interface DocumentStats {
lines: number; lines: number;
@@ -298,7 +297,6 @@ export const useEditorStore = defineStore('editor', () => {
codeBlockExtension, codeBlockExtension,
...dynamicExtensions, ...dynamicExtensions,
...httpExtension, ...httpExtension,
markdownExtensions
]; ];
// 创建编辑器状态 // 创建编辑器状态

View File

@@ -5,30 +5,20 @@ import {
dropCursor, dropCursor,
EditorView, EditorView,
highlightActiveLine, highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars, highlightSpecialChars,
keymap, keymap,
lineNumbers,
rectangularSelection, rectangularSelection,
scrollPastEnd
} from '@codemirror/view'; } from '@codemirror/view';
import { import {bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting,} from '@codemirror/language';
bracketMatching,
defaultHighlightStyle,
foldGutter,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import {history} from '@codemirror/commands'; import {history} from '@codemirror/commands';
import {highlightSelectionMatches} from '@codemirror/search'; import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete'; import {closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
import createEditorContextMenu from '../contextMenu';
// 基本编辑器设置 // 基本编辑器设置
export const createBasicSetup = (): Extension[] => { export const createBasicSetup = (): Extension[] => {
return [ return [
// 基础UI // 基础UI
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
dropCursor(), dropCursor(),
EditorView.lineWrapping, EditorView.lineWrapping,
@@ -36,9 +26,6 @@ export const createBasicSetup = (): Extension[] => {
// 历史记录 // 历史记录
history(), history(),
// 代码折叠
foldGutter(),
// 选择与高亮 // 选择与高亮
drawSelection(), drawSelection(),
highlightActiveLine(), highlightActiveLine(),
@@ -52,11 +39,7 @@ export const createBasicSetup = (): Extension[] => {
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
// 自动完成 scrollPastEnd(),
autocompletion(),
// 上下文菜单
createEditorContextMenu(),
// 键盘映射 // 键盘映射
keymap.of([ keymap.of([

View File

@@ -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<void>();
/**
* 复选框小部件类
*/
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
};

View File

@@ -79,6 +79,7 @@ const blockLineNumbers = lineNumbers({
/** /**
* 创建代码块扩展 * 创建代码块扩展
* 注意blockLineNumbers 已移至动态扩展管理,通过 ExtensionLineNumbers 控制
*/ */
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension { export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
const { const {
@@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
// 核心状态管理 // 核心状态管理
blockState, blockState,
// 块内行号
blockLineNumbers,
// 语言解析支持 // 语言解析支持
...getCodeBlockLanguageExtension(), ...getCodeBlockLanguageExtension(),

View File

@@ -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};
});

View File

@@ -3,15 +3,42 @@ import {
EditorView, EditorView,
Decoration, Decoration,
DecorationSet, DecorationSet,
MatchDecorator,
WidgetType, WidgetType,
ViewUpdate, ViewUpdate,
} from '@codemirror/view'; } from '@codemirror/view';
import { Extension, Range } from '@codemirror/state'; import { Extension, ChangeSet } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language'; import { syntaxTree } from '@codemirror/language';
import * as runtime from "@wailsio/runtime"; import * as runtime from "@wailsio/runtime";
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`; const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
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 // Markdown link parent nodes that should be excluded from hyperlink decoration
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']); const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
@@ -38,6 +65,45 @@ function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
return inLink; 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<string>(); // 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 { export interface HyperLinkState {
at: number; at: number;
url: string; url: string;
@@ -70,96 +136,80 @@ class HyperLinkIcon extends WidgetType {
} }
} }
function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) { /**
const widgets: Array<Range<Decoration>> = []; * Build decorations from extracted link info.
const doc = view.state.doc.toString(); */
let match; function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
const decorations: ReturnType<Decoration['range']>[] = [];
while ((match = defaultRegexp.exec(doc)) !== null) {
const from = match.index; for (const link of links) {
const to = from + match[0].length; // Add text decoration
decorations.push(Decoration.mark({
// Skip URLs that are inside markdown link syntax
if (isInMarkdownLink(view, from, to)) {
continue;
}
const linkMark = Decoration.mark({
class: 'cm-hyper-link-text' class: 'cm-hyper-link-text'
}); }).range(link.from, link.to));
widgets.push(linkMark.range(from, to));
const widget = Decoration.widget({ // Add icon widget
decorations.push(Decoration.widget({
widget: new HyperLinkIcon({ widget: new HyperLinkIcon({
at: to, at: link.to,
url: match[0], url: link.url,
anchor, anchor,
}), }),
side: 1, side: 1,
}); }).range(link.to));
widgets.push(widget.range(to));
} }
return Decoration.set(widgets); return Decoration.set(decorations, true);
} }
const linkDecorator = (
regexp?: RegExp,
matchData?: Record<string, string>,
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 = { export type HyperLinkExtensionOptions = {
regexp?: RegExp; /** Custom anchor element transformer */
match?: Record<string, string>;
handle?: (value: string, input: string, from: number, to: number) => string;
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement; 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( return ViewPlugin.fromClass(
class HyperLinkView { class HyperLinkView {
decorator?: MatchDecorator;
decorations: DecorationSet; decorations: DecorationSet;
links: HyperLinkInfo[] = [];
constructor(view: EditorView) { constructor(view: EditorView) {
if (regexp) { this.links = extractVisibleLinks(view);
this.decorator = linkDecorator(regexp, match, handle, anchor); this.decorations = buildDecorations(this.links, anchor);
this.decorations = this.decorator.createDeco(view);
} else {
this.decorations = hyperLinkDecorations(view, anchor);
}
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) { // Always rebuild on viewport change (new content visible)
if (regexp && this.decorator) { if (update.viewportChanged) {
this.decorations = this.decorator.updateDeco(update, this.decorations); 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 { } 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': { '.cm-hyper-link-icon svg': {
display: 'block', display: 'block',
width: '14px', width: 'inherit',
height: '14px', height: 'inherit',
}, },
'.cm-editor.cm-focused .cm-hyper-link-text': { '.cm-editor.cm-focused .cm-hyper-link-text': {

View File

@@ -171,11 +171,11 @@ export const inlineStylesTheme = EditorView.baseTheme({
'.cm-superscript': { '.cm-superscript': {
verticalAlign: 'super', verticalAlign: 'super',
fontSize: '0.75em', fontSize: '0.75em',
color: 'var(--cm-superscript-color, inherit)' color: 'inherit'
}, },
'.cm-subscript': { '.cm-subscript': {
verticalAlign: 'sub', verticalAlign: 'sub',
fontSize: '0.75em', fontSize: '0.75em',
color: 'var(--cm-subscript-color, inherit)' color: 'inherit'
} }
}); });

View File

@@ -1,67 +1,88 @@
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view'; import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
import { Range } from '@codemirror/state'; import { Range } from '@codemirror/state';
// 生成彩虹颜色数组 // 彩虹颜色数组
function generateColors(): string[] { const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
return ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'
];
}
const OPEN_BRACKETS = new Set(['(', '[', '{']);
const CLOSE_BRACKETS = new Set([')', ']', '}']);
const BRACKET_PAIRS: Record<string, string> = { ')': '(', ']': '[', '}': '{' };
/**
* 彩虹括号插件
*/
class RainbowBracketsView { class RainbowBracketsView {
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = this.getBracketDecorations(view); this.decorations = this.buildDecorations(view);
} }
update(update: ViewUpdate): void { update(update: ViewUpdate): void {
if (update.docChanged || update.selectionSet || update.viewportChanged) { if (update.docChanged || update.viewportChanged) {
this.decorations = this.getBracketDecorations(update.view); this.decorations = this.buildDecorations(update.view);
} }
} }
private getBracketDecorations(view: EditorView): DecorationSet { private buildDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const decorations: Range<Decoration>[] = []; const decorations: Range<Decoration>[] = [];
const stack: { type: string; from: number }[] = []; const doc = view.state.doc;
const colors = generateColors();
const visibleRanges = view.visibleRanges;
// 遍历文档内容 if (visibleRanges.length === 0) {
for (let pos = 0; pos < doc.length; pos++) { 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); const char = doc.sliceString(pos, pos + 1);
// 遇到开括号 if (OPEN_BRACKETS.has(char)) {
if (char === '(' || char === '[' || char === '{') { stack.push({ char, from: pos });
stack.push({ type: char, from: pos }); } else if (CLOSE_BRACKETS.has(char)) {
}
// 遇到闭括号
else if (char === ')' || char === ']' || char === '}') {
const open = stack.pop(); const open = stack.pop();
const matchingBracket = this.getMatchingBracket(char); if (open && open.char !== BRACKET_PAIRS[char]) {
stack.push(open); // 不匹配,放回
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)
);
} }
} }
} }
return Decoration.set(decorations.sort((a, b) => a.from - b.from)); // 阶段2: 处理可视范围内的括号(创建装饰)
} for (let pos = visibleFrom; pos < visibleTo && pos < doc.length; pos++) {
const char = doc.sliceString(pos, pos + 1);
private getMatchingBracket(closingBracket: string): string | null {
switch (closingBracket) { if (OPEN_BRACKETS.has(char)) {
case ')': return '('; const depth = stack.length;
case ']': return '['; stack.push({ char, from: pos });
case '}': return '{';
default: return null; // 添加开括号装饰
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, decorations: (v) => v.decorations,
}); });
export default function index() { export default function rainbowBrackets() {
return [ return [
rainbowBracketsPlugin, rainbowBracketsPlugin,
EditorView.baseTheme({ EditorView.baseTheme({
@@ -83,4 +104,4 @@ export default function index() {
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' }, '.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
}), }),
]; ];
} }

View File

@@ -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<TextHighlightConfig> = {
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<TextHighlightConfig, Required<TextHighlightConfig>>({
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<TextHighlightConfig>): 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<DecorationSet>({
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
];
}

View File

@@ -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<string, string> = {
"downChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/></svg>',
"rightChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/></svg>',
"matchCase": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>',
"wholeWord": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>',
"regex": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>',
"prevMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>',
"nextMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>',
"close": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>',
"replace": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>',
"replaceAll": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>',
};
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; }
}

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { EditorView } from '@codemirror/view';
import {
getSearchQuery,
SearchQuery,
setSearchQuery,
findNext,
findPrevious,
replaceNext,
replaceAll,
closeSearchPanel,
SearchCursor,
RegExpCursor
} from '@codemirror/search';
const props = defineProps<{ view: EditorView }>();
// State - options will be initialized from getSearchQuery (uses config defaults)
const replaceVisible = ref(false);
const searchText = ref('');
const replaceText = ref('');
const matchCase = ref(false); // Will be set from query in onMounted
const matchWord = ref(false); // Will be set from query in onMounted
const useRegex = ref(false); // Will be set from query in onMounted
const hasError = ref(false);
const totalMatches = ref(0);
const currentMatchIndex = ref(0);
const searchInput = ref<HTMLInputElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);
// Computed
const matchCountText = computed(() => {
if (hasError.value) return 'Invalid regex';
if (!searchText.value || totalMatches.value === 0) return 'No results';
return `${currentMatchIndex.value} of ${totalMatches.value}`;
});
const hasMatches = computed(() => totalMatches.value > 0);
const canReplace = computed(() => searchText.value.length > 0 && hasMatches.value);
// Core functions
function commit() {
try {
const query = new SearchQuery({
search: searchText.value,
replace: replaceText.value,
caseSensitive: matchCase.value,
regexp: useRegex.value,
wholeWord: matchWord.value,
});
props.view.dispatch({ effects: setSearchQuery.of(query) });
hasError.value = false;
updateMatchCount();
} catch {
hasError.value = true;
totalMatches.value = currentMatchIndex.value = 0;
}
}
function updateMatchCount() {
const query = getSearchQuery(props.view.state);
if (!query.search) {
totalMatches.value = currentMatchIndex.value = 0;
return;
}
try {
const cursorPos = props.view.state.selection.main.from;
let total = 0, current = 0, foundCurrent = false;
const cursor = query.regexp
? new RegExpCursor(props.view.state.doc, query.search, { ignoreCase: !query.caseSensitive })
: new SearchCursor(props.view.state.doc, query.search, 0, props.view.state.doc.length,
query.caseSensitive ? undefined : (s: string) => s.toLowerCase());
while (!cursor.next().done) {
total++;
if (!foundCurrent && cursor.value.from >= cursorPos) {
current = total;
foundCurrent = true;
}
if (total >= 9999) break;
}
totalMatches.value = total;
currentMatchIndex.value = foundCurrent ? current : Math.min(1, total);
} catch {
totalMatches.value = currentMatchIndex.value = 0;
}
}
// Actions - scrollToMatch is handled by search config in plugin.ts
const doFindNext = () => { findNext(props.view); updateMatchCount(); };
const doFindPrevious = () => { findPrevious(props.view); updateMatchCount(); };
const doReplace = () => { if (canReplace.value) { replaceNext(props.view); updateMatchCount(); } };
const doReplaceAll = () => { if (canReplace.value) { replaceAll(props.view); updateMatchCount(); } };
const toggleOption = (opt: 'case' | 'word' | 'regex') => {
const map = { case: matchCase, word: matchWord, regex: useRegex };
map[opt].value = !map[opt].value;
commit();
};
const close = () => closeSearchPanel(props.view);
// Keyboard handlers
const onSearchKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? doFindPrevious() : doFindNext(); }
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
const onReplaceKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); (e.ctrlKey || e.metaKey) ? doReplaceAll() : doReplace(); }
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
// Resize
let resizeState = { startX: 0, startWidth: 0 };
const onResize = (e: MouseEvent) => {
if (!containerRef.value) return;
containerRef.value.style.width = `${Math.max(411, Math.min(800, resizeState.startWidth + resizeState.startX - e.clientX))}px`;
};
const stopResize = () => { document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', stopResize); };
const startResize = (e: MouseEvent) => {
resizeState = { startX: e.clientX, startWidth: containerRef.value?.offsetWidth ?? 411 };
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
};
// Watch for input changes
watch([searchText, replaceText], commit);
// Init - read options from query (defaults from search config), pre-populate search text
onMounted(() => {
// Always read options from query - this uses defaults from search() config
const query = getSearchQuery(props.view.state);
matchCase.value = query.caseSensitive;
matchWord.value = query.wholeWord;
useRegex.value = query.regexp;
// Pre-populate search/replace text
if (query?.search) {
searchText.value = query.search;
replaceText.value = query.replace;
} else {
// Pre-populate from selection if no existing search
const { main } = props.view.state.selection;
if (!main.empty) {
const text = props.view.state.doc.sliceString(main.from, main.to);
if (!text.includes('\n') && text.length < 200) searchText.value = text;
}
}
// Focus input
nextTick(() => {
searchInput.value?.focus();
searchInput.value?.select();
if (searchText.value) commit();
});
});
</script>
<template>
<div ref="containerRef" class="search-panel" @keydown.esc="close">
<div class="resize-handle" @mousedown="startResize" />
<div class="toggle-section">
<div class="toggle-replace" @click="replaceVisible = !replaceVisible" :title="replaceVisible ? 'Hide Replace' : 'Show Replace'">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path v-if="replaceVisible" fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/>
<path v-else fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/>
</svg>
</div>
</div>
<div class="input-section">
<div class="search-bar">
<input ref="searchInput" v-model="searchText" type="text" class="find-input" :class="{ error: hasError }" placeholder="Find" @keydown="onSearchKeydown" />
<div class="search-controls">
<div class="control-btn" :class="{ active: matchCase }" title="Match Case (Alt+C)" @click="toggleOption('case')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>
</div>
<div class="control-btn" :class="{ active: matchWord }" title="Match Whole Word (Alt+W)" @click="toggleOption('word')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>
</div>
<div class="control-btn" :class="{ active: useRegex }" title="Use Regular Expression (Alt+R)" @click="toggleOption('regex')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>
</div>
</div>
</div>
<div v-show="replaceVisible" class="replace-bar">
<input v-model="replaceText" type="text" class="replace-input" placeholder="Replace" @keydown="onReplaceKeydown" />
</div>
</div>
<div class="actions-section">
<div class="button-group">
<span class="match-count">{{ matchCountText }}</span>
<div class="search-icons">
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Previous Match (Shift+Enter)" @click="doFindPrevious">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>
</div>
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Next Match (Enter)" @click="doFindNext">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>
</div>
<div class="icon-btn" title="Close (Escape)" @click="close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>
</div>
</div>
</div>
<div v-show="replaceVisible" class="replace-buttons">
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace (Enter)" @click="doReplace">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>
</div>
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace All (Ctrl+Enter)" @click="doReplaceAll">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.search-panel {
position: absolute;
top: 0;
right: 14px;
z-index: 9999;
display: flex;
min-width: 411px;
max-width: calc(100% - 28px);
padding: 4px;
border-radius: 4px;
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.36);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
box-sizing: border-box;
background-color: var(--search-panel-bg);
color: var(--search-panel-text);
border: 1px solid var(--search-panel-border);
}
.resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
&:hover { background-color: var(--search-focus-border); }
}
.toggle-section {
display: flex;
flex-direction: column;
padding: 3px 2px 3px 3px;
}
.toggle-replace {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 100%;
padding: 3px;
border-radius: 3px;
cursor: pointer;
&:hover { background-color: var(--search-btn-hover); }
}
.input-section {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
padding: 3px 0;
}
.search-bar { display: flex; position: relative; }
.find-input, .replace-input {
width: 100%;
height: 24px;
padding: 3px 70px 3px 6px;
border-radius: 2px;
outline: none;
font-size: 13px;
line-height: 18px;
box-sizing: border-box;
background: var(--search-input-bg);
color: var(--search-input-text);
border: 1px solid var(--search-input-border);
&:focus {
border-color: var(--search-focus-border);
outline: 1px solid var(--search-focus-border);
outline-offset: -1px;
}
&.error {
border-color: var(--search-error-border) !important;
background-color: var(--search-error-bg) !important;
}
}
.replace-input { padding: 3px 6px; }
.replace-bar { display: flex; }
.search-controls {
display: flex;
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
gap: 1px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
cursor: pointer;
border: 1px solid transparent;
svg { width: 16px; height: 16px; }
&:hover { background-color: var(--search-btn-hover); }
&.active {
background-color: var(--search-btn-active-bg);
color: var(--search-btn-active-text);
border-color: var(--search-focus-border);
svg { fill: var(--search-btn-active-text); }
}
}
.actions-section {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px 4px;
}
.button-group {
display: flex;
align-items: center;
height: 24px;
gap: 4px;
}
.match-count {
font-size: 12px;
white-space: nowrap;
min-width: 50px;
text-align: center;
line-height: 22px;
}
.search-icons, .replace-buttons { display: flex; gap: 1px; }
.replace-buttons { height: 24px; }
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 2px;
border-radius: 3px;
cursor: pointer;
svg { width: 16px; height: 16px; }
&:hover { background-color: var(--search-btn-hover); }
&.disabled { opacity: 0.4; pointer-events: none; }
}
</style>

View File

@@ -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;
};

View File

@@ -1,4 +1 @@
export { VSCodeSearch, vscodeSearch} from "./plugin"; export { vscodeSearch, VSCodeSearch } from "./plugin";
export { searchVisibilityField, SearchVisibilityEffect } from "./state";
export { searchBaseTheme } from "./theme";
export * from "./commands";

View File

@@ -1,80 +1,64 @@
import { getSearchQuery, search, SearchQuery } from "@codemirror/search"; import { search } from "@codemirror/search";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; import { EditorView, Panel } from "@codemirror/view";
import { CustomSearchPanel } from "./FindReplaceControl"; import { StateEffect } from "@codemirror/state";
import { SearchVisibilityEffect } from "./state"; import { createApp, App } from "vue";
import { searchBaseTheme } from "./theme"; import SearchPanel from "./SearchPanel.vue";
/**
export class SearchPlugin { * Create custom search panel using Vue component
private searchControl: CustomSearchPanel; * This integrates directly with CodeMirror's search extension
private prevQuery: SearchQuery | null = null; */
function createSearchPanel(view: EditorView): Panel {
constructor(view: EditorView) { const dom = document.createElement("div");
this.searchControl = new CustomSearchPanel(view); dom.className = "vscode-search-container";
}
let app: App | null = null;
update(update: ViewUpdate) {
const currentQuery = getSearchQuery(update.state); return {
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) { dom,
this.searchControl.findMatchesAndSelectClosest(update.state); 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<unknown> {
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 = [ export const vscodeSearch = [
search({}), search({
VSCodeSearch, createPanel: createSearchPanel,
searchBaseTheme top: true,
]; scrollToMatch: scrollMatchToCenter,
caseSensitive: false,
wholeWord: false,
regexp: false,
literal: false,
}),
];
// Re-export for backwards compatibility
export { vscodeSearch as VSCodeSearch };

View File

@@ -1,19 +0,0 @@
import { StateEffect, StateField } from "@codemirror/state";
// Define an effect to update the visibility state
export const SearchVisibilityEffect = StateEffect.define<boolean>();
// 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;
}
});

View File

@@ -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"),
});

View File

@@ -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 }));
}

View File

@@ -1,13 +1,8 @@
import {KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models'; import {KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models';
import { import {
hideSearchVisibilityCommand, openSearchPanel,
searchReplaceAll, closeSearchPanel,
searchShowReplace, } from '@codemirror/search';
searchToggleCase,
searchToggleRegex,
searchToggleWholeWord,
showSearchVisibilityCommand
} from '../extensions/vscodeSearch/commands';
import { import {
addNewBlockAfterCurrent, addNewBlockAfterCurrent,
addNewBlockAfterLast, addNewBlockAfterLast,
@@ -26,7 +21,6 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines'; import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
import {transposeChars} from '../extensions/codeblock'; import {transposeChars} from '../extensions/codeblock';
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste'; import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
import {textHighlightToggleCommand} from '../extensions/textHighlight';
import { import {
copyLineDown, copyLineDown,
copyLineUp, copyLineUp,
@@ -68,34 +62,13 @@ const defaultEditorOptions = {
*/ */
export const commands = { export const commands = {
[KeyBindingCommand.ShowSearchCommand]: { [KeyBindingCommand.ShowSearchCommand]: {
handler: showSearchVisibilityCommand, handler: openSearchPanel,
descriptionKey: 'keybindings.commands.showSearch' descriptionKey: 'keybindings.commands.showSearch'
}, },
[KeyBindingCommand.HideSearchCommand]: { [KeyBindingCommand.HideSearchCommand]: {
handler: hideSearchVisibilityCommand, handler: closeSearchPanel,
descriptionKey: 'keybindings.commands.hideSearch' 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]: { [KeyBindingCommand.BlockSelectAllCommand]: {
handler: selectAll, handler: selectAll,
@@ -285,12 +258,6 @@ export const commands = {
handler: deleteGroupForward, handler: deleteGroupForward,
descriptionKey: 'keybindings.commands.deleteGroupForward' descriptionKey: 'keybindings.commands.deleteGroupForward'
}, },
// 文本高亮扩展命令
[KeyBindingCommand.TextHighlightToggleCommand]: {
handler: textHighlightToggleCommand,
descriptionKey: 'keybindings.commands.textHighlightToggle'
},
} as const; } as const;
/** /**

View File

@@ -2,16 +2,19 @@ import {Manager} from './manager';
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models'; import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import i18n from '@/i18n'; import i18n from '@/i18n';
import {ExtensionDefinition} from './types'; import {ExtensionDefinition} from './types';
import {Prec} from '@codemirror/state';
import index from '../extensions/rainbowBracket'; import rainbowBrackets from '../extensions/rainbowBracket';
import {createTextHighlighter} from '../extensions/textHighlight';
import {color} from '../extensions/colorSelector'; import {color} from '../extensions/colorSelector';
import {hyperLink} from '../extensions/hyperlink'; import {hyperLink} from '../extensions/hyperlink';
import {minimap} from '../extensions/minimap'; import {minimap} from '../extensions/minimap';
import {vscodeSearch} from '../extensions/vscodeSearch'; import {vscodeSearch} from '../extensions/vscodeSearch';
import {createCheckboxExtension} from '../extensions/checkbox';
import {createTranslatorExtension} from '../extensions/translator'; 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 = { type ExtensionEntry = {
definition: ExtensionDefinition definition: ExtensionDefinition
@@ -28,7 +31,7 @@ const defineExtension = (create: (config: any) => any, defaultConfig: Record<str
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = { const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
[ExtensionID.ExtensionRainbowBrackets]: { [ExtensionID.ExtensionRainbowBrackets]: {
definition: defineExtension(() => index()), definition: defineExtension(() => rainbowBrackets()),
displayNameKey: 'extensions.rainbowBrackets.name', displayNameKey: 'extensions.rainbowBrackets.name',
descriptionKey: 'extensions.rainbowBrackets.description' descriptionKey: 'extensions.rainbowBrackets.description'
}, },
@@ -66,25 +69,34 @@ const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
descriptionKey: 'extensions.search.description' descriptionKey: 'extensions.search.description'
}, },
[ExtensionID.ExtensionFold]: { [ExtensionID.ExtensionFold]: {
definition: defineExtension(() => foldingOnIndent), definition: defineExtension(() => Prec.low(foldGutter())),
displayNameKey: 'extensions.fold.name', displayNameKey: 'extensions.fold.name',
descriptionKey: 'extensions.fold.description' descriptionKey: 'extensions.fold.description'
}, },
[ExtensionID.ExtensionTextHighlight]: { [ExtensionID.ExtensionMarkdown]: {
definition: defineExtension((config: any) => createTextHighlighter({ definition: defineExtension(() => markdownExtensions),
backgroundColor: config?.backgroundColor ?? '#FFD700', displayNameKey: 'extensions.markdown.name',
opacity: config?.opacity ?? 0.3 descriptionKey: 'extensions.markdown.description'
}), {
backgroundColor: '#FFD700',
opacity: 0.3
}),
displayNameKey: 'extensions.textHighlight.name',
descriptionKey: 'extensions.textHighlight.description'
}, },
[ExtensionID.ExtensionCheckbox]: { [ExtensionID.ExtensionLineNumbers]: {
definition: defineExtension(() => createCheckboxExtension()), definition: defineExtension(() => Prec.high([blockLineNumbers, highlightActiveLineGutter()])),
displayNameKey: 'extensions.checkbox.name', displayNameKey: 'extensions.lineNumbers.name',
descriptionKey: 'extensions.checkbox.description' 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; } as const;

View File

@@ -62,6 +62,17 @@ export function createBaseTheme(colors: ThemeColors): Extension {
outline: `0.5px solid ${colors.matchingBracket}`, 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': { '.code-blocks-layer': {
width: '100%', width: '100%',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#3b334b', borderColor: '#3b334b',
matchingBracket: '#a394f033', matchingBracket: '#a394f033',
// 搜索匹配 - Aura 紫青色调
searchMatch: 'rgba(162, 119, 255, 0.4)',
searchMatchSelected: 'rgba(97, 255, 202, 0.45)',
searchMatchSelectedOutline: '#61ffca',
comment: '#6d6d6d', comment: '#6d6d6d',
lineComment: '#5c5c5c', lineComment: '#5c5c5c',
blockComment: '#5a5a5a', blockComment: '#5a5a5a',

View File

@@ -22,6 +22,11 @@ export const defaultDarkColors: ThemeColors = {
borderColor: '#1e222a', borderColor: '#1e222a',
matchingBracket: '#ffffff19', matchingBracket: '#ffffff19',
// 搜索匹配 - 金黄色调
searchMatch: 'rgba(250, 220, 81, 0.7)',
searchMatchSelected: 'rgba(255, 140, 0, 0.85)',
searchMatchSelectedOutline: '#ff6600',
// 语法标签色值 // 语法标签色值
comment: '#6272a4', comment: '#6272a4',
lineComment: '#5c6b99', lineComment: '#5c6b99',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#191a21', borderColor: '#191a21',
matchingBracket: '#44475a', matchingBracket: '#44475a',
// 搜索匹配 - Dracula 紫粉色调
searchMatch: 'rgba(189, 147, 249, 0.45)',
searchMatchSelected: 'rgba(255, 121, 198, 0.65)',
searchMatchSelectedOutline: '#ff79c6',
comment: '#6272a4', comment: '#6272a4',
lineComment: '#55608c', lineComment: '#55608c',
blockComment: '#4f597f', blockComment: '#4f597f',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#1b1f23', borderColor: '#1b1f23',
matchingBracket: '#17e5e650', matchingBracket: '#17e5e650',
// 搜索匹配 - GitHub 蓝色调
searchMatch: 'rgba(121, 184, 255, 0.4)',
searchMatchSelected: 'rgba(51, 146, 255, 0.6)',
searchMatchSelectedOutline: '#58a6ff',
comment: '#6a737d', comment: '#6a737d',
lineComment: '#596068', lineComment: '#596068',
blockComment: '#4f555c', blockComment: '#4f555c',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#ffffff10', borderColor: '#ffffff10',
matchingBracket: '#263238', matchingBracket: '#263238',
// 搜索匹配 - Material 青绿色调
searchMatch: 'rgba(137, 221, 255, 0.4)',
searchMatchSelected: 'rgba(130, 170, 255, 0.55)',
searchMatchSelectedOutline: '#82aaff',
comment: '#546e7a', comment: '#546e7a',
lineComment: '#4b606a', lineComment: '#4b606a',
blockComment: '#455962', blockComment: '#455962',

View File

@@ -34,6 +34,11 @@ export const config: ThemeColors = {
borderColor: darkBackground, borderColor: darkBackground,
matchingBracket: '#bad0f847', matchingBracket: '#bad0f847',
// 搜索匹配 - One Dark 蓝橙色调
searchMatch: 'rgba(97, 175, 239, 0.4)',
searchMatchSelected: 'rgba(229, 192, 123, 0.55)',
searchMatchSelectedOutline: '#e5c07b',
comment: stone, comment: stone,
lineComment: '#6c7484', lineComment: '#6c7484',
blockComment: '#606775', blockComment: '#606775',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#073642', borderColor: '#073642',
matchingBracket: '#073642', matchingBracket: '#073642',
// 搜索匹配 - Solarized 黄橙色调
searchMatch: 'rgba(181, 137, 0, 0.45)',
searchMatchSelected: 'rgba(203, 75, 22, 0.55)',
searchMatchSelectedOutline: '#cb4b16',
comment: '#586e75', comment: '#586e75',
lineComment: '#4f646a', lineComment: '#4f646a',
blockComment: '#46595e', blockComment: '#46595e',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#1f2335', borderColor: '#1f2335',
matchingBracket: '#1f2335', matchingBracket: '#1f2335',
// 搜索匹配 - Tokyo Night Storm 紫蓝色调
searchMatch: 'rgba(187, 154, 247, 0.4)',
searchMatchSelected: 'rgba(122, 162, 247, 0.55)',
searchMatchSelectedOutline: '#7aa2f7',
comment: '#565f89', comment: '#565f89',
lineComment: '#4d567b', lineComment: '#4d567b',
blockComment: '#454e6f', blockComment: '#454e6f',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#16161e', borderColor: '#16161e',
matchingBracket: '#16161e', matchingBracket: '#16161e',
// 搜索匹配 - Tokyo Night 紫蓝色调
searchMatch: 'rgba(187, 154, 247, 0.4)',
searchMatchSelected: 'rgba(122, 162, 247, 0.55)',
searchMatchSelectedOutline: '#7aa2f7',
comment: '#444b6a', comment: '#444b6a',
lineComment: '#3d4360', lineComment: '#3d4360',
blockComment: '#373d55', blockComment: '#373d55',

View File

@@ -20,6 +20,11 @@ export const defaultLightColors: ThemeColors = {
borderColor: '#d8dee4', borderColor: '#d8dee4',
matchingBracket: '#00000019', matchingBracket: '#00000019',
// 搜索匹配 - 金黄色调
searchMatch: 'rgba(255, 200, 0, 0.55)',
searchMatchSelected: 'rgba(255, 140, 0, 0.75)',
searchMatchSelectedOutline: '#ff8c00',
comment: '#6a737d', comment: '#6a737d',
lineComment: '#808a95', lineComment: '#808a95',
blockComment: '#5c646f', blockComment: '#5c646f',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#e1e4e8', borderColor: '#e1e4e8',
matchingBracket: '#34d05840', matchingBracket: '#34d05840',
// 搜索匹配 - GitHub 蓝色调
searchMatch: 'rgba(3, 102, 214, 0.25)',
searchMatchSelected: 'rgba(3, 102, 214, 0.45)',
searchMatchSelectedOutline: '#0366d6',
comment: '#6a737d', comment: '#6a737d',
lineComment: '#5e6873', lineComment: '#5e6873',
blockComment: '#4f5864', blockComment: '#4f5864',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#00000010', borderColor: '#00000010',
matchingBracket: '#fafafa', matchingBracket: '#fafafa',
// 搜索匹配 - Material 紫色调
searchMatch: 'rgba(124, 77, 255, 0.25)',
searchMatchSelected: 'rgba(145, 184, 89, 0.45)',
searchMatchSelectedOutline: '#91b859',
comment: '#90a4ae', comment: '#90a4ae',
lineComment: '#8598a3', lineComment: '#8598a3',
blockComment: '#788b97', blockComment: '#788b97',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#eee8d5', borderColor: '#eee8d5',
matchingBracket: '#eee8d5', matchingBracket: '#eee8d5',
// 搜索匹配 - Solarized 黄橙色调
searchMatch: 'rgba(181, 137, 0, 0.35)',
searchMatchSelected: 'rgba(38, 139, 210, 0.4)',
searchMatchSelectedOutline: '#268bd2',
comment: '#93a1a1', comment: '#93a1a1',
lineComment: '#82939d', lineComment: '#82939d',
blockComment: '#7a8b95', blockComment: '#7a8b95',

View File

@@ -21,6 +21,11 @@ export const config: ThemeColors = {
borderColor: '#e9e9ec', borderColor: '#e9e9ec',
matchingBracket: '#e9e9ec', matchingBracket: '#e9e9ec',
// 搜索匹配 - Tokyo Night Day 紫蓝色调
searchMatch: 'rgba(152, 84, 241, 0.25)',
searchMatchSelected: 'rgba(46, 125, 233, 0.4)',
searchMatchSelectedOutline: '#2e7de9',
comment: '#9da3c2', comment: '#9da3c2',
lineComment: '#8b90a8', lineComment: '#8b90a8',
blockComment: '#7e849d', blockComment: '#7e849d',

View File

@@ -192,5 +192,10 @@ export interface ThemeColors extends ThemeTagColors {
borderColor: string; // 边框颜色 borderColor: string; // 边框颜色
matchingBracket: string; // 匹配括号颜色 matchingBracket: string; // 匹配括号颜色
// 搜索匹配颜色
searchMatch: string; // 搜索匹配背景色
searchMatchSelected: string; // 当前选中匹配背景色
searchMatchSelectedOutline: string; // 当前选中匹配边框色
} }

View File

@@ -15,16 +15,19 @@ type ExtensionID string
const ( const (
// 编辑增强扩展 // 编辑增强扩展
ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号 ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号
ExtensionHyperlink ExtensionID = "hyperlink" // 超链接 ExtensionHyperlink ExtensionID = "hyperlink" // 超链接
ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器 ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器
ExtensionFold ExtensionID = "fold" ExtensionFold ExtensionID = "fold" // 代码折叠
ExtensionTextHighlight ExtensionID = "textHighlight" ExtensionTranslator ExtensionID = "translator" // 划词翻译
ExtensionCheckbox ExtensionID = "checkbox" // 选择框 ExtensionMarkdown ExtensionID = "markdown" // Markdown渲染
ExtensionTranslator ExtensionID = "translator" // 划词翻译 ExtensionHighlightWhitespace ExtensionID = "highlightWhitespace" // 显示空白字符
ExtensionHighlightTrailingWhitespace ExtensionID = "highlightTrailingWhitespace" // 高亮行尾空白
// UI增强扩展 // UI增强扩展
ExtensionMinimap ExtensionID = "minimap" // 小地图 ExtensionMinimap ExtensionID = "minimap" // 小地图
ExtensionLineNumbers ExtensionID = "lineNumbers" // 行号显示
ExtensionContextMenu ExtensionID = "contextMenu" // 上下文菜单
// 工具扩展 // 工具扩展
ExtensionSearch ExtensionID = "search" // 搜索功能 ExtensionSearch ExtensionID = "search" // 搜索功能
@@ -87,21 +90,6 @@ func NewDefaultExtensions() []Extension {
IsDefault: true, IsDefault: true,
Config: ExtensionConfig{}, 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, ID: ExtensionTranslator,
Enabled: true, Enabled: true,
@@ -112,6 +100,24 @@ func NewDefaultExtensions() []Extension {
"maxTranslationLength": 5000, "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增强扩展 // UI增强扩展
{ {
@@ -124,6 +130,18 @@ func NewDefaultExtensions() []Extension {
"autohide": false, "autohide": false,
}, },
}, },
{
ID: ExtensionLineNumbers,
Enabled: true,
IsDefault: true,
Config: ExtensionConfig{},
},
{
ID: ExtensionContextMenu,
Enabled: true,
IsDefault: true,
Config: ExtensionConfig{},
},
// 工具扩展 // 工具扩展
{ {

View File

@@ -16,13 +16,8 @@ type KeyBindingCommand string
const ( const (
// 搜索扩展相关 // 搜索扩展相关
ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索 ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索
HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索 HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索
SearchToggleCaseCommand KeyBindingCommand = "searchToggleCase" // 搜索切换大小写
SearchToggleWordCommand KeyBindingCommand = "searchToggleWord" // 搜索切换整词
SearchToggleRegexCommand KeyBindingCommand = "searchToggleRegex" // 搜索切换正则
SearchShowReplaceCommand KeyBindingCommand = "searchShowReplace" // 显示替换
SearchReplaceAllCommand KeyBindingCommand = "searchReplaceAll" // 替换全部
// 代码块扩展相关 // 代码块扩展相关
BlockSelectAllCommand KeyBindingCommand = "blockSelectAll" // 块内选择全部 BlockSelectAllCommand KeyBindingCommand = "blockSelectAll" // 块内选择全部
@@ -78,9 +73,6 @@ const (
HistoryRedoCommand KeyBindingCommand = "historyRedo" // 重做 HistoryRedoCommand KeyBindingCommand = "historyRedo" // 重做
HistoryUndoSelectionCommand KeyBindingCommand = "historyUndoSelection" // 撤销选择 HistoryUndoSelectionCommand KeyBindingCommand = "historyUndoSelection" // 撤销选择
HistoryRedoSelectionCommand KeyBindingCommand = "historyRedoSelection" // 重做选择 HistoryRedoSelectionCommand KeyBindingCommand = "historyRedoSelection" // 重做选择
// 文本高亮扩展相关
TextHighlightToggleCommand KeyBindingCommand = "textHighlightToggle" // 切换文本高亮
) )
// KeyBindingMetadata 快捷键配置元数据 // KeyBindingMetadata 快捷键配置元数据
@@ -124,41 +116,6 @@ func NewDefaultKeyBindings() []KeyBinding {
Enabled: true, Enabled: true,
IsDefault: 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, Enabled: true,
IsDefault: true, IsDefault: true,
}, },
// 文本高亮扩展快捷键
{
Command: TextHighlightToggleCommand,
Extension: ExtensionTextHighlight,
Key: "Mod-Shift-h",
Enabled: true,
IsDefault: true,
},
} }
} }

View File

@@ -121,6 +121,12 @@ func (es *ExtensionService) initDatabase() error {
es.logger.Error("Failed to insert default extensions", "error", err) es.logger.Error("Failed to insert default extensions", "error", err)
return 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 return nil
@@ -153,6 +159,80 @@ func (es *ExtensionService) insertDefaultExtensions() error {
return nil 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 启动时调用 // ServiceStartup 启动时调用
func (es *ExtensionService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { func (es *ExtensionService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
es.ctx = ctx es.ctx = ctx