3 Commits

Author SHA1 Message Date
a9c81c878e 🚚 2025-12-08 23:28:36 +08:00
3660d13d7d ♻️ Refactor search 2025-12-08 23:20:37 +08:00
281f53c049 Optimized markdown preview performance 2025-12-07 00:09:52 +08:00
70 changed files with 2366 additions and 6021 deletions

View File

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

View File

@@ -70,6 +70,25 @@
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
--cm-table-border: rgba(75, 85, 99, 0.35);
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
/* Search Panel - Dark Theme */
--search-panel-bg: #252526;
--search-panel-text: #cccccc;
--search-panel-border: #454545;
--search-input-bg: #3c3c3c;
--search-input-text: #cccccc;
--search-input-border: #3c3c3c;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(255, 255, 255, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.4);
--search-btn-active-text: #ffffff;
--search-error-border: #f14c4c;
--search-error-bg: #5a1d1d;
/* Search Match Highlight - Dark Theme (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(81, 175, 255, 0.5);
--search-match-selected-border: #74b0f4;
}
/* 亮色主题 */
@@ -119,7 +138,7 @@
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
/* Markdown 代码块样式 - 亮色主题 */
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
--cm-codeblock-bg: #f3f3f3;
--cm-codeblock-radius: 0.4rem;
/* Markdown 内联代码样式 */
@@ -137,6 +156,25 @@
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
/* Search Panel - Light Theme */
--search-panel-bg: #f3f3f3;
--search-panel-text: #616161;
--search-panel-border: #c8c8c8;
--search-input-bg: #ffffff;
--search-input-text: #616161;
--search-input-border: #cecece;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(0, 0, 0, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
--search-btn-active-text: #0078d4;
--search-error-border: #e51400;
--search-error-bg: #fdeceb;
/* Search Match Highlight - Light Theme (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
--search-match-selected-border: #268fff;
}
/* 跟随系统的浅色偏好 */
@@ -205,5 +243,24 @@
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
/* Search Panel - Light Theme (auto) */
--search-panel-bg: #f3f3f3;
--search-panel-text: #616161;
--search-panel-border: #c8c8c8;
--search-input-bg: #ffffff;
--search-input-text: #616161;
--search-input-border: #cecece;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(0, 0, 0, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
--search-btn-active-text: #0078d4;
--search-error-border: #e51400;
--search-error-bg: #fdeceb;
/* Search Match Highlight - Light Theme auto (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
--search-match-selected-border: #268fff;
}
}

View File

@@ -111,7 +111,6 @@ export default {
deleteCharForward: 'Delete character forward',
deleteGroupBackward: 'Delete group backward',
deleteGroupForward: 'Delete group forward',
textHighlightToggle: 'Toggle text highlight',
}
},
tabs: {
@@ -257,7 +256,7 @@ export default {
},
colorSelector: {
name: 'Color Selector',
description: 'Visual color picker and color value display'
description: 'CSS code block visual color picker and color value display'
},
translator: {
name: 'Text Translator',
@@ -275,19 +274,29 @@ export default {
name: 'Code Folding',
description: 'Collapse and expand code sections for better readability'
},
textHighlight: {
name: 'Text Highlight',
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)',
backgroundColor: 'Background Color',
opacity: 'Opacity'
},
checkbox: {
name: 'Checkbox',
description: 'Render [x] and [ ] as interactive checkboxes'
markdown: {
name: 'Markdown Renderer',
description: 'Render Markdown elements, "what you see is what you get"'
},
codeblock: {
name: 'Code Block',
description: 'Code block related functionality'
},
lineNumbers: {
name: 'Line Numbers',
description: 'Display line numbers on the left side of the editor and highlight the current line'
},
contextMenu: {
name: 'Context Menu',
description: 'Show context menu when right-clicking in the editor'
},
highlightWhitespace: {
name: 'Highlight Whitespace',
description: 'Display whitespace characters such as spaces and tabs in the editor'
},
highlightTrailingWhitespace: {
name: 'Highlight Trailing Whitespace',
description: 'Highlight trailing whitespace at the end of lines'
}
},
monitor: {

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import Toolbar from '@/components/toolbar/Toolbar.vue';
import {useWindowStore} from '@/stores/windowStore';
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
import {useTabStore} from '@/stores/tabStore';
import ContextMenu from './contextMenu/ContextMenu.vue';
import {contextMenuManager} from './contextMenu/manager';
import ContextMenu from '@/views/editor/extensions/contextMenu/ContextMenu.vue';
import {contextMenuManager} from '@/views/editor/extensions/contextMenu/manager';
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
import {translatorManager} from './extensions/translator/manager';

View File

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

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 {
const {
@@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
// 核心状态管理
blockState,
// 块内行号
blockLineNumbers,
// 语言解析支持
...getCodeBlockLanguageExtension(),

View File

@@ -13,6 +13,7 @@ import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
import {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -118,7 +119,7 @@ export const LANGUAGES: LanguageInfo[] = [
}),
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({

View File

@@ -1,7 +1,7 @@
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
import { copyCommand, cutCommand, pasteCommand } from '../codeblock/copyPaste';
import { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { undo, redo } from '@codemirror/commands';
import i18n from '@/i18n';

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
import type { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
export interface MenuContext {
view: EditorView;

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,14 +3,106 @@ import {
EditorView,
Decoration,
DecorationSet,
MatchDecorator,
WidgetType,
ViewUpdate,
} from '@codemirror/view';
import { Extension, Range } from '@codemirror/state';
import { Extension, ChangeSet } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import * as runtime from "@wailsio/runtime";
const pathStr = `<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
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
/**
* Check if a position is inside a markdown link syntax node.
* This prevents hyperlink decorations from conflicting with markdown rendering.
*/
function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
const tree = syntaxTree(view.state);
let inLink = false;
tree.iterate({
from,
to,
enter: (node) => {
if (MARKDOWN_LINK_PARENTS.has(node.name)) {
inLink = true;
return false; // Stop iteration
}
}
});
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 {
at: number;
@@ -44,86 +136,80 @@ class HyperLinkIcon extends WidgetType {
}
}
function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) {
const widgets: Array<Range<Decoration>> = [];
const doc = view.state.doc.toString();
let match;
while ((match = defaultRegexp.exec(doc)) !== null) {
const from = match.index;
const to = from + match[0].length;
const linkMark = Decoration.mark({
/**
* Build decorations from extracted link info.
*/
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
const decorations: ReturnType<Decoration['range']>[] = [];
for (const link of links) {
// Add text decoration
decorations.push(Decoration.mark({
class: 'cm-hyper-link-text'
});
widgets.push(linkMark.range(from, to));
}).range(link.from, link.to));
const widget = Decoration.widget({
// Add icon widget
decorations.push(Decoration.widget({
widget: new HyperLinkIcon({
at: to,
url: match[0],
at: link.to,
url: link.url,
anchor,
}),
side: 1,
});
widgets.push(widget.range(to));
}).range(link.to));
}
return Decoration.set(widgets);
return Decoration.set(decorations, true);
}
const linkDecorator = (
regexp?: RegExp,
matchData?: Record<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) => {
const url = match[0];
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
if (matchData && matchData[url]) {
urlStr = matchData[url];
}
const start = to,
end = to;
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
add(from, to, Decoration.mark({
class: 'cm-hyper-link-text cm-hyper-link-underline'
}));
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
},
});
export type HyperLinkExtensionOptions = {
regexp?: RegExp;
match?: Record<string, string>;
handle?: (value: string, input: string, from: number, to: number) => string;
/** Custom anchor element transformer */
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
showIcon?: boolean;
};
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
/**
* Optimized hyperlink extension with visible-range-only scanning.
*
* Performance optimizations:
* 1. Only scans visible ranges (not the entire document)
* 2. Incremental updates: maps positions when changes don't affect links
* 3. Caches link info to avoid redundant re-extraction
*/
export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) {
return ViewPlugin.fromClass(
class HyperLinkView {
decorator?: MatchDecorator;
decorations: DecorationSet;
links: HyperLinkInfo[] = [];
constructor(view: EditorView) {
if (regexp) {
this.decorator = linkDecorator(regexp, match, handle, anchor);
this.decorations = this.decorator.createDeco(view);
} else {
this.decorations = hyperLinkDecorations(view, anchor);
}
this.links = extractVisibleLinks(view);
this.decorations = buildDecorations(this.links, anchor);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
if (regexp && this.decorator) {
this.decorations = this.decorator.updateDeco(update, this.decorations);
// Always rebuild on viewport change (new content visible)
if (update.viewportChanged) {
this.links = extractVisibleLinks(update.view);
this.decorations = buildDecorations(this.links, anchor);
return;
}
// For document changes, check if they affect link regions
if (update.docChanged) {
const needsRebuild = changesAffectLinks(update.changes, this.links);
if (needsRebuild) {
// Changes affect links, full rebuild
this.links = extractVisibleLinks(update.view);
this.decorations = buildDecorations(this.links, anchor);
} else {
this.decorations = hyperLinkDecorations(update.view, anchor);
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.links = this.links.map(link => ({
...link,
from: update.changes.mapPos(link.from),
to: update.changes.mapPos(link.to)
}));
}
}
}
@@ -171,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({
'.cm-hyper-link-icon svg': {
display: 'block',
width: '14px',
height: '14px',
width: 'inherit',
height: 'inherit',
},
'.cm-editor.cm-focused .cm-hyper-link-text': {

View File

@@ -1,45 +1,19 @@
import { Extension } from '@codemirror/state';
import { blockquote } from './plugins/blockquote';
import { codeblock } from './plugins/code-block';
import { headings } from './plugins/heading';
import { hideMarks } from './plugins/hide-mark';
import { image } from './plugins/image';
import { links } from './plugins/link';
import { lists } from './plugins/list';
import { headingSlugField } from './state/heading-slug';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight';
import { insert } from './plugins/insert';
import { math } from './plugins/math';
import { footnote } from './plugins/footnote';
import table from "./plugins/table";
import {htmlBlockExtension} from "./plugins/html";
import {html} from './plugins/html';
import { render } from './plugins/render';
import { Theme } from './plugins/theme';
/**
* markdown extensions
* Markdown extensions.
*/
export const markdownExtensions: Extension = [
headingSlugField,
blockquote(),
codeblock(),
headings(),
hideMarks(),
lists(),
links(),
render(),
Theme,
image(),
emoji(),
horizontalRule(),
inlineCode(),
subscriptSuperscript(),
highlight(),
insert(),
math(),
footnote(),
table(),
htmlBlockExtension
html()
];
export default markdownExtensions;

View File

@@ -1,173 +1,56 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/**
* Blockquote handler and theme.
*/
/** Pre-computed line decoration */
const LINE_DECO = Decoration.line({ class: 'cm-blockquote' });
import { Decoration, EditorView } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
/**
* Blockquote plugin.
*
* Features:
* - Decorates blockquote with left border
* - Hides quote marks (>) when cursor is outside
* - Supports nested blockquotes
* Handle Blockquote node.
*/
export function blockquote() {
return [blockQuotePlugin, baseTheme];
}
export function handleBlockquote(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): boolean {
if (ctx.seen.has(nf)) return false;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return false;
/**
* Collect blockquote ranges in visible viewport.
*/
function collectBlockquoteRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
if (node.type.name !== 'Blockquote') return;
if (seen.has(node.from)) return;
seen.add(node.from);
ranges.push([node.from, node.to]);
return false; // Don't recurse into nested
}
});
}
return ranges;
}
/**
* Get cursor's blockquote position (-1 if not in any).
*/
function getCursorBlockquotePos(view: EditorView, ranges: RangeTuple[]): number {
const sel = view.state.selection.main;
const selRange: RangeTuple = [sel.from, sel.to];
for (const range of ranges) {
if (checkRangeOverlap(selRange, range)) {
return range[0];
}
}
return -1;
}
/**
* Build blockquote decorations for visible viewport.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { pos: number; endPos?: number; deco: Decoration }[] = [];
const processedLines = new Set<number>();
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
if (node.type.name !== 'Blockquote') return;
if (seen.has(node.from)) return;
seen.add(node.from);
const inBlock = checkRangeOverlap(
[node.from, node.to],
[view.state.selection.main.from, view.state.selection.main.to]
);
if (inBlock) return false;
// Line decorations
const startLine = view.state.doc.lineAt(node.from).number;
const endLine = view.state.doc.lineAt(node.to).number;
for (let i = startLine; i <= endLine; i++) {
if (!processedLines.has(i)) {
processedLines.add(i);
const line = view.state.doc.line(i);
items.push({ pos: line.from, deco: LINE_DECO });
}
}
// Hide quote marks
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'QuoteMark') {
items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration });
}
});
return false;
}
});
}
// Sort and build
items.sort((a, b) => a.pos - b.pos);
for (const item of items) {
if (item.endPos !== undefined) {
builder.add(item.pos, item.endPos, item.deco);
} else {
builder.add(item.pos, item.pos, item.deco);
const startLine = ctx.view.state.doc.lineAt(nf).number;
const endLine = ctx.view.state.doc.lineAt(nt).number;
for (let i = startLine; i <= endLine; i++) {
if (!ctx.processedLines.has(i)) {
ctx.processedLines.add(i);
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
}
}
return builder.finish();
// Use TreeCursor to traverse all descendant QuoteMarks
// getChildren() only returns direct children, but QuoteMarks may be nested
// deeper in the syntax tree (e.g., in nested blockquotes for empty lines)
// cursor.next() is the official Lezer API for depth-first tree traversal
const cursor = node.cursor();
while (cursor.next() && cursor.to <= nt) {
if (cursor.name === 'QuoteMark') {
ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration });
}
}
return false;
}
/**
* Blockquote plugin with optimized updates.
* Theme for blockquotes.
*/
class BlockQuotePlugin {
decorations: DecorationSet;
private blockRanges: RangeTuple[] = [];
private cursorBlockPos = -1;
constructor(view: EditorView) {
this.blockRanges = collectBlockquoteRanges(view);
this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.blockRanges = collectBlockquoteRanges(update.view);
this.cursorBlockPos = getCursorBlockquotePos(update.view, this.blockRanges);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const newPos = getCursorBlockquotePos(update.view, this.blockRanges);
if (newPos !== this.cursorBlockPos) {
this.cursorBlockPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for blockquotes.
*/
const baseTheme = EditorView.baseTheme({
export const blockquoteTheme = EditorView.baseTheme({
'.cm-blockquote': {
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
color: 'var(--cm-blockquote-color, #666)'

View File

@@ -1,331 +1,107 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/**
* Code block handler and theme.
*/
/** Code block node types in syntax tree */
const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']);
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' });
const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
/** Copy button icon SVGs */
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
/** Pre-computed line decoration classes */
const LINE_DECO_NORMAL = Decoration.line({ class: 'cm-codeblock' });
const LINE_DECO_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
const LINE_DECO_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
const LINE_DECO_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
/** Code block metadata for widget */
interface CodeBlockMeta {
from: number;
to: number;
language: string | null;
}
/**
* Code block extension with language label and copy button.
*
* Features:
* - Adds background styling to code blocks
* - Shows language label + copy button when language is specified
* - Hides markers when cursor is outside block
* - Optimized with viewport-only rendering and minimal rebuilds
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
/**
* Widget for displaying language label and copy button.
* Content is computed lazily on copy action.
*/
class CodeBlockInfoWidget extends WidgetType {
constructor(readonly meta: CodeBlockMeta) {
super();
}
eq(other: CodeBlockInfoWidget): boolean {
return other.meta.from === this.meta.from &&
other.meta.language === this.meta.language;
}
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('span');
container.className = 'cm-code-block-info';
if (this.meta.language) {
if (this.language) {
const lang = document.createElement('span');
lang.className = 'cm-code-block-lang';
lang.textContent = this.meta.language;
lang.textContent = this.language;
container.append(lang);
}
const btn = document.createElement('button');
btn.className = 'cm-code-block-copy-btn';
btn.title = 'Copy';
btn.innerHTML = ICON_COPY;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.copyContent(view, btn);
const text = view.state.doc.sliceString(this.from, this.to);
const lines = text.split('\n');
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
if (content) {
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
});
}
});
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
container.append(btn);
return container;
}
/** Lazy content extraction and copy */
private copyContent(view: EditorView, btn: HTMLButtonElement): void {
const { from, to } = this.meta;
const text = view.state.doc.sliceString(from, to);
const lines = text.split('\n');
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
if (!content) return;
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => {
btn.innerHTML = ICON_COPY;
}, 1500);
});
}
ignoreEvent(): boolean {
return true;
}
}
/** Parsed code block info from single tree traversal */
interface ParsedBlock {
from: number;
to: number;
language: string | null;
marks: RangeTuple[]; // CodeMark and CodeInfo positions to hide
ignoreEvent() { return true; }
}
/**
* Parse a code block node in a single traversal.
* Extracts language and mark positions together.
* Handle FencedCode / CodeBlock node.
*/
function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock {
let language: string | null = null;
const marks: RangeTuple[] = [];
export function handleCodeBlock(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
node.toTree().iterate({
enter: ({ type, from, to }) => {
const absFrom = nodeFrom + from;
const absTo = nodeFrom + to;
if (type.name === 'CodeInfo') {
language = view.state.doc.sliceString(absFrom, absTo).trim();
marks.push([absFrom, absTo]);
} else if (type.name === 'CodeMark') {
marks.push([absFrom, absTo]);
}
}
});
return { from: nodeFrom, to: nodeTo, language, marks };
}
/**
* Find which code block the cursor is in (returns block start position, or -1 if not in any).
*/
function getCursorBlockPosition(view: EditorView, blocks: RangeTuple[]): number {
const { ranges } = view.state.selection;
for (const sel of ranges) {
const selRange: RangeTuple = [sel.from, sel.to];
for (const block of blocks) {
if (checkRangeOverlap(selRange, block)) {
return block[0]; // Return the block's start position as identifier
}
}
const startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
for (let num = startLine.number; num <= endLine.number; num++) {
const line = ctx.view.state.doc.line(num);
let deco = DECO_CODEBLOCK_LINE;
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
ctx.items.push({ from: line.from, to: line.from, deco });
}
return -1;
}
/**
* Collect all code block ranges in visible viewport.
*/
function collectCodeBlockRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (!CODE_BLOCK_TYPES.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Build decorations for visible code blocks.
* Uses RangeSetBuilder for efficient sorted construction.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { pos: number; endPos?: number; deco: Decoration; isWidget?: boolean; isReplace?: boolean }[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!CODE_BLOCK_TYPES.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Check if cursor is in this block
const inBlock = checkRangeOverlap(
[nodeFrom, nodeTo],
[view.state.selection.main.from, view.state.selection.main.to]
);
if (inBlock) return;
// Parse block in single traversal
const block = parseCodeBlock(view, nodeFrom, nodeTo, node);
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
// Add line decorations
for (let num = startLine.number; num <= endLine.number; num++) {
const line = view.state.doc.line(num);
let deco: Decoration;
if (startLine.number === endLine.number) {
deco = LINE_DECO_SINGLE;
} else if (num === startLine.number) {
deco = LINE_DECO_BEGIN;
} else if (num === endLine.number) {
deco = LINE_DECO_END;
} else {
deco = LINE_DECO_NORMAL;
}
items.push({ pos: line.from, deco });
}
// Add info widget
const meta: CodeBlockMeta = {
from: nodeFrom,
to: nodeTo,
language: block.language
};
items.push({
pos: startLine.to,
deco: Decoration.widget({
widget: new CodeBlockInfoWidget(meta),
side: 1
}),
isWidget: true
});
// Hide marks
for (const [mFrom, mTo] of block.marks) {
items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true });
}
}
});
}
// Sort by position and add to builder
items.sort((a, b) => {
if (a.pos !== b.pos) return a.pos - b.pos;
// Widgets should come after line decorations at same position
return (a.isWidget ? 1 : 0) - (b.isWidget ? 1 : 0);
});
for (const item of items) {
if (item.isReplace && item.endPos !== undefined) {
builder.add(item.pos, item.endPos, item.deco);
} else {
builder.add(item.pos, item.pos, item.deco);
}
}
return builder.finish();
}
/**
* Code block plugin with optimized update detection.
*/
class CodeBlockPluginClass {
decorations: DecorationSet;
private blockRanges: RangeTuple[] = [];
private cursorBlockPos = -1; // Which block the cursor is in (-1 = none)
constructor(view: EditorView) {
this.blockRanges = collectCodeBlockRanges(view);
this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
const { docChanged, viewportChanged, selectionSet } = update;
// Always rebuild on doc or viewport change
if (docChanged || viewportChanged) {
this.blockRanges = collectCodeBlockRanges(update.view);
this.cursorBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
this.decorations = buildDecorations(update.view);
return;
}
// For selection changes, only rebuild if cursor moves to a different block
if (selectionSet) {
const newBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
if (newBlockPos !== this.cursorBlockPos) {
this.cursorBlockPos = newBlockPos;
this.decorations = buildDecorations(update.view);
}
}
if (!inCursor) {
const codeInfo = node.getChild('CodeInfo');
const codeMarks = node.getChildren('CodeMark');
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
}
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
decorations: (v) => v.decorations
});
/**
* Base theme for code blocks.
* Theme for code blocks.
*/
const baseTheme = EditorView.baseTheme({
export const codeBlockTheme = EditorView.baseTheme({
'.cm-codeblock': {
backgroundColor: 'var(--cm-codeblock-bg)',
fontFamily: 'inherit',
fontFamily: 'inherit'
},
'.cm-codeblock-begin': {
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative',
position: 'relative'
},
'.cm-codeblock-end': {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
},
'.cm-code-block-info': {
position: 'absolute',
@@ -339,9 +115,7 @@ const baseTheme = EditorView.baseTheme({
opacity: '0.5',
transition: 'opacity 0.15s'
},
'.cm-code-block-info:hover': {
opacity: '1'
},
'.cm-code-block-info:hover': { opacity: '1' },
'.cm-code-block-lang': {
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
textTransform: 'lowercase',
@@ -360,12 +134,6 @@ const baseTheme = EditorView.baseTheme({
opacity: '0.7',
transition: 'opacity 0.15s, background 0.15s'
},
'.cm-code-block-copy-btn:hover': {
opacity: '1',
background: 'rgba(128, 128, 128, 0.2)'
},
'.cm-code-block-copy-btn svg': {
width: '1em',
height: '1em'
}
'.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' },
'.cm-code-block-copy-btn svg': { width: '1em', height: '1em' }
});

View File

@@ -1,44 +1,16 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
/**
* Emoji handler and theme.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import { emojies } from '@/common/constant/emojies';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
*
* Features:
* - Detects emoji patterns like :smile:, :heart:, etc.
* - Replaces them with actual emoji characters
* - Shows the original text when cursor is nearby
* - Optimized with cached matches and minimal rebuilds
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
/**
* Emoji widget with optimized rendering.
*/
class EmojiWidget extends WidgetType {
constructor(
readonly emoji: string,
readonly name: string
) {
super();
}
eq(other: EmojiWidget): boolean {
return other.emoji === this.emoji;
}
constructor(readonly emoji: string, readonly name: string) { super(); }
eq(other: EmojiWidget) { return other.emoji === this.emoji; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-emoji';
@@ -49,148 +21,37 @@ class EmojiWidget extends WidgetType {
}
/**
* Cached emoji match.
* Handle Emoji node (:emoji:).
*/
interface EmojiMatch {
from: number;
to: number;
name: string;
emoji: string;
}
export function handleEmoji(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
/**
* Find all emoji matches in visible ranges.
*/
function findAllEmojiMatches(view: EditorView): EmojiMatch[] {
const matches: EmojiMatch[] = [];
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
const text = doc.sliceString(from, to);
let match: RegExpExecArray | null;
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1].toLowerCase();
const emojiChar = emojies[name];
if (emojiChar) {
matches.push({
from: from + match.index,
to: from + match.index + match[0].length,
name,
emoji: emojiChar
});
}
}
}
return matches;
}
/**
* Get which emoji the cursor is in (-1 if none).
*/
function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (let i = 0; i < matches.length; i++) {
if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) {
return i;
}
}
return -1;
}
/**
* Build decorations from cached matches.
*/
function buildDecorations(matches: EmojiMatch[], selFrom: number, selTo: number): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const selRange: RangeTuple = [selFrom, selTo];
for (const match of matches) {
// Skip if cursor overlaps this emoji
if (checkRangeOverlap([match.from, match.to], selRange)) {
continue;
}
builder.add(
match.from,
match.to,
Decoration.replace({
widget: new EmojiWidget(match.emoji, match.name)
})
);
}
return builder.finish();
}
/**
* Emoji plugin with cached matches and optimized updates.
*/
class EmojiPlugin {
decorations: DecorationSet;
private matches: EmojiMatch[] = [];
private cursorEmojiIdx = -1;
constructor(view: EditorView) {
this.matches = findAllEmojiMatches(view);
const { from, to } = view.state.selection.main;
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
this.decorations = buildDecorations(this.matches, from, to);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
// Rebuild matches on doc or viewport change
if (docChanged || viewportChanged) {
this.matches = findAllEmojiMatches(update.view);
const { from, to } = update.state.selection.main;
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
this.decorations = buildDecorations(this.matches, from, to);
return;
}
// For selection changes, only rebuild if cursor enters/leaves an emoji
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newIdx = getCursorEmojiIndex(this.matches, from, to);
if (newIdx !== this.cursorEmojiIdx) {
this.cursorEmojiIdx = newIdx;
this.decorations = buildDecorations(this.matches, from, to);
}
}
const nameNode = node.getChild('EmojiName');
if (!nameNode) return;
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
const emojiChar = emojies[name];
if (emojiChar) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
}
}
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for emoji.
* Theme for emoji.
*/
const baseTheme = EditorView.baseTheme({
export const emojiTheme = EditorView.baseTheme({
'.cm-emoji': {
verticalAlign: 'middle',
cursor: 'default'
cursor: 'default',
fontSize: 'inherit',
lineHeight: 'inherit'
}
});
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Object.keys(emojies);
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return emojies[name.toLowerCase()];
}

View File

@@ -1,621 +1,152 @@
/**
* Footnote plugin for CodeMirror.
*
* Features:
* - Renders footnote references as superscript numbers/labels
* - Renders inline footnotes as superscript numbers with embedded content
* - Shows footnote content on hover (tooltip)
* - Click to jump between reference and definition
* - Hides syntax marks when cursor is outside
* Footnote handlers and theme.
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
*/
import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType,
hoverTooltip,
Tooltip,
} from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
// ============================================================================
// Types
// ============================================================================
interface FootnoteDefinition {
id: string;
content: string;
from: number;
to: number;
/** Extended context for footnotes */
export interface FootnoteContext extends BuildContext {
definitionIds: Set<string>;
pendingRefs: { from: number; to: number; id: string; index: number }[];
pendingInlines: { from: number; to: number; index: number }[];
seenIds: Map<string, number>;
inlineFootnoteIdx: number;
}
interface FootnoteReference {
id: string;
from: number;
to: number;
index: number;
}
interface InlineFootnoteInfo {
content: string;
from: number;
to: number;
index: number;
}
/**
* Collected footnote data with O(1) lookup indexes.
*/
interface FootnoteData {
definitions: Map<string, FootnoteDefinition>;
references: FootnoteReference[];
inlineFootnotes: InlineFootnoteInfo[];
referencesByPos: Map<number, FootnoteReference>;
inlineByPos: Map<number, InlineFootnoteInfo>;
definitionByPos: Map<number, FootnoteDefinition>; // For position-based lookup
firstRefById: Map<string, FootnoteReference>;
// All footnote ranges for cursor detection
allRanges: RangeTuple[];
}
// ============================================================================
// Footnote Collection (cached via closure)
// ============================================================================
let cachedData: FootnoteData | null = null;
let cachedDocLength = -1;
/**
* Collect all footnote data from the document.
*/
function collectFootnotes(state: EditorState): FootnoteData {
// Simple cache invalidation based on doc length
if (cachedData && cachedDocLength === state.doc.length) {
return cachedData;
}
const definitions = new Map<string, FootnoteDefinition>();
const references: FootnoteReference[] = [];
const inlineFootnotes: InlineFootnoteInfo[] = [];
const referencesByPos = new Map<number, FootnoteReference>();
const inlineByPos = new Map<number, InlineFootnoteInfo>();
const definitionByPos = new Map<number, FootnoteDefinition>();
const firstRefById = new Map<string, FootnoteReference>();
const allRanges: RangeTuple[] = [];
const seenIds = new Map<string, number>();
let inlineIndex = 0;
syntaxTree(state).iterate({
enter: ({ type, from, to, node }) => {
if (type.name === 'FootnoteDefinition') {
const labelNode = node.getChild('FootnoteDefinitionLabel');
const contentNode = node.getChild('FootnoteDefinitionContent');
if (labelNode) {
const id = state.sliceDoc(labelNode.from, labelNode.to);
const content = contentNode
? state.sliceDoc(contentNode.from, contentNode.to).trim()
: '';
const def: FootnoteDefinition = { id, content, from, to };
definitions.set(id, def);
definitionByPos.set(from, def);
allRanges.push([from, to]);
}
} else if (type.name === 'FootnoteReference') {
const labelNode = node.getChild('FootnoteReferenceLabel');
if (labelNode) {
const id = state.sliceDoc(labelNode.from, labelNode.to);
if (!seenIds.has(id)) {
seenIds.set(id, seenIds.size + 1);
}
const ref: FootnoteReference = {
id,
from,
to,
index: seenIds.get(id)!,
};
references.push(ref);
referencesByPos.set(from, ref);
allRanges.push([from, to]);
if (!firstRefById.has(id)) {
firstRefById.set(id, ref);
}
}
} else if (type.name === 'InlineFootnote') {
const contentNode = node.getChild('InlineFootnoteContent');
if (contentNode) {
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
inlineIndex++;
const info: InlineFootnoteInfo = {
content,
from,
to,
index: inlineIndex,
};
inlineFootnotes.push(info);
inlineByPos.set(from, info);
allRanges.push([from, to]);
}
}
},
});
cachedData = {
definitions,
references,
inlineFootnotes,
referencesByPos,
inlineByPos,
definitionByPos,
firstRefById,
allRanges,
};
cachedDocLength = state.doc.length;
return cachedData;
}
// ============================================================================
// Widgets
// ============================================================================
class FootnoteRefWidget extends WidgetType {
constructor(
readonly id: string,
readonly index: number,
readonly hasDefinition: boolean
) {
super();
}
constructor(readonly index: number, readonly hasDefinition: boolean) { super(); }
eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-ref';
span.textContent = `[${this.index}]`;
span.dataset.footnoteId = this.id;
if (!this.hasDefinition) {
span.classList.add('cm-footnote-ref-undefined');
}
if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined');
return span;
}
eq(other: FootnoteRefWidget): boolean {
return this.id === other.id && this.index === other.index;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { return false; }
}
class InlineFootnoteWidget extends WidgetType {
constructor(
readonly content: string,
readonly index: number
) {
super();
}
constructor(readonly index: number) { super(); }
eq(other: InlineFootnoteWidget) { return this.index === other.index; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-footnote-ref';
span.textContent = `[${this.index}]`;
span.dataset.footnoteContent = this.content;
span.dataset.footnoteIndex = String(this.index);
return span;
}
eq(other: InlineFootnoteWidget): boolean {
return this.content === other.content && this.index === other.index;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { return false; }
}
class FootnoteDefLabelWidget extends WidgetType {
constructor(readonly id: string) {
super();
}
constructor(readonly id: string) { super(); }
eq(other: FootnoteDefLabelWidget) { return this.id === other.id; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-footnote-def-label';
span.textContent = `[${this.id}]`;
span.dataset.footnoteId = this.id;
return span;
}
eq(other: FootnoteDefLabelWidget): boolean {
return this.id === other.id;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { return false; }
}
// ============================================================================
// Cursor Detection
// ============================================================================
/**
* Get which footnote range the cursor is in (returns start position, -1 if none).
* Handle FootnoteDefinition node.
*/
function getCursorFootnotePos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
export function handleFootnoteDefinition(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
// ============================================================================
// Decorations
// ============================================================================
const marks = node.getChildren('FootnoteDefinitionMark');
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (marks.length >= 2 && labelNode) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
ctx.definitionIds.add(id);
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
}
}
/**
* Build decorations using RangeSetBuilder.
* Handle FootnoteReference node.
*/
function buildDecorations(view: EditorView, data: FootnoteData): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { pos: number; endPos?: number; deco: Decoration; priority?: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
export function handleFootnoteReference(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange);
// Footnote References
if (type.name === 'FootnoteReference') {
const labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (!labelNode || marks.length < 2) return;
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
const ref = data.referencesByPos.get(nodeFrom);
if (!inCursor && ref && ref.id === id) {
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
items.push({
pos: nodeTo,
deco: Decoration.widget({
widget: new FootnoteRefWidget(id, ref.index, data.definitions.has(id)),
side: 1,
}),
priority: 1
});
}
}
// Footnote Definitions
if (type.name === 'FootnoteDefinition') {
const marks = node.getChildren('FootnoteDefinitionMark');
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (!inCursor && marks.length >= 2 && labelNode) {
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
items.push({ pos: marks[0].from, endPos: marks[1].to, deco: invisibleDecoration });
items.push({
pos: marks[1].to,
deco: Decoration.widget({
widget: new FootnoteDefLabelWidget(id),
side: 1,
}),
priority: 1
});
}
}
// Inline Footnotes
if (type.name === 'InlineFootnote') {
const contentNode = node.getChild('InlineFootnoteContent');
const marks = node.getChildren('InlineFootnoteMark');
if (!contentNode || marks.length < 2) return;
const inlineNote = data.inlineByPos.get(nodeFrom);
if (!inCursor && inlineNote) {
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
items.push({
pos: nodeTo,
deco: Decoration.widget({
widget: new InlineFootnoteWidget(inlineNote.content, inlineNote.index),
side: 1,
}),
priority: 1
});
}
}
},
});
}
// Sort by position, widgets after replace at same position
items.sort((a, b) => {
if (a.pos !== b.pos) return a.pos - b.pos;
return (a.priority || 0) - (b.priority || 0);
});
for (const item of items) {
if (item.endPos !== undefined) {
builder.add(item.pos, item.endPos, item.deco);
} else {
builder.add(item.pos, item.pos, item.deco);
}
}
return builder.finish();
}
// ============================================================================
// Plugin
// ============================================================================
class FootnotePlugin {
decorations: DecorationSet;
private data: FootnoteData;
private cursorFootnotePos = -1;
constructor(view: EditorView) {
this.data = collectFootnotes(view.state);
const { from, to } = view.state.selection.main;
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
this.decorations = buildDecorations(view, this.data);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged) {
// Invalidate cache on doc change
cachedData = null;
this.data = collectFootnotes(update.state);
const { from, to } = update.state.selection.main;
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
this.decorations = buildDecorations(update.view, this.data);
return;
}
if (viewportChanged) {
this.decorations = buildDecorations(update.view, this.data);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorFootnotePos(this.data.allRanges, from, to);
if (newPos !== this.cursorFootnotePos) {
this.cursorFootnotePos = newPos;
this.decorations = buildDecorations(update.view, this.data);
}
}
const labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (labelNode && marks.length >= 2) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
}
}
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
decorations: (v) => v.decorations,
});
/**
* Handle InlineFootnote node.
*/
export function handleInlineFootnote(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
// ============================================================================
// Hover Tooltip
// ============================================================================
const footnoteHoverTooltip = hoverTooltip(
(view, pos): Tooltip | null => {
const data = collectFootnotes(view.state);
// Check widget elements first
const coords = view.coordsAtPos(pos);
if (coords) {
const target = document.elementFromPoint(coords.left, coords.top) as HTMLElement | null;
if (target?.classList.contains('cm-footnote-ref')) {
const id = target.dataset.footnoteId;
if (id) {
const def = data.definitions.get(id);
if (def) {
return {
pos,
above: true,
arrow: true,
create: () => createTooltipDom(id, def.content),
};
}
}
}
if (target?.classList.contains('cm-inline-footnote-ref')) {
const content = target.dataset.footnoteContent;
const index = target.dataset.footnoteIndex;
if (content && index) {
return {
pos,
above: true,
arrow: true,
create: () => createInlineTooltipDom(parseInt(index), content),
};
}
}
}
// Check by position using indexed data
const ref = data.referencesByPos.get(pos);
if (ref) {
const def = data.definitions.get(ref.id);
if (def) {
return {
pos: ref.to,
above: true,
arrow: true,
create: () => createTooltipDom(ref.id, def.content),
};
}
}
const inline = data.inlineByPos.get(pos);
if (inline) {
return {
pos: inline.to,
above: true,
arrow: true,
create: () => createInlineTooltipDom(inline.index, inline.content),
};
}
// Fallback: check if pos is within any footnote range
for (const ref of data.references) {
if (pos >= ref.from && pos <= ref.to) {
const def = data.definitions.get(ref.id);
if (def) {
return {
pos: ref.to,
above: true,
arrow: true,
create: () => createTooltipDom(ref.id, def.content),
};
}
}
}
for (const inline of data.inlineFootnotes) {
if (pos >= inline.from && pos <= inline.to) {
return {
pos: inline.to,
above: true,
arrow: true,
create: () => createInlineTooltipDom(inline.index, inline.content),
};
}
}
return null;
},
{ hoverTime: 300 }
);
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
const dom = document.createElement('div');
dom.className = 'cm-footnote-tooltip';
const header = document.createElement('div');
header.className = 'cm-footnote-tooltip-header';
header.textContent = `[^${id}]`;
const body = document.createElement('div');
body.className = 'cm-footnote-tooltip-body';
body.textContent = content || '(Empty footnote)';
dom.appendChild(header);
dom.appendChild(body);
return { dom };
const contentNode = node.getChild('InlineFootnoteContent');
const marks = node.getChildren('InlineFootnoteMark');
if (contentNode && marks.length >= 2) {
ctx.inlineFootnoteIdx++;
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
}
}
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
const dom = document.createElement('div');
dom.className = 'cm-footnote-tooltip';
const header = document.createElement('div');
header.className = 'cm-footnote-tooltip-header';
header.textContent = `Inline Footnote [${index}]`;
const body = document.createElement('div');
body.className = 'cm-footnote-tooltip-body';
body.textContent = content || '(Empty footnote)';
dom.appendChild(header);
dom.appendChild(body);
return { dom };
/**
* Process pending footnote refs after all definitions are collected.
*/
export function processPendingFootnotes(ctx: FootnoteContext): void {
for (const ref of ctx.pendingRefs) {
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
}
for (const inline of ctx.pendingInlines) {
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
}
}
// ============================================================================
// Click Handler
// ============================================================================
const footnoteClickHandler = EditorView.domEventHandlers({
mousedown(event, view) {
const target = event.target as HTMLElement;
// Click on footnote reference → jump to definition
if (target.classList.contains('cm-footnote-ref')) {
const id = target.dataset.footnoteId;
if (id) {
const data = collectFootnotes(view.state);
const def = data.definitions.get(id);
if (def) {
event.preventDefault();
setTimeout(() => {
view.dispatch({
selection: { anchor: def.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
}
}
// Click on definition label → jump to first reference
if (target.classList.contains('cm-footnote-def-label')) {
const id = target.dataset.footnoteId;
if (id) {
const data = collectFootnotes(view.state);
const firstRef = data.firstRefById.get(id);
if (firstRef) {
event.preventDefault();
setTimeout(() => {
view.dispatch({
selection: { anchor: firstRef.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
}
}
return false;
},
});
// ============================================================================
// Theme
// ============================================================================
const baseTheme = EditorView.baseTheme({
/**
* Theme for footnotes.
*/
export const footnoteTheme = EditorView.baseTheme({
'.cm-footnote-ref': {
display: 'inline-flex',
alignItems: 'center',
@@ -630,20 +161,12 @@ const baseTheme = EditorView.baseTheme({
verticalAlign: 'super',
color: 'var(--cm-footnote-color, #1a73e8)',
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s ease',
textDecoration: 'none',
},
'.cm-footnote-ref:hover': {
color: 'var(--cm-footnote-hover-color, #1557b0)',
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
borderRadius: '3px'
},
'.cm-footnote-ref-undefined': {
color: 'var(--cm-footnote-undefined-color, #d93025)',
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))'
},
'.cm-inline-footnote-ref': {
display: 'inline-flex',
alignItems: 'center',
@@ -658,79 +181,10 @@ const baseTheme = EditorView.baseTheme({
verticalAlign: 'super',
color: 'var(--cm-inline-footnote-color, #e67e22)',
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s ease',
textDecoration: 'none',
borderRadius: '3px'
},
'.cm-inline-footnote-ref:hover': {
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
},
'.cm-footnote-def-label': {
color: 'var(--cm-footnote-def-color, #1a73e8)',
fontWeight: '600',
cursor: 'pointer',
},
'.cm-footnote-def-label:hover': {
textDecoration: 'underline',
},
'.cm-footnote-tooltip': {
maxWidth: '400px',
padding: '0',
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
},
'.cm-footnote-tooltip-header': {
padding: '6px 12px',
fontSize: '0.8em',
fontWeight: '600',
fontFamily: 'monospace',
color: 'var(--cm-footnote-color, #1a73e8)',
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
borderBottom: '1px solid var(--border-color)',
},
'.cm-footnote-tooltip-body': {
padding: '10px 12px',
fontSize: '0.9em',
lineHeight: '1.5',
color: 'var(--text-primary)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
'.cm-tooltip:has(.cm-footnote-tooltip)': {
animation: 'cm-footnote-fade-in 0.15s ease-out',
},
'@keyframes cm-footnote-fade-in': {
from: { opacity: '0', transform: 'translateY(4px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
fontWeight: '600'
}
});
// ============================================================================
// Export
// ============================================================================
/**
* Footnote extension.
*/
export const footnote = (): Extension => [
footnotePlugin,
footnoteHoverTooltip,
footnoteClickHandler,
baseTheme,
];
export default footnote;
/**
* Get footnote data for external use.
*/
export function getFootnoteData(state: EditorState): FootnoteData {
return collectFootnotes(state);
}

View File

@@ -1,168 +1,63 @@
import { syntaxTree } from '@codemirror/language';
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
/**
* Heading handler and theme.
*/
/** Hidden mark decoration */
const hiddenMarkDecoration = Decoration.mark({
class: 'cm-heading-mark-hidden'
});
import { Decoration, EditorView } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
/**
* Collect all heading ranges in visible viewport.
* Handle ATXHeading node (# Heading).
*/
function collectHeadingRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
export function handleATXHeading(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
if (!node.type.name.startsWith('ATXHeading') &&
!node.type.name.startsWith('SetextHeading')) {
return;
}
if (seen.has(node.from)) return;
seen.add(node.from);
ranges.push([node.from, node.to]);
}
});
const header = node.firstChild;
if (header && header.type.name === 'HeaderMark') {
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
}
return ranges;
}
/**
* Get which heading the cursor is in (-1 if none).
* Handle SetextHeading node (underline style).
*/
function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
export function handleSetextHeading(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const headerMarks = node.getChildren('HeaderMark');
for (const mark of headerMarks) {
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
}
return -1;
}
/**
* Build heading decorations using RangeSetBuilder.
* Theme for headings.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
// Skip if cursor is in this heading
if (checkRangeOverlap([node.from, node.to], selRange)) return;
// ATX headings (# Heading)
if (node.type.name.startsWith('ATXHeading')) {
if (seen.has(node.from)) return;
seen.add(node.from);
const header = node.node.firstChild;
if (header && header.type.name === 'HeaderMark') {
const markFrom = header.from;
// Include the space after #
const markTo = Math.min(header.to + 1, node.to);
items.push({ from: markFrom, to: markTo });
}
}
// Setext headings (underline style)
else if (node.type.name.startsWith('SetextHeading')) {
if (seen.has(node.from)) return;
seen.add(node.from);
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'HeaderMark') {
items.push({ from: child.from, to: child.to });
}
});
}
}
});
}
// Sort by position and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, hiddenMarkDecoration);
}
return builder.finish();
}
/**
* Heading plugin with optimized updates.
*/
class HeadingPlugin {
decorations: DecorationSet;
private headingRanges: RangeTuple[] = [];
private cursorHeadingPos = -1;
constructor(view: EditorView) {
this.headingRanges = collectHeadingRanges(view);
const { from, to } = view.state.selection.main;
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.headingRanges = collectHeadingRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorHeadingPos(this.headingRanges, from, to);
if (newPos !== this.cursorHeadingPos) {
this.cursorHeadingPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, {
decorations: (v) => v.decorations
});
/**
* Theme for hidden heading marks.
*/
const headingTheme = EditorView.baseTheme({
export const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': {
fontSize: '0'
}
});
/**
* Headings plugin.
*/
export const headings = (): Extension => [headingPlugin, headingTheme];

View File

@@ -1,167 +0,0 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { Extension, RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/**
* Node types that contain markers to hide.
* Note: InlineCode is handled by inline-code.ts
*/
const TYPES_WITH_MARKS = new Set([
'Emphasis',
'StrongEmphasis',
'Strikethrough'
]);
/**
* Marker node types to hide.
*/
const MARK_TYPES = new Set([
'EmphasisMark',
'StrikethroughMark'
]);
// Export for external use
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
export const markTypes = Array.from(MARK_TYPES);
/**
* Collect all mark ranges in visible viewport.
*/
function collectMarkRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (!TYPES_WITH_MARKS.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which mark range the cursor is in (-1 if none).
*/
function getCursorMarkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build mark hiding decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!TYPES_WITH_MARKS.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is in this range
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
// Collect mark positions
const innerTree = node.toTree();
innerTree.iterate({
enter({ type: markType, from: markFrom, to: markTo }) {
if (!MARK_TYPES.has(markType.name)) return;
items.push({
from: nodeFrom + markFrom,
to: nodeFrom + markTo
});
}
});
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, invisibleDecoration);
}
return builder.finish();
}
/**
* Hide marks plugin with optimized updates.
*
* Hides emphasis marks (*, **, ~~) when cursor is outside.
* Note: InlineCode backticks are handled by inline-code.ts
*/
class HideMarkPlugin {
decorations: DecorationSet;
private markRanges: RangeTuple[] = [];
private cursorMarkPos = -1;
constructor(view: EditorView) {
this.markRanges = collectMarkRanges(view);
const { from, to } = view.state.selection.main;
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.markRanges = collectMarkRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorMarkPos(this.markRanges, from, to);
if (newPos !== this.cursorMarkPos) {
this.cursorMarkPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
/**
* Hide marks plugin.
* Hides marks for emphasis, strong, and strikethrough.
*/
export const hideMarks = (): Extension => [
ViewPlugin.fromClass(HideMarkPlugin, {
decorations: (v) => v.decorations
})
];

View File

@@ -1,160 +0,0 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/** Mark decoration for highlighted content */
const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' });
/**
* Highlight plugin using syntax tree.
*
* Detects ==text== and renders as highlighted text.
*/
export const highlight = (): Extension => [highlightPlugin, baseTheme];
/**
* Collect all highlight ranges in visible viewport.
*/
function collectHighlightRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'Highlight') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which highlight the cursor is in (-1 if none).
*/
function getCursorHighlightPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build highlight decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; deco: Decoration }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'Highlight') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is in this highlight
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
const marks = node.getChildren('HighlightMark');
if (marks.length < 2) return;
// Hide opening ==
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
// Apply highlight style to content
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
items.push({ from: contentStart, to: contentEnd, deco: highlightMarkDecoration });
}
// Hide closing ==
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, item.deco);
}
return builder.finish();
}
/**
* Highlight plugin with optimized updates.
*/
class HighlightPlugin {
decorations: DecorationSet;
private highlightRanges: RangeTuple[] = [];
private cursorHighlightPos = -1;
constructor(view: EditorView) {
this.highlightRanges = collectHighlightRanges(view);
const { from, to } = view.state.selection.main;
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.highlightRanges = collectHighlightRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorHighlightPos(this.highlightRanges, from, to);
if (newPos !== this.cursorHighlightPos) {
this.cursorHighlightPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for highlight.
*/
const baseTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px',
}
});

View File

@@ -1,172 +1,48 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
DecorationSet,
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
import { syntaxTree } from '@codemirror/language';
/**
* Horizontal rule plugin that renders beautiful horizontal lines.
*
* Features:
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
* - Shows the original text when cursor is on the line
* - Uses inline widget to avoid affecting block system boundaries
* Horizontal rule handler and theme.
*/
export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme];
/**
* Widget to display a horizontal rule.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { RangeTuple } from '../util';
import { BuildContext } from './types';
class HorizontalRuleWidget extends WidgetType {
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-horizontal-rule-widget';
const hr = document.createElement('hr');
hr.className = 'cm-horizontal-rule';
span.appendChild(hr);
return span;
}
eq(_other: HorizontalRuleWidget) {
return true;
}
ignoreEvent(): boolean {
return false;
}
eq() { return true; }
ignoreEvent() { return false; }
}
/** Shared widget instance (all HR widgets are identical) */
const hrWidget = new HorizontalRuleWidget();
/**
* Collect all horizontal rule ranges in visible viewport.
* Handle HorizontalRule node.
*/
function collectHRRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'HorizontalRule') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
export function handleHorizontalRule(
ctx: BuildContext,
nf: number,
nt: number,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (!inCursor) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
}
return ranges;
}
/**
* Get which HR the cursor is in (-1 if none).
* Theme for horizontal rules.
*/
function getCursorHRPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build horizontal rule decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'HorizontalRule') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is on this HR
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
items.push({ from: nodeFrom, to: nodeTo });
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, Decoration.replace({ widget: hrWidget }));
}
return builder.finish();
}
/**
* Horizontal rule plugin with optimized updates.
*/
class HorizontalRulePlugin {
decorations: DecorationSet;
private hrRanges: RangeTuple[] = [];
private cursorHRPos = -1;
constructor(view: EditorView) {
this.hrRanges = collectHRRanges(view);
const { from, to } = view.state.selection.main;
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.hrRanges = collectHRRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorHRPos(this.hrRanges, from, to);
if (newPos !== this.cursorHRPos) {
this.cursorHRPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const horizontalRulePlugin = ViewPlugin.fromClass(HorizontalRulePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for horizontal rules.
*/
const baseTheme = EditorView.baseTheme({
export const horizontalRuleTheme = EditorView.baseTheme({
'.cm-horizontal-rule-widget': {
display: 'inline-block',
width: '100%',

View File

@@ -340,7 +340,7 @@ const theme = EditorView.baseTheme({
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
export const htmlBlockExtension: Extension = [
export const html = (): Extension => [
htmlBlockPlugin,
htmlTooltipState,
clickOutsideHandler,

View File

@@ -1,183 +0,0 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/** Mark decoration for code content */
const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' });
/**
* Inline code styling plugin.
*
* Features:
* - Adds background color, border radius, padding to code content
* - Hides backtick markers when cursor is outside
*/
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
/**
* Collect all inline code ranges in visible viewport.
*/
function collectCodeRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'InlineCode') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which inline code the cursor is in (-1 if none).
*/
function getCursorCodePos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build inline code decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; deco: Decoration }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'InlineCode') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip when cursor is in this code
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
// Find backtick boundaries
let codeStart = nodeFrom;
let codeEnd = nodeTo;
// Count opening backticks
let i = 0;
while (i < text.length && text[i] === '`') {
i++;
}
codeStart = nodeFrom + i;
// Count closing backticks
let j = text.length - 1;
while (j >= 0 && text[j] === '`') {
j--;
}
codeEnd = nodeFrom + j + 1;
// Hide opening backticks
if (nodeFrom < codeStart) {
items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration });
}
// Add style to code content
if (codeStart < codeEnd) {
items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration });
}
// Hide closing backticks
if (codeEnd < nodeTo) {
items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration });
}
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, item.deco);
}
return builder.finish();
}
/**
* Inline code plugin with optimized updates.
*/
class InlineCodePlugin {
decorations: DecorationSet;
private codeRanges: RangeTuple[] = [];
private cursorCodePos = -1;
constructor(view: EditorView) {
this.codeRanges = collectCodeRanges(view);
const { from, to } = view.state.selection.main;
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.codeRanges = collectCodeRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorCodePos(this.codeRanges, from, to);
if (newPos !== this.cursorCodePos) {
this.cursorCodePos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for inline code.
*/
const baseTheme = EditorView.baseTheme({
'.cm-inline-code': {
backgroundColor: 'var(--cm-inline-code-bg)',
borderRadius: '0.25rem',
padding: '0.1rem 0.3rem',
fontFamily: 'var(--voidraft-font-mono)'
}
});

View File

@@ -0,0 +1,181 @@
/**
* Inline styles handlers and theme.
* Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript
*/
import { Decoration, EditorView } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' });
const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' });
const DECO_INSERT = Decoration.mark({ class: 'cm-insert' });
const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' });
const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' });
const MARK_TYPES: Record<string, string> = {
'Emphasis': 'EmphasisMark',
'StrongEmphasis': 'EmphasisMark',
'Strikethrough': 'StrikethroughMark'
};
/**
* Handle Highlight node (==text==).
*/
export function handleHighlight(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('HighlightMark');
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Handle InlineCode node (`code`).
*/
export function handleInlineCode(
ctx: BuildContext,
nf: number,
nt: number,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const text = ctx.view.state.doc.sliceString(nf, nt);
let i = 0; while (i < text.length && text[i] === '`') i++;
let j = text.length - 1; while (j >= 0 && text[j] === '`') j--;
const codeStart = nf + i, codeEnd = nf + j + 1;
if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration });
if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE });
if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration });
}
/**
* Handle Emphasis, StrongEmphasis, Strikethrough nodes.
*/
export function handleEmphasis(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
typeName: string,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const markType = MARK_TYPES[typeName];
if (markType) {
const marks = node.getChildren(markType);
for (const mark of marks) {
ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
}
}
}
/**
* Handle Insert node (++text++).
*/
export function handleInsert(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('InsertMark');
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Handle Superscript / Subscript nodes.
*/
export function handleScript(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
typeName: string,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const isSuper = typeName === 'Superscript';
const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark';
const marks = node.getChildren(markName);
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Theme for inline styles.
*/
export const inlineStylesTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px'
},
'.cm-inline-code': {
backgroundColor: 'var(--cm-inline-code-bg)',
borderRadius: '0.25rem',
padding: '0.1rem 0.3rem',
fontFamily: 'var(--voidraft-font-mono)'
},
'.cm-insert': {
textDecoration: 'underline'
},
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.75em',
color: 'inherit'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.75em',
color: 'inherit'
}
});

View File

@@ -1,159 +0,0 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/** Mark decoration for inserted content */
const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' });
/**
* Insert plugin using syntax tree.
*
* Detects ++text++ and renders as inserted text (underline).
*/
export const insert = (): Extension => [insertPlugin, baseTheme];
/**
* Collect all insert ranges in visible viewport.
*/
function collectInsertRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'Insert') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which insert the cursor is in (-1 if none).
*/
function getCursorInsertPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build insert decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; deco: Decoration }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'Insert') return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is in this insert
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
const marks = node.getChildren('InsertMark');
if (marks.length < 2) return;
// Hide opening ++
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
// Apply insert style to content
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
items.push({ from: contentStart, to: contentEnd, deco: insertMarkDecoration });
}
// Hide closing ++
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, item.deco);
}
return builder.finish();
}
/**
* Insert plugin with optimized updates.
*/
class InsertPlugin {
decorations: DecorationSet;
private insertRanges: RangeTuple[] = [];
private cursorInsertPos = -1;
constructor(view: EditorView) {
this.insertRanges = collectInsertRanges(view);
const { from, to } = view.state.selection.main;
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.insertRanges = collectInsertRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorInsertPos(this.insertRanges, from, to);
if (newPos !== this.cursorInsertPos) {
this.cursorInsertPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const insertPlugin = ViewPlugin.fromClass(InsertPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for insert.
*/
const baseTheme = EditorView.baseTheme({
'.cm-insert': {
textDecoration: 'underline',
}
});

View File

@@ -1,202 +1,111 @@
import { syntaxTree } from '@codemirror/language';
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
/**
* Link handler with underline and clickable icon.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import * as runtime from "@wailsio/runtime";
/**
* Parent node types that should not process.
* - Image: handled by image plugin
* - LinkReference: reference link definitions should be fully visible
*/
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
/**
* Links plugin.
*
* Features:
* - Hides link markup when cursor is outside
* - Link icons and click events are handled by hyperlink extension
*/
export const links = (): Extension => [goToLinkPlugin];
/** Link text decoration with underline */
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
/**
* Link info for tracking.
*/
interface LinkInfo {
parentFrom: number;
parentTo: number;
urlFrom: number;
urlTo: number;
marks: { from: number; to: number }[];
linkTitle: { from: number; to: number } | null;
isAutoLink: boolean;
/** Link icon widget - clickable to open URL */
class LinkIconWidget extends WidgetType {
constructor(readonly url: string) { super(); }
eq(other: LinkIconWidget) { return this.url === other.url; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-md-link-icon';
span.textContent = '🔗';
span.title = this.url;
span.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
runtime.Browser.OpenURL(this.url);
};
return span;
}
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
}
/**
* Collect all link ranges in visible viewport.
* Handle URL node (within Link).
*/
function collectLinkRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
export function handleURL(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
ranges: RangeTuple[]
): void {
const parent = node.parent;
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
if (ctx.seen.has(parent.from)) return;
ctx.seen.add(parent.from);
ranges.push([parent.from, parent.to]);
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, node }) => {
if (type.name !== 'URL') return;
// Get link text node (content between first [ and ])
const linkText = parent.getChild('LinkLabel');
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
if (closeBracket && nf < closeBracket.from) return;
const parent = node.parent;
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
if (seen.has(parent.from)) return;
seen.add(parent.from);
// Get URL for the icon
const url = ctx.view.state.sliceDoc(nf, nt);
ranges.push([parent.from, parent.to]);
}
// Add underline decoration to link text
if (linkText) {
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
}
// Hide markdown syntax marks
for (const m of marks) {
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
}
// Hide URL
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
// Hide link title if present
if (linkTitle) {
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
}
// Add clickable icon widget after link text (at close bracket position)
if (closeBracket) {
ctx.items.push({
from: closeBracket.from,
to: closeBracket.from,
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
priority: 1
});
}
return ranges;
}
/**
* Get which link the cursor is in (-1 if none).
* Theme for markdown links.
*/
function getCursorLinkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
export const linkTheme = EditorView.baseTheme({
'.cm-md-link-text': {
color: 'var(--cm-link-color, #0969da)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
cursor: 'text'
},
'.cm-md-link-icon': {
cursor: 'pointer',
marginLeft: '0.2em',
opacity: '0.7',
transition: 'opacity 0.15s ease',
'&:hover': {
opacity: '1'
}
}
return -1;
}
/**
* Build link decorations.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'URL') return;
const parent = node.parent;
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
// Use parent.from as unique key to handle multiple URLs in same link
if (seen.has(parent.from)) return;
seen.add(parent.from);
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
// Find the ']' mark to distinguish link text from URL
const closeBracketMark = marks.find((mark) => {
const text = view.state.sliceDoc(mark.from, mark.to);
return text === ']';
});
// If URL is before ']', it's part of display text, don't hide
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
return;
}
// Check if cursor overlaps with the parent link
if (checkRangeOverlap([parent.from, parent.to], selRange)) {
return;
}
// Hide link marks and URL
if (marks.length > 0) {
for (const mark of marks) {
items.push({ from: mark.from, to: mark.to });
}
items.push({ from: nodeFrom, to: nodeTo });
if (linkTitle) {
items.push({ from: linkTitle.from, to: linkTitle.to });
}
}
// Handle auto-links with < > markers
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
if (linkContent.startsWith('<') && linkContent.endsWith('>')) {
// Already hidden the whole URL above, no extra handling needed
}
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
// Deduplicate overlapping ranges
let lastTo = -1;
for (const item of items) {
if (item.from >= lastTo) {
builder.add(item.from, item.to, invisibleDecoration);
lastTo = item.to;
}
}
return builder.finish();
}
/**
* Link plugin with optimized updates.
*/
class LinkPlugin {
decorations: DecorationSet;
private linkRanges: RangeTuple[] = [];
private cursorLinkPos = -1;
constructor(view: EditorView) {
this.linkRanges = collectLinkRanges(view);
const { from, to } = view.state.selection.main;
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.linkRanges = collectLinkRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorLinkPos(this.linkRanges, from, to);
if (newPos !== this.cursorLinkPos) {
this.cursorLinkPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
decorations: (v) => v.decorations
});

View File

@@ -1,40 +1,18 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Range, RangeSetBuilder, EditorState } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, RangeTuple } from '../util';
/** Bullet list marker pattern */
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
/**
* Lists plugin.
*
* Features:
* - Custom bullet mark rendering (- → •)
* - Interactive task list checkboxes
* List handlers and theme.
* Handles: ListMark (bullets), Task (checkboxes)
*/
export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme];
// ============================================================================
// List Bullet Plugin
// ============================================================================
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const BULLET_RE = /^[-+*]$/;
class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) {
super();
}
eq(other: ListBulletWidget): boolean {
return other.bullet === this.bullet;
}
constructor(readonly bullet: string) { super(); }
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-list-bullet';
@@ -43,360 +21,84 @@ class ListBulletWidget extends WidgetType {
}
}
/**
* Collect all list mark ranges in visible viewport.
*/
function collectBulletRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'ListMark') return;
// Skip task list items
const parent = node.parent;
if (parent?.getChild('Task')) return;
// Only bullet markers
const text = view.state.sliceDoc(nodeFrom, nodeTo);
if (!BULLET_LIST_MARKER_RE.test(text)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which bullet the cursor is in (-1 if none).
*/
function getCursorBulletPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build list bullet decorations.
*/
function buildBulletDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; bullet: string }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'ListMark') return;
// Skip task list items
const parent = node.parent;
if (parent?.getChild('Task')) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is in this mark
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
const bullet = view.state.sliceDoc(nodeFrom, nodeTo);
if (BULLET_LIST_MARKER_RE.test(bullet)) {
items.push({ from: nodeFrom, to: nodeTo, bullet });
}
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, Decoration.replace({
widget: new ListBulletWidget(item.bullet)
}));
}
return builder.finish();
}
/**
* List bullet plugin with optimized updates.
*/
class ListBulletPlugin {
decorations: DecorationSet;
private bulletRanges: RangeTuple[] = [];
private cursorBulletPos = -1;
constructor(view: EditorView) {
this.bulletRanges = collectBulletRanges(view);
const { from, to } = view.state.selection.main;
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
this.decorations = buildBulletDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.bulletRanges = collectBulletRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
this.decorations = buildBulletDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorBulletPos(this.bulletRanges, from, to);
if (newPos !== this.cursorBulletPos) {
this.cursorBulletPos = newPos;
this.decorations = buildBulletDecorations(update.view);
}
}
}
}
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations
});
// ============================================================================
// Task List Plugin
// ============================================================================
class TaskCheckboxWidget extends WidgetType {
constructor(
readonly checked: boolean,
readonly pos: number
) {
super();
}
eq(other: TaskCheckboxWidget): boolean {
return other.checked === this.checked && other.pos === this.pos;
}
constructor(readonly checked: boolean, readonly pos: number) { super(); }
eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; }
toDOM(view: EditorView): HTMLElement {
const wrap = document.createElement('span');
wrap.setAttribute('aria-hidden', 'true');
wrap.className = 'cm-task-checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.tabIndex = -1;
checkbox.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const newValue = !this.checked;
view.dispatch({
changes: {
from: this.pos,
to: this.pos + 1,
insert: newValue ? 'x' : ' '
}
});
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
});
wrap.appendChild(checkbox);
return wrap;
}
ignoreEvent() { return false; }
}
ignoreEvent(): boolean {
return false;
/**
* Handle ListMark node (bullet markers).
*/
export function handleListMark(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
const parent = node.parent;
if (parent?.getChild('Task')) return;
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const bullet = ctx.view.state.sliceDoc(nf, nt);
if (BULLET_RE.test(bullet)) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) });
}
}
/**
* Collect all task ranges in visible viewport.
* Handle Task node (checkboxes).
*/
function collectTaskRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
export function handleTask(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
ranges: RangeTuple[]
): void {
const listItem = node.parent;
if (!listItem || listItem.type.name !== 'ListItem') return;
const listMark = listItem.getChild('ListMark');
const taskMarker = node.getChild('TaskMarker');
if (!listMark || !taskMarker) return;
if (ctx.seen.has(listMark.from)) return;
ctx.seen.add(listMark.from);
ranges.push([listMark.from, taskMarker.to]);
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'Task') return;
const listItem = node.parent;
if (!listItem || listItem.type.name !== 'ListItem') return;
const listMark = listItem.getChild('ListMark');
if (!listMark) return;
if (seen.has(listMark.from)) return;
seen.add(listMark.from);
// Track the full range from ListMark to TaskMarker
const taskMarker = node.getChild('TaskMarker');
if (taskMarker) {
ranges.push([listMark.from, taskMarker.to]);
}
}
});
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
if (isChecked) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
}
return ranges;
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
}
/**
* Get which task the cursor is in (-1 if none).
* Theme for lists.
*/
function getCursorTaskPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build task list decorations.
*/
function buildTaskDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; deco: Decoration; priority: number }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: taskFrom, to: taskTo, node }) => {
if (type.name !== 'Task') return;
const listItem = node.parent;
if (!listItem || listItem.type.name !== 'ListItem') return;
const listMark = listItem.getChild('ListMark');
const taskMarker = node.getChild('TaskMarker');
if (!listMark || !taskMarker) return;
if (seen.has(listMark.from)) return;
seen.add(listMark.from);
const replaceFrom = listMark.from;
const replaceTo = taskMarker.to;
// Skip if cursor is in this range
if (checkRangeOverlap([replaceFrom, replaceTo], selRange)) return;
// Check if task is checked
const markerText = view.state.sliceDoc(taskMarker.from, taskMarker.to);
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
const checkboxPos = taskMarker.from + 1;
// Add strikethrough for checked items
if (isChecked) {
items.push({
from: taskFrom,
to: taskTo,
deco: Decoration.mark({ class: 'cm-task-checked' }),
priority: 0
});
}
// Replace "- [x]" or "- [ ]" with checkbox widget
items.push({
from: replaceFrom,
to: replaceTo,
deco: Decoration.replace({
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
}),
priority: 1
});
}
});
}
// Sort by position, then priority
items.sort((a, b) => {
if (a.from !== b.from) return a.from - b.from;
return a.priority - b.priority;
});
for (const item of items) {
builder.add(item.from, item.to, item.deco);
}
return builder.finish();
}
/**
* Task list plugin with optimized updates.
*/
class TaskListPlugin {
decorations: DecorationSet;
private taskRanges: RangeTuple[] = [];
private cursorTaskPos = -1;
constructor(view: EditorView) {
this.taskRanges = collectTaskRanges(view);
const { from, to } = view.state.selection.main;
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
this.decorations = buildTaskDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.taskRanges = collectTaskRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
this.decorations = buildTaskDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorTaskPos(this.taskRanges, from, to);
if (newPos !== this.cursorTaskPos) {
this.cursorTaskPos = newPos;
this.decorations = buildTaskDecorations(update.view);
}
}
}
}
const taskListPlugin = ViewPlugin.fromClass(TaskListPlugin, {
decorations: (v) => v.decorations
});
// ============================================================================
// Theme
// ============================================================================
const baseTheme = EditorView.baseTheme({
export const listTheme = EditorView.baseTheme({
'.cm-list-bullet': {
color: 'var(--cm-list-bullet-color, inherit)'
},

View File

@@ -1,359 +1,125 @@
/**
* Math plugin for CodeMirror using KaTeX.
*
* Features:
* - Renders inline math $...$ as inline formula
* - Renders block math $$...$$ as block formula
* - Block math: lines remain, content hidden, formula overlays on top
* - Shows source when cursor is inside
* Math handlers and theme.
* Handles: InlineMath, BlockMath
*/
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { isCursorInRange, invisibleDecoration } from '../util';
import { LruCache } from '@/common/utils/lruCache';
interface KatexCacheValue {
html: string;
error: string | null;
}
/**
* LRU cache for KaTeX rendering results.
* Key format: "inline:latex" or "block:latex"
*/
const katexCache = new LruCache<string, KatexCacheValue>(200);
/**
* Get cached KaTeX render result or render and cache it.
*/
function renderKatex(latex: string, displayMode: boolean): KatexCacheValue {
const cacheKey = `${displayMode ? 'block' : 'inline'}:${latex}`;
// Check cache first
const cached = katexCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
// Render and cache
let result: KatexCacheValue;
try {
const html = katex.renderToString(latex, {
throwOnError: !displayMode, // inline throws, block doesn't
displayMode,
output: 'html'
});
result = { html, error: null };
} catch (e) {
result = {
html: '',
error: e instanceof Error ? e.message : 'Render error'
};
}
katexCache.set(cacheKey, result);
return result;
}
/**
* Widget to display inline math formula.
* Uses cached KaTeX rendering for performance.
*/
class InlineMathWidget extends WidgetType {
constructor(readonly latex: string) {
super();
}
constructor(readonly latex: string) { super(); }
eq(other: InlineMathWidget) { return this.latex === other.latex; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-math';
// Use cached render
const { html, error } = renderKatex(this.latex, false);
if (error) {
try {
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
} catch (e) {
span.textContent = this.latex;
span.title = error;
} else {
span.innerHTML = html;
span.title = e instanceof Error ? e.message : 'Render error';
}
return span;
}
eq(other: InlineMathWidget): boolean {
return this.latex === other.latex;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { return false; }
}
/**
* Widget to display block math formula.
* Uses absolute positioning to overlay on source lines.
*/
class BlockMathWidget extends WidgetType {
constructor(
readonly latex: string,
readonly lineCount: number = 1,
readonly lineHeight: number = 22
) {
super();
}
constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-block-math-container';
// Set height to cover all source lines
const height = this.lineCount * this.lineHeight;
container.style.height = `${height}px`;
container.style.height = `${this.lineCount * this.lineHeight}px`;
const inner = document.createElement('div');
inner.className = 'cm-block-math';
// Use cached render
const { html, error } = renderKatex(this.latex, true);
if (error) {
try {
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
} catch (e) {
inner.textContent = this.latex;
inner.title = error;
} else {
inner.innerHTML = html;
inner.title = e instanceof Error ? e.message : 'Render error';
}
container.appendChild(inner);
return container;
}
ignoreEvent() { return false; }
}
eq(other: BlockMathWidget): boolean {
return this.latex === other.latex && this.lineCount === other.lineCount;
}
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
ignoreEvent(): boolean {
return false;
/**
* Handle InlineMath node ($...$).
*/
export function handleInlineMath(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('InlineMathMark');
if (marks.length >= 2) {
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
}
}
/**
* Represents a math region in the document.
* Handle BlockMath node ($$...$$).
*/
interface MathRegion {
from: number;
to: number;
}
export function handleBlockMath(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
/**
* Result of building decorations, includes math regions for cursor tracking.
*/
interface BuildResult {
decorations: DecorationSet;
mathRegions: MathRegion[];
}
/**
* Find the math region containing the given position.
* Returns the region index or -1 if not in any region.
*/
function findMathRegionIndex(pos: number, regions: MathRegion[]): number {
for (let i = 0; i < regions.length; i++) {
if (pos >= regions[i].from && pos <= regions[i].to) {
return i;
}
}
return -1;
}
/**
* Build decorations for math formulas.
* Also collects math regions for cursor tracking optimization.
*/
function buildDecorations(view: EditorView): BuildResult {
const decorations: Range<Decoration>[] = [];
const mathRegions: MathRegion[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle inline math
if (type.name === 'InlineMath') {
// Collect math region for cursor tracking
mathRegions.push({ from: nodeFrom, to: nodeTo });
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('InlineMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
// Hide the entire syntax
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
decorations.push(
Decoration.widget({
widget: new InlineMathWidget(latex),
side: 1
}).range(nodeTo)
);
}
}
// Handle block math ($$...$$)
if (type.name === 'BlockMath') {
// Collect math region for cursor tracking
mathRegions.push({ from: nodeFrom, to: nodeTo });
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('BlockMathMark');
if (!cursorInRange && marks.length >= 2) {
// Get latex content (without $$ marks)
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
// Calculate line info
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
const lineCount = endLine.number - startLine.number + 1;
const lineHeight = view.defaultLineHeight;
// Check if block math spans multiple lines
const hasLineBreak = lineCount > 1;
if (hasLineBreak) {
// For multi-line: use line decorations to hide content
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
decorations.push(
Decoration.line({
class: 'cm-block-math-line'
}).range(line.from)
);
}
// Add widget on the first line (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, lineCount, lineHeight),
side: -1
}).range(startLine.from)
);
} else {
// Single line: make content transparent, overlay widget
decorations.push(
Decoration.mark({
class: 'cm-block-math-content-hidden'
}).range(nodeFrom, nodeTo)
);
// Add widget at the start (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new BlockMathWidget(latex, 1, lineHeight),
side: -1
}).range(nodeFrom)
);
}
}
}
}
});
}
return {
decorations: Decoration.set(decorations, true),
mathRegions
};
}
/**
* Math plugin with optimized update detection.
*/
class MathPlugin {
decorations: DecorationSet;
private mathRegions: MathRegion[] = [];
private lastSelectionHead: number = -1;
private lastMathRegionIndex: number = -1;
constructor(view: EditorView) {
const result = buildDecorations(view);
this.decorations = result.decorations;
this.mathRegions = result.mathRegions;
this.lastSelectionHead = view.state.selection.main.head;
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
}
update(update: ViewUpdate) {
// Always rebuild on document change or viewport change
if (update.docChanged || update.viewportChanged) {
const result = buildDecorations(update.view);
this.decorations = result.decorations;
this.mathRegions = result.mathRegions;
this.lastSelectionHead = update.state.selection.main.head;
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
return;
}
// For selection changes, only rebuild if cursor changes math region context
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
const newRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
// Only rebuild if:
// 1. Cursor entered a math region (was outside, now inside)
// 2. Cursor left a math region (was inside, now outside)
// 3. Cursor moved to a different math region
if (newRegionIndex !== this.lastMathRegionIndex) {
const result = buildDecorations(update.view);
this.decorations = result.decorations;
this.mathRegions = result.mathRegions;
this.lastMathRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
}
this.lastSelectionHead = newHead;
const marks = node.getChildren('BlockMathMark');
if (marks.length >= 2) {
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
const startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
const lineCount = endLine.number - startLine.number + 1;
if (lineCount > 1) {
for (let num = startLine.number; num <= endLine.number; num++) {
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
}
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
} else {
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
}
}
}
const mathPlugin = ViewPlugin.fromClass(
MathPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for math.
* Theme for math.
*/
const baseTheme = EditorView.baseTheme({
// Inline math
export const mathTheme = EditorView.baseTheme({
'.cm-inline-math': {
display: 'inline',
verticalAlign: 'baseline',
verticalAlign: 'baseline'
},
'.cm-inline-math .katex': {
fontSize: 'inherit',
fontSize: 'inherit'
},
// Block math container - absolute positioned to overlay on source
'.cm-block-math-container': {
position: 'absolute',
left: '0',
@@ -362,61 +128,36 @@ const baseTheme = EditorView.baseTheme({
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
zIndex: '1',
zIndex: '1'
},
// Block math inner
'.cm-block-math': {
display: 'inline-block',
textAlign: 'center',
pointerEvents: 'auto',
pointerEvents: 'auto'
},
'.cm-block-math .katex-display': {
margin: '0',
margin: '0'
},
'.cm-block-math .katex': {
fontSize: '1.1em',
fontSize: '1.1em'
},
// Hidden line content for block math (text transparent but line preserved)
// Use high specificity to override rainbow brackets and other plugins
'.cm-line.cm-block-math-line': {
color: 'transparent !important',
caretColor: 'transparent',
caretColor: 'transparent'
},
'.cm-line.cm-block-math-line span': {
color: 'transparent !important',
color: 'transparent !important'
},
// Override rainbow brackets in hidden math lines
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
color: 'transparent !important'
},
// Hidden content for single-line block math
'.cm-block-math-content-hidden': {
color: 'transparent !important',
color: 'transparent !important'
},
'.cm-block-math-content-hidden span': {
color: 'transparent !important',
color: 'transparent !important'
},
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
},
color: 'transparent !important'
}
});
/**
* Math extension.
*
* Features:
* - Parses inline math $...$ and block math $$...$$
* - Renders formulas using KaTeX
* - Block math preserves line structure, overlays rendered formula
* - Shows source when cursor is inside
*/
export const math = (): Extension => [
mathPlugin,
baseTheme
];
export default math;

View File

@@ -0,0 +1,253 @@
import { Extension } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { DecoItem } from './types';
import { blockState } from '@/views/editor/extensions/codeblock/state';
import { Block } from '@/views/editor/extensions/codeblock/types';
import { handleBlockquote } from './blockquote';
import { handleCodeBlock } from './code-block';
import { handleATXHeading, handleSetextHeading } from './heading';
import { handleHorizontalRule } from './horizontal-rule';
import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles';
import { handleURL } from './link';
import { handleListMark, handleTask } from './list';
import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote';
import { handleInlineMath, handleBlockMath } from './math';
import { handleEmoji } from './emoji';
import { handleTable } from './table';
interface BuildResult {
decorations: DecorationSet;
trackedRanges: RangeTuple[];
}
/**
* Get markdown block ranges from visible ranges.
* Only returns ranges that are within 'md' language blocks.
*/
function getMdBlockRanges(view: EditorView): { from: number; to: number }[] {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) {
// No blocks, treat entire document as md
return view.visibleRanges.map(r => ({ from: r.from, to: r.to }));
}
// Filter md blocks
const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md');
if (mdBlocks.length === 0) return [];
// Intersect visible ranges with md block content ranges
const result: { from: number; to: number }[] = [];
for (const { from, to } of view.visibleRanges) {
for (const block of mdBlocks) {
const intersectFrom = Math.max(from, block.content.from);
const intersectTo = Math.min(to, block.content.to);
if (intersectFrom < intersectTo) {
result.push({ from: intersectFrom, to: intersectTo });
}
}
}
return result;
}
function buildDecorationsAndRanges(view: EditorView): BuildResult {
const { from: selFrom, to: selTo } = view.state.selection.main;
// Create context with footnote extensions
const ctx: FootnoteContext = {
view,
items: [],
selRange: [selFrom, selTo],
seen: new Set(),
processedLines: new Set(),
contentWidth: view.contentDOM.clientWidth - 10,
lineHeight: view.defaultLineHeight,
// Footnote state
definitionIds: new Set(),
pendingRefs: [],
pendingInlines: [],
seenIds: new Map(),
inlineFootnoteIdx: 0
};
const trackedRanges: RangeTuple[] = [];
// Only traverse md blocks (not other language blocks like js, py, etc.)
const mdRanges = getMdBlockRanges(view);
// Single traversal - dispatch to all handlers
for (const { from, to } of mdRanges) {
syntaxTree(view.state).iterate({
from, to,
enter: (nodeRef: SyntaxNodeRef) => {
const { type, from: nf, to: nt, node } = nodeRef;
const typeName = type.name;
const inCursor = checkRangeOverlap([nf, nt], ctx.selRange);
// Dispatch to handlers
if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges);
if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges);
if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges);
if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges);
if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges);
}
});
}
// Process pending footnotes
processPendingFootnotes(ctx);
// Sort and filter
ctx.items.sort((a, b) => {
if (a.from !== b.from) return a.from - b.from;
if (a.to !== b.to) return a.to - b.to;
return (a.priority || 0) - (b.priority || 0);
});
const result: DecoItem[] = [];
let replaceMaxTo = -1;
for (const item of ctx.items) {
const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration;
if (item.from === item.to) {
result.push(item);
} else if (isReplace) {
if (item.from >= replaceMaxTo) {
result.push(item);
replaceMaxTo = item.to;
}
} else {
result.push(item);
}
}
return {
decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true),
trackedRanges
};
}
class MarkdownRenderPlugin {
decorations: DecorationSet;
private trackedRanges: RangeTuple[] = [];
private lastSelFrom = -1;
private lastSelTo = -1;
private lastWidth = 0;
constructor(view: EditorView) {
const result = buildDecorationsAndRanges(view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
const { from, to } = view.state.selection.main;
this.lastSelFrom = from;
this.lastSelTo = to;
this.lastWidth = view.contentDOM.clientWidth;
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet, geometryChanged } = update;
const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1;
if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth;
// Full rebuild for structural changes
if (docChanged || viewportChanged || geometryChanged || widthChanged) {
const result = buildDecorationsAndRanges(update.view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
const { from, to } = update.state.selection.main;
this.lastSelFrom = from;
this.lastSelTo = to;
return;
}
// Selection change handling with fine-grained detection
if (selectionSet) {
const { from, to } = update.state.selection.main;
const isPointCursor = from === to;
const wasPointCursor = this.lastSelFrom === this.lastSelTo;
// Optimization: Point cursor moving within same tracked range - no rebuild needed
if (isPointCursor && wasPointCursor) {
const oldRange = this.findContainingRange(this.lastSelFrom);
const newRange = this.findContainingRange(from);
if (this.rangeSame(oldRange, newRange)) {
this.lastSelFrom = from;
this.lastSelTo = to;
return;
}
}
// Check if overlapping ranges changed
const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo);
const newOverlaps = this.getOverlappingRanges(from, to);
this.lastSelFrom = from;
this.lastSelTo = to;
if (!this.rangesSame(oldOverlaps, newOverlaps)) {
const result = buildDecorationsAndRanges(update.view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
}
}
}
private findContainingRange(pos: number): RangeTuple | null {
for (const range of this.trackedRanges) {
if (pos >= range[0] && pos <= range[1]) return range;
}
return null;
}
private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return a[0] === b[0] && a[1] === b[1];
}
private getOverlappingRanges(from: number, to: number): RangeTuple[] {
const selRange: RangeTuple = [from, to];
return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange));
}
private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
}
return true;
}
}
const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, {
decorations: (v) => v.decorations
});
export const render = (): Extension => [renderPlugin];

View File

@@ -1,184 +0,0 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
/** Pre-computed mark decorations */
const superscriptMarkDecoration = Decoration.mark({ class: 'cm-superscript' });
const subscriptMarkDecoration = Decoration.mark({ class: 'cm-subscript' });
/**
* Subscript and Superscript plugin using syntax tree.
*
* - Superscript: ^text^ → renders as superscript
* - Subscript: ~text~ → renders as subscript
*
* Note: Inline footnotes ^[content] are handled by the Footnote extension.
*/
export const subscriptSuperscript = (): Extension => [
subscriptSuperscriptPlugin,
baseTheme
];
/** Node types to handle */
const SCRIPT_TYPES = new Set(['Superscript', 'Subscript']);
/**
* Collect all superscript/subscript ranges in visible viewport.
*/
function collectScriptRanges(view: EditorView): RangeTuple[] {
const ranges: RangeTuple[] = [];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (!SCRIPT_TYPES.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
ranges.push([nodeFrom, nodeTo]);
}
});
}
return ranges;
}
/**
* Get which script element the cursor is in (-1 if none).
*/
function getCursorScriptPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (const range of ranges) {
if (checkRangeOverlap(range, selRange)) {
return range[0];
}
}
return -1;
}
/**
* Build decorations for subscript and superscript.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const items: { from: number; to: number; deco: Decoration }[] = [];
const { from: selFrom, to: selTo } = view.state.selection.main;
const selRange: RangeTuple = [selFrom, selTo];
const seen = new Set<number>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!SCRIPT_TYPES.has(type.name)) return;
if (seen.has(nodeFrom)) return;
seen.add(nodeFrom);
// Skip if cursor is in this element
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
const isSuperscript = type.name === 'Superscript';
const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark';
const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration;
const marks = node.getChildren(markName);
if (marks.length < 2) return;
// Hide opening mark
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
// Apply style to content
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
items.push({ from: contentStart, to: contentEnd, deco: contentDeco });
}
// Hide closing mark
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
});
}
// Sort and add to builder
items.sort((a, b) => a.from - b.from);
for (const item of items) {
builder.add(item.from, item.to, item.deco);
}
return builder.finish();
}
/**
* Subscript/Superscript plugin with optimized updates.
*/
class SubscriptSuperscriptPlugin {
decorations: DecorationSet;
private scriptRanges: RangeTuple[] = [];
private cursorScriptPos = -1;
constructor(view: EditorView) {
this.scriptRanges = collectScriptRanges(view);
const { from, to } = view.state.selection.main;
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
if (docChanged || viewportChanged) {
this.scriptRanges = collectScriptRanges(update.view);
const { from, to } = update.state.selection.main;
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
this.decorations = buildDecorations(update.view);
return;
}
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newPos = getCursorScriptPos(this.scriptRanges, from, to);
if (newPos !== this.cursorScriptPos) {
this.cursorScriptPos = newPos;
this.decorations = buildDecorations(update.view);
}
}
}
}
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
SubscriptSuperscriptPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for subscript and superscript.
*/
const baseTheme = EditorView.baseTheme({
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.75em',
color: 'var(--cm-superscript-color, inherit)'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.75em',
color: 'var(--cm-subscript-color, inherit)'
}
});

View File

@@ -1,262 +1,19 @@
/**
* Table plugin for CodeMirror.
*
* Features:
* - Renders markdown tables as beautiful HTML tables
* - Lines remain, content hidden, table overlays on top (same as math.ts)
* - Shows source when cursor is inside
* - Supports alignment (left, center, right)
*
* Table syntax tree structure from @lezer/markdown:
* - Table (root)
* - TableHeader (first row)
* - TableDelimiter (|)
* - TableCell (content)
* - TableDelimiter (separator row |---|---|)
* - TableRow (data rows)
* - TableCell (content)
* Table handler and theme.
*/
import { Extension, Range } from '@codemirror/state';
import { syntaxTree, foldedRanges } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { foldedRanges } from '@codemirror/language';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { isCursorInRange } from '../util';
import { LruCache } from '@/common/utils/lruCache';
import { generateContentHash } from '@/common/utils/hashUtils';
import { BuildContext } from './types';
import DOMPurify from 'dompurify';
// ============================================================================
// Types and Interfaces
// ============================================================================
/** Cell alignment type */
type CellAlign = 'left' | 'center' | 'right';
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
/** Parsed table data */
interface TableData {
headers: string[];
alignments: CellAlign[];
rows: string[][];
}
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
/** Table range info for tracking */
interface TableRange {
from: number;
to: number;
}
// ============================================================================
// Cache using LruCache from utils
// ============================================================================
/** LRU cache for parsed table data - keyed by position for fast lookup */
const tableCacheByPos = new LruCache<string, { hash: string; data: TableData }>(50);
/** LRU cache for inline markdown rendering */
const inlineRenderCache = new LruCache<string, string>(200);
/**
* Get or parse table data with two-level caching.
* First checks position, then verifies content hash only if position matches.
* This avoids expensive hash computation on cache miss.
*/
function getCachedTableData(
state: import('@codemirror/state').EditorState,
tableNode: SyntaxNode
): TableData | null {
const posKey = `${tableNode.from}-${tableNode.to}`;
// First level: check if we have data for this position
const cached = tableCacheByPos.get(posKey);
if (cached) {
// Second level: verify content hash matches (lazy hash computation)
const content = state.sliceDoc(tableNode.from, tableNode.to);
const contentHash = generateContentHash(content);
if (cached.hash === contentHash) {
return cached.data;
}
}
// Cache miss - parse and cache
const content = state.sliceDoc(tableNode.from, tableNode.to);
const data = parseTableData(state, tableNode);
if (data) {
tableCacheByPos.set(posKey, {
hash: generateContentHash(content),
data
});
}
return data;
}
// ============================================================================
// Parsing Functions (Optimized)
// ============================================================================
/**
* Parse alignment from delimiter row.
* Optimized: early returns, minimal string operations.
*/
function parseAlignment(delimiterText: string): CellAlign {
const len = delimiterText.length;
if (len === 0) return 'left';
// Find first and last non-space characters
let start = 0;
let end = len - 1;
while (start < len && delimiterText.charCodeAt(start) === 32) start++;
while (end > start && delimiterText.charCodeAt(end) === 32) end--;
if (start > end) return 'left';
const hasLeftColon = delimiterText.charCodeAt(start) === 58; // ':'
const hasRightColon = delimiterText.charCodeAt(end) === 58;
if (hasLeftColon && hasRightColon) return 'center';
if (hasRightColon) return 'right';
return 'left';
}
/**
* Parse a row text into cells by splitting on |
* Optimized: single-pass parsing without multiple string operations.
*/
function parseRowText(rowText: string): string[] {
const cells: string[] = [];
const len = rowText.length;
let start = 0;
let end = len;
// Skip leading whitespace
while (start < len && rowText.charCodeAt(start) <= 32) start++;
// Skip trailing whitespace
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
// Skip leading |
if (start < end && rowText.charCodeAt(start) === 124) start++;
// Skip trailing |
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
// Parse cells in single pass
let cellStart = start;
for (let i = start; i <= end; i++) {
if (i === end || rowText.charCodeAt(i) === 124) {
// Extract and trim cell
let cs = cellStart;
let ce = i;
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
cells.push(rowText.substring(cs, ce));
cellStart = i + 1;
}
}
return cells;
}
/**
* Parse table data from syntax tree node.
*
* Table syntax tree structure from @lezer/markdown:
* - Table (root)
* - TableHeader (contains TableCell children)
* - TableDelimiter (the |---|---| line)
* - TableRow (contains TableCell children)
*/
function parseTableData(state: import('@codemirror/state').EditorState, tableNode: SyntaxNode): TableData | null {
const headers: string[] = [];
const alignments: CellAlign[] = [];
const rows: string[][] = [];
// Get TableHeader
const headerNode = tableNode.getChild('TableHeader');
if (!headerNode) return null;
// Get TableCell children from header
const headerCells = headerNode.getChildren('TableCell');
if (headerCells.length > 0) {
// Parse from TableCell nodes
for (const cell of headerCells) {
const text = state.sliceDoc(cell.from, cell.to).trim();
headers.push(text);
}
} else {
// Fallback: parse the entire header row text
const headerText = state.sliceDoc(headerNode.from, headerNode.to);
const parsedHeaders = parseRowText(headerText);
headers.push(...parsedHeaders);
}
if (headers.length === 0) return null;
// Find delimiter row to get alignments
// The delimiter is a direct child of Table
let child = tableNode.firstChild;
while (child) {
if (child.type.name === 'TableDelimiter') {
const delimText = state.sliceDoc(child.from, child.to);
// Check if this contains --- (alignment row)
if (delimText.includes('-')) {
const parts = parseRowText(delimText);
for (const part of parts) {
if (part.includes('-')) {
alignments.push(parseAlignment(part));
}
}
break;
}
}
child = child.nextSibling;
}
// Fill missing alignments with 'left'
while (alignments.length < headers.length) {
alignments.push('left');
}
// Parse data rows
const rowNodes = tableNode.getChildren('TableRow');
for (const rowNode of rowNodes) {
const rowData: string[] = [];
const cells = rowNode.getChildren('TableCell');
if (cells.length > 0) {
// Parse from TableCell nodes
for (const cell of cells) {
const text = state.sliceDoc(cell.from, cell.to).trim();
rowData.push(text);
}
} else {
// Fallback: parse the entire row text
const rowText = state.sliceDoc(rowNode.from, rowNode.to);
const parsedCells = parseRowText(rowText);
rowData.push(...parsedCells);
}
// Fill missing cells with empty string
while (rowData.length < headers.length) {
rowData.push('');
}
rows.push(rowData);
}
return { headers, alignments, rows };
}
// Pre-compiled regex patterns for better performance
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
const BOLD_UNDER_RE = /__(.+?)__/g;
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
@@ -264,426 +21,166 @@ const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
const CODE_RE = /`([^`]+)`/g;
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
const STRIKE_RE = /~~(.+?)~~/g;
// Regex to detect HTML tags (opening, closing, or self-closing)
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
/**
* Sanitize HTML content with DOMPurify.
*/
function sanitizeHTML(html: string): string {
return DOMPurify.sanitize(html, {
ADD_TAGS: ['code', 'strong', 'em', 'del', 'a', 'img', 'br', 'span'],
ADD_ATTR: ['href', 'target', 'src', 'alt', 'class', 'style'],
ALLOW_DATA_ATTR: true
});
}
/**
* Convert inline markdown syntax to HTML.
* Handles: **bold**, *italic*, `code`, [link](url), ~~strikethrough~~, and HTML tags
* Optimized with pre-compiled regex and LRU caching.
*/
function renderInlineMarkdown(text: string): string {
// Check cache first
const cached = inlineRenderCache.get(text);
if (cached !== undefined) return cached;
let html = text;
// Check if text contains HTML tags
const hasHTMLTags = HTML_TAG_RE.test(text);
if (hasHTMLTags) {
// If contains HTML tags, process markdown first without escaping < >
// Bold: **text** or __text__
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
// Italic: *text* or _text_ (but not inside words for _)
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
// Inline code: `code` - but don't double-process if already has <code>
if (!html.includes('<code>')) {
html = html.replace(CODE_RE, '<code>$1</code>');
}
// Links: [text](url)
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
// Strikethrough: ~~text~~
html = html.replace(STRIKE_RE, '<del>$1</del>');
// Sanitize HTML for security
html = sanitizeHTML(html);
if (HTML_TAG_RE.test(text)) {
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
if (!html.includes('<code>')) html = html.replace(CODE_RE, '<code>$1</code>');
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
} else {
// No HTML tags - escape < > and process markdown
html = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Bold: **text** or __text__
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
// Italic: *text* or _text_ (but not inside words for _)
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
// Inline code: `code`
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
html = html.replace(CODE_RE, '<code>$1</code>');
// Links: [text](url)
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
// Strikethrough: ~~text~~
html = html.replace(STRIKE_RE, '<del>$1</del>');
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
}
// Cache result using LRU cache
inlineRenderCache.set(text, html);
return html;
}
function parseRowText(rowText: string): string[] {
const cells: string[] = [];
let start = 0, end = rowText.length;
while (start < end && rowText.charCodeAt(start) <= 32) start++;
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
if (start < end && rowText.charCodeAt(start) === 124) start++;
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
let cellStart = start;
for (let i = start; i <= end; i++) {
if (i === end || rowText.charCodeAt(i) === 124) {
let cs = cellStart, ce = i;
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
cells.push(rowText.substring(cs, ce));
cellStart = i + 1;
}
}
return cells;
}
function parseAlignment(text: string): CellAlign {
const len = text.length;
if (len === 0) return 'left';
let start = 0, end = len - 1;
while (start < len && text.charCodeAt(start) === 32) start++;
while (end > start && text.charCodeAt(end) === 32) end--;
if (start > end) return 'left';
const hasLeft = text.charCodeAt(start) === 58;
const hasRight = text.charCodeAt(end) === 58;
if (hasLeft && hasRight) return 'center';
if (hasRight) return 'right';
return 'left';
}
/**
* Widget to display rendered table.
* Uses absolute positioning to overlay on source lines.
* Optimized with innerHTML for faster DOM creation.
*/
class TableWidget extends WidgetType {
// Cache the generated HTML to avoid regenerating on each toDOM call
private cachedHTML: string | null = null;
constructor(
readonly tableData: TableData,
readonly lineCount: number,
readonly lineHeight: number,
readonly visualHeight: number,
readonly contentWidth: number
) {
super();
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
eq(other: TableWidget) {
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
if (this.data === other.data) return true;
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
for (let i = 0; i < this.data.rows.length; i++) {
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
}
return true;
}
/**
* Build table HTML string (much faster than DOM API for large tables).
*/
private buildTableHTML(): string {
if (this.cachedHTML) return this.cachedHTML;
// Calculate row heights
const headerRatio = 2 / this.lineCount;
const dataRowRatio = 1 / this.lineCount;
const headerHeight = this.visualHeight * headerRatio;
const dataRowHeight = this.visualHeight * dataRowRatio;
// Build header cells
const headerCells = this.tableData.headers.map((header, idx) => {
const align = this.tableData.alignments[idx] || 'left';
const escapedTitle = header.replace(/"/g, '&quot;');
return `<th class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(header)}</th>`;
}).join('');
// Build body rows
const bodyRows = this.tableData.rows.map(row => {
const cells = row.map((cell, idx) => {
const align = this.tableData.alignments[idx] || 'left';
const escapedTitle = cell.replace(/"/g, '&quot;');
return `<td class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(cell)}</td>`;
}).join('');
return `<tr style="height:${dataRowHeight}px">${cells}</tr>`;
}).join('');
this.cachedHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
return this.cachedHTML;
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-table-container';
container.style.height = `${this.visualHeight}px`;
const tableWrapper = document.createElement('div');
tableWrapper.className = 'cm-table-wrapper';
tableWrapper.style.maxWidth = `${this.contentWidth}px`;
tableWrapper.style.maxHeight = `${this.visualHeight}px`;
// Use innerHTML for faster DOM creation (single parse vs many createElement calls)
tableWrapper.innerHTML = this.buildTableHTML();
container.appendChild(tableWrapper);
const wrapper = document.createElement('div');
wrapper.className = 'cm-table-wrapper';
wrapper.style.maxWidth = `${this.contentWidth}px`;
wrapper.style.maxHeight = `${this.visualHeight}px`;
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '&quot;')}">${renderInlineMarkdown(h)}</th>`).join('');
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '&quot;')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
container.appendChild(wrapper);
return container;
}
eq(other: TableWidget): boolean {
// Quick dimension checks first (most likely to differ)
if (this.visualHeight !== other.visualHeight ||
this.contentWidth !== other.contentWidth ||
this.lineCount !== other.lineCount) {
return false;
}
// Use reference equality for tableData if same object
if (this.tableData === other.tableData) return true;
// Quick length checks
const headers1 = this.tableData.headers;
const headers2 = other.tableData.headers;
const rows1 = this.tableData.rows;
const rows2 = other.tableData.rows;
if (headers1.length !== headers2.length || rows1.length !== rows2.length) {
return false;
}
// Compare headers (usually short)
for (let i = 0, len = headers1.length; i < len; i++) {
if (headers1[i] !== headers2[i]) return false;
}
// Compare rows
for (let i = 0, rowLen = rows1.length; i < rowLen; i++) {
const row1 = rows1[i];
const row2 = rows2[i];
if (row1.length !== row2.length) return false;
for (let j = 0, cellLen = row1.length; j < cellLen; j++) {
if (row1[j] !== row2[j]) return false;
}
}
return true;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { return false; }
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Check if a range overlaps with any folded region.
*/
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
const folded = foldedRanges(view.state);
const cursor = folded.iter();
while (cursor.value) {
// Check if ranges overlap
if (cursor.from < to && cursor.to > from) {
return true;
}
if (cursor.from < to && cursor.to > from) return true;
cursor.next();
}
return false;
}
/** Result of building decorations - includes both decorations and table ranges */
interface BuildResult {
decorations: DecorationSet;
tableRanges: TableRange[];
}
/**
* Build decorations for tables and collect table ranges in a single pass.
* Optimized: single syntax tree traversal instead of two separate ones.
* Handle Table node.
*/
function buildDecorationsAndRanges(view: EditorView): BuildResult {
const decorations: Range<Decoration>[] = [];
const tableRanges: TableRange[] = [];
const contentWidth = view.contentDOM.clientWidth - 10;
const lineHeight = view.defaultLineHeight;
export function handleTable(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
// Pre-create the line decoration to reuse (same class for all hidden lines)
const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' });
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'Table') return;
// Always collect table ranges for selection tracking
tableRanges.push({ from: nodeFrom, to: nodeTo });
// Skip rendering if table is in a folded region
if (isInFoldedRange(view, nodeFrom, nodeTo)) return;
// Skip rendering if cursor/selection is in table range
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
// Get cached or parse table data
const tableData = getCachedTableData(view.state, node);
if (!tableData) return;
// Calculate line info
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
const lineCount = endLine.number - startLine.number + 1;
// Get visual height using lineBlockAt (includes wrapped lines)
const startBlock = view.lineBlockAt(nodeFrom);
const endBlock = view.lineBlockAt(nodeTo);
const visualHeight = endBlock.bottom - startBlock.top;
// Add line decorations to hide content (reuse decoration object)
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
decorations.push(hiddenLineDecoration.range(line.from));
}
// Add widget on the first line (positioned absolutely)
decorations.push(
Decoration.widget({
widget: new TableWidget(tableData, lineCount, lineHeight, visualHeight, contentWidth),
side: -1
}).range(startLine.from)
);
}
});
const headerNode = node.getChild('TableHeader');
if (!headerNode) return;
const headers: string[] = [];
const alignments: CellAlign[] = [];
const rows: string[][] = [];
const headerCells = headerNode.getChildren('TableCell');
if (headerCells.length > 0) {
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
} else {
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
}
return {
decorations: Decoration.set(decorations, true),
tableRanges
};
}
// ============================================================================
// Plugin
// ============================================================================
/**
* Find which table the selection is in (if any).
* Returns table index or -1 if not in any table.
* Optimized: early exit on first match.
*/
function findSelectionTableIndex(
selectionRanges: readonly { from: number; to: number }[],
tableRanges: TableRange[]
): number {
// Early exit if no tables
if (tableRanges.length === 0) return -1;
for (const sel of selectionRanges) {
const selFrom = sel.from;
const selTo = sel.to;
for (let i = 0; i < tableRanges.length; i++) {
const table = tableRanges[i];
// Inline overlap check (avoid function call overhead)
if (selFrom <= table.to && table.from <= selTo) {
return i;
if (headers.length === 0) return;
let child = node.firstChild;
while (child) {
if (child.type.name === 'TableDelimiter') {
const delimText = ctx.view.state.sliceDoc(child.from, child.to);
if (delimText.includes('-')) {
for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
break;
}
}
child = child.nextSibling;
}
return -1;
while (alignments.length < headers.length) alignments.push('left');
for (const rowNode of node.getChildren('TableRow')) {
const rowData: string[] = [];
const cells = rowNode.getChildren('TableCell');
if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
while (rowData.length < headers.length) rowData.push('');
rows.push(rowData);
}
const startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
const lineCount = endLine.number - startLine.number + 1;
const startBlock = ctx.view.lineBlockAt(nf);
const endBlock = ctx.view.lineBlockAt(nt);
const visualHeight = endBlock.bottom - startBlock.top;
for (let num = startLine.number; num <= endLine.number; num++) {
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
}
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
}
/**
* Table plugin with optimized update detection.
*
* Performance optimizations:
* - Single syntax tree traversal (buildDecorationsAndRanges)
* - Tracks table ranges to minimize unnecessary rebuilds
* - Only rebuilds when selection enters/exits table OR switches between tables
* - Detects both cursor position AND selection range changes
* Theme for tables.
*/
class TablePlugin {
decorations: DecorationSet;
private tableRanges: TableRange[] = [];
private lastContentWidth: number = 0;
// Track last selection state for comparison
private lastSelectionFrom: number = -1;
private lastSelectionTo: number = -1;
// Track which table the selection is in (-1 = not in any table)
private lastTableIndex: number = -1;
constructor(view: EditorView) {
const result = buildDecorationsAndRanges(view);
this.decorations = result.decorations;
this.tableRanges = result.tableRanges;
this.lastContentWidth = view.contentDOM.clientWidth;
// Initialize selection tracking
const mainSel = view.state.selection.main;
this.lastSelectionFrom = mainSel.from;
this.lastSelectionTo = mainSel.to;
this.lastTableIndex = findSelectionTableIndex(view.state.selection.ranges, this.tableRanges);
}
update(update: ViewUpdate) {
const view = update.view;
const currentContentWidth = view.contentDOM.clientWidth;
// Check if content width changed (requires rebuild for proper sizing)
const widthChanged = Math.abs(currentContentWidth - this.lastContentWidth) > 1;
if (widthChanged) {
this.lastContentWidth = currentContentWidth;
}
// Full rebuild needed for:
// - Document changes (table content may have changed)
// - Viewport changes (new tables may be visible)
// - Geometry changes (folding, line height changes)
// - Width changes (table needs resizing)
if (update.docChanged || update.viewportChanged || update.geometryChanged || widthChanged) {
const result = buildDecorationsAndRanges(view);
this.decorations = result.decorations;
this.tableRanges = result.tableRanges;
// Update selection tracking
const mainSel = update.state.selection.main;
this.lastSelectionFrom = mainSel.from;
this.lastSelectionTo = mainSel.to;
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
return;
}
// For selection changes, check if selection moved in/out of a table OR between tables
if (update.selectionSet) {
const mainSel = update.state.selection.main;
const selectionChanged = mainSel.from !== this.lastSelectionFrom ||
mainSel.to !== this.lastSelectionTo;
if (selectionChanged) {
// Find which table (if any) the selection is now in
const currentTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
// Rebuild if selection moved to a different table (including in/out)
if (currentTableIndex !== this.lastTableIndex) {
const result = buildDecorationsAndRanges(view);
this.decorations = result.decorations;
this.tableRanges = result.tableRanges;
// Re-check after rebuild (table ranges may have changed)
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
} else {
this.lastTableIndex = currentTableIndex;
}
// Update tracking state
this.lastSelectionFrom = mainSel.from;
this.lastSelectionTo = mainSel.to;
}
}
}
}
const tablePlugin = ViewPlugin.fromClass(
TablePlugin,
{
decorations: (v) => v.decorations
}
);
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for tables.
*/
const baseTheme = EditorView.baseTheme({
// Table container - same as math.ts
export const tableTheme = EditorView.baseTheme({
'.cm-table-container': {
position: 'absolute',
display: 'flex',
@@ -691,19 +188,15 @@ const baseTheme = EditorView.baseTheme({
alignItems: 'flex-start',
pointerEvents: 'none',
zIndex: '2',
overflow: 'hidden',
overflow: 'hidden'
},
// Table wrapper - scrollable when needed
'.cm-table-wrapper': {
display: 'inline-block',
pointerEvents: 'auto',
backgroundColor: 'var(--bg-primary)',
overflowX: 'auto',
overflowY: 'auto',
overflowY: 'auto'
},
// Table styles - use inset box-shadow for outer border (not clipped by overflow)
'.cm-table': {
borderCollapse: 'separate',
borderSpacing: '0',
@@ -713,9 +206,8 @@ const baseTheme = EditorView.baseTheme({
backgroundColor: 'var(--cm-table-bg)',
border: 'none',
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
color: 'var(--text-primary) !important',
color: 'var(--text-primary) !important'
},
'.cm-table th, .cm-table td': {
padding: '0 8px',
border: 'none',
@@ -725,109 +217,35 @@ const baseTheme = EditorView.baseTheme({
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
// Prevent text wrapping to maintain row height
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
maxWidth: '300px'
},
// Data cells: left divider + bottom divider
'.cm-table td': {
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
},
// First column data cells: only bottom divider
'.cm-table td:first-child': {
boxShadow: '0 1px 0 var(--cm-table-border)',
},
// Last row data cells: only left divider (no bottom)
'.cm-table tbody tr:last-child td': {
boxShadow: '-1px 0 0 var(--cm-table-border)',
},
// Last row first column: no dividers
'.cm-table tbody tr:last-child td:first-child': {
boxShadow: 'none',
},
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
'.cm-table th': {
backgroundColor: 'var(--cm-table-header-bg)',
fontWeight: '600',
// Header cells: left divider + bottom divider
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
},
'.cm-table th:first-child': {
// First header cell: only bottom divider
boxShadow: '0 1px 0 var(--cm-table-border)',
},
'.cm-table tbody tr:hover': {
backgroundColor: 'var(--cm-table-row-hover)',
},
// Alignment classes - use higher specificity to override default
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': {
textAlign: 'left',
},
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': {
textAlign: 'center',
},
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': {
textAlign: 'right',
},
// Inline elements in table cells
'.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
'.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { textAlign: 'center' },
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { textAlign: 'right' },
'.cm-table code': {
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
padding: '1px 4px',
borderRadius: '3px',
fontSize: 'inherit',
fontFamily: 'var(--voidraft-font-mono)',
},
'.cm-table a': {
color: 'var(--selection-text)',
textDecoration: 'none',
},
'.cm-table a:hover': {
textDecoration: 'underline',
},
// Hidden line content for table (text transparent but line preserved)
// Use high specificity to override rainbow brackets and other plugins
'.cm-line.cm-table-line-hidden': {
color: 'transparent !important',
caretColor: 'transparent',
},
'.cm-line.cm-table-line-hidden span': {
color: 'transparent !important',
},
// Override rainbow brackets in hidden table lines
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
fontFamily: 'var(--voidraft-font-mono)'
},
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
'.cm-table a:hover': { textDecoration: 'underline' },
'.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
'.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { color: 'transparent !important' }
});
/**
* Table extension.
*
* Features:
* - Parses markdown tables using syntax tree
* - Renders tables as beautiful HTML tables
* - Table preserves line structure, overlays rendered table
* - Shows source when cursor is inside
*/
export const table = (): Extension => [
tablePlugin,
baseTheme
];
export default table;

View File

@@ -0,0 +1,33 @@
/**
* Unified theme - combines all markdown plugin themes.
*/
import { Extension } from '@codemirror/state';
import { blockquoteTheme } from './blockquote';
import { codeBlockTheme } from './code-block';
import { headingTheme } from './heading';
import { horizontalRuleTheme } from './horizontal-rule';
import { inlineStylesTheme } from './inline-styles';
import { linkTheme } from './link';
import { listTheme } from './list';
import { footnoteTheme } from './footnote';
import { mathTheme } from './math';
import { emojiTheme } from './emoji';
import { tableTheme } from './table';
/**
* All markdown themes combined.
*/
export const Theme: Extension = [
blockquoteTheme,
codeBlockTheme,
headingTheme,
horizontalRuleTheme,
inlineStylesTheme,
linkTheme,
listTheme,
footnoteTheme,
mathTheme,
emojiTheme,
tableTheme
];

View File

@@ -0,0 +1,36 @@
/**
* Shared types for unified markdown plugin handlers.
*/
import { Decoration, EditorView } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
/** Decoration item to be added */
export interface DecoItem {
from: number;
to: number;
deco: Decoration;
priority?: number;
}
/** Shared build context passed to all handlers */
export interface BuildContext {
view: EditorView;
items: DecoItem[];
selRange: RangeTuple;
seen: Set<number>;
processedLines: Set<number>;
contentWidth: number;
lineHeight: number;
}
/** Handler function type */
export type NodeHandler = (
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
) => void | boolean;

View File

@@ -0,0 +1,127 @@
/**
* Emoji extension for Lezer Markdown parser.
*
* Parses :emoji_name: syntax for emoji shortcodes.
*
* Syntax: :emoji_name: → renders as actual emoji character
*
* Examples:
* - :smile: → 😄
* - :heart: → ❤️
* - :+1: → 👍
*/
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
import { CharCode } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Pre-computed lookup table for emoji name characters.
* Valid characters: a-z, 0-9, _, +, -
* Uses Uint8Array for memory efficiency and O(1) lookup.
*/
const EMOJI_NAME_CHARS = new Uint8Array(128);
// Initialize lookup table
for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
EMOJI_NAME_CHARS[95] = 1; // _
EMOJI_NAME_CHARS[43] = 1; // +
EMOJI_NAME_CHARS[45] = 1; // -
/**
* O(1) check if a character is valid for emoji name.
* @param code - ASCII character code
* @returns True if valid emoji name character
*/
function isEmojiNameChar(code: number): boolean {
return code < 128 && EMOJI_NAME_CHARS[code] === 1;
}
/**
* Parse emoji :name: syntax.
*
* @param cx - Inline context
* @param pos - Start position (at :)
* @returns Position after element, or -1 if no match
*/
function parseEmoji(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: : + name + : = at least 3 chars, name must be non-empty
if (end < pos + 2) return -1;
// Track content for validation
let hasContent = false;
const contentStart = pos + 1;
// Search for closing :
for (let i = contentStart; i < end; i++) {
const char = cx.char(i);
// Found closing :
if (char === CharCode.Colon) {
// Must have content
if (!hasContent) return -1;
// Extract and validate emoji name
const name = cx.slice(contentStart, i).toLowerCase();
// Check if this is a valid emoji
if (!emojies[name]) return -1;
// Create element with marks and name
return cx.addElement(cx.elt('Emoji', pos, i + 1, [
cx.elt('EmojiMark', pos, contentStart),
cx.elt('EmojiName', contentStart, i),
cx.elt('EmojiMark', i, i + 1)
]));
}
// Newline not allowed in emoji
if (char === CharCode.Newline) return -1;
// Space not allowed in emoji name
if (char === CharCode.Space || char === CharCode.Tab) return -1;
// Validate name character using O(1) lookup table
// Also check for uppercase A-Z (65-90) and convert mentally
const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
if (isEmojiNameChar(lowerChar)) {
hasContent = true;
} else {
return -1;
}
}
return -1;
}
/**
* Emoji extension for Lezer Markdown.
*
* Defines:
* - Emoji: The container node for emoji shortcode
* - EmojiMark: The : delimiter marks
* - EmojiName: The emoji name part
*/
export const Emoji: MarkdownConfig = {
defineNodes: [
{ name: 'Emoji' },
{ name: 'EmojiMark' },
{ name: 'EmojiName' }
],
parseInline: [
{
name: 'Emoji',
parse(cx, next, pos) {
// Fast path: must start with :
if (next !== CharCode.Colon) return -1;
return parseEmoji(cx, pos);
},
// Parse after emphasis to avoid conflicts with other syntax
after: 'Emphasis'
}
]
};
export default Emoji;

View File

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

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

@@ -283,7 +283,7 @@ function handleClickOutside(e: MouseEvent) {
class="cm-translation-copy-btn"
@click="copyToClipboard"
@mousedown.stop
title="复制"
title="Copy"
>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>

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 { searchVisibilityField, SearchVisibilityEffect } from "./state";
export { searchBaseTheme } from "./theme";
export * from "./commands";
export { vscodeSearch, VSCodeSearch } from "./plugin";

View File

@@ -1,80 +1,64 @@
import { getSearchQuery, search, SearchQuery } from "@codemirror/search";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { CustomSearchPanel } from "./FindReplaceControl";
import { SearchVisibilityEffect } from "./state";
import { searchBaseTheme } from "./theme";
import { search } from "@codemirror/search";
import { EditorView, Panel } from "@codemirror/view";
import { StateEffect } from "@codemirror/state";
import { createApp, App } from "vue";
import SearchPanel from "./SearchPanel.vue";
export class SearchPlugin {
private searchControl: CustomSearchPanel;
private prevQuery: SearchQuery | null = null;
constructor(view: EditorView) {
this.searchControl = new CustomSearchPanel(view);
}
update(update: ViewUpdate) {
const currentQuery = getSearchQuery(update.state);
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) {
this.searchControl.findMatchesAndSelectClosest(update.state);
/**
* Create custom search panel using Vue component
* This integrates directly with CodeMirror's search extension
*/
function createSearchPanel(view: EditorView): Panel {
const dom = document.createElement("div");
dom.className = "vscode-search-container";
let app: App | null = null;
return {
dom,
top: true,
mount() {
// Mount Vue component after panel is added to DOM
app = createApp(SearchPanel, { view });
app.mount(dom);
},
destroy() {
// Cleanup Vue component
app?.unmount();
app = null;
}
this.prevQuery = currentQuery;
for (const tr of update.transactions) {
for (const e of tr.effects) {
if (e.is(SearchVisibilityEffect)) {
this.searchControl.setVisibility(e.value);
}
}
}
}
destroy() {
this.searchControl.dom.remove(); // Clean up
}
toggleCaseInsensitive() {
this.searchControl.toggleCase();
}
toggleWholeWord() {
this.searchControl.toggleWord();
}
toggleRegex() {
this.searchControl.toggleRegex();
}
showReplace() {
this.searchControl.setVisibility(true);
this.searchControl.showReplace();
}
findReplaceMatch() {
this.searchControl.findReplaceMatch();
}
findNext() {
this.searchControl.matchNext();
}
replace() {
this.searchControl.replace();
}
replaceAll() {
this.searchControl.replaceAll();
}
findPrevious() {
this.searchControl.matchPrevious();
}
};
}
export const VSCodeSearch = ViewPlugin.fromClass(SearchPlugin);
/**
* Custom scroll behavior - scroll match to center of viewport
* This is called automatically by findNext/findPrevious
*/
function scrollMatchToCenter(range: { from: number }, view: EditorView): StateEffect<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 = [
search({}),
VSCodeSearch,
searchBaseTheme
];
search({
createPanel: createSearchPanel,
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 {
hideSearchVisibilityCommand,
searchReplaceAll,
searchShowReplace,
searchToggleCase,
searchToggleRegex,
searchToggleWholeWord,
showSearchVisibilityCommand
} from '../extensions/vscodeSearch/commands';
openSearchPanel,
closeSearchPanel,
} from '@codemirror/search';
import {
addNewBlockAfterCurrent,
addNewBlockAfterLast,
@@ -26,7 +21,6 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
import {transposeChars} from '../extensions/codeblock';
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
import {textHighlightToggleCommand} from '../extensions/textHighlight';
import {
copyLineDown,
copyLineUp,
@@ -68,34 +62,13 @@ const defaultEditorOptions = {
*/
export const commands = {
[KeyBindingCommand.ShowSearchCommand]: {
handler: showSearchVisibilityCommand,
handler: openSearchPanel,
descriptionKey: 'keybindings.commands.showSearch'
},
[KeyBindingCommand.HideSearchCommand]: {
handler: hideSearchVisibilityCommand,
handler: closeSearchPanel,
descriptionKey: 'keybindings.commands.hideSearch'
},
[KeyBindingCommand.SearchToggleCaseCommand]: {
handler: searchToggleCase,
descriptionKey: 'keybindings.commands.searchToggleCase'
},
[KeyBindingCommand.SearchToggleWordCommand]: {
handler: searchToggleWholeWord,
descriptionKey: 'keybindings.commands.searchToggleWord'
},
[KeyBindingCommand.SearchToggleRegexCommand]: {
handler: searchToggleRegex,
descriptionKey: 'keybindings.commands.searchToggleRegex'
},
[KeyBindingCommand.SearchShowReplaceCommand]: {
handler: searchShowReplace,
descriptionKey: 'keybindings.commands.searchShowReplace'
},
[KeyBindingCommand.SearchReplaceAllCommand]: {
handler: searchReplaceAll,
descriptionKey: 'keybindings.commands.searchReplaceAll'
},
// 代码块操作命令
[KeyBindingCommand.BlockSelectAllCommand]: {
handler: selectAll,
@@ -285,12 +258,6 @@ export const commands = {
handler: deleteGroupForward,
descriptionKey: 'keybindings.commands.deleteGroupForward'
},
// 文本高亮扩展命令
[KeyBindingCommand.TextHighlightToggleCommand]: {
handler: textHighlightToggleCommand,
descriptionKey: 'keybindings.commands.textHighlightToggle'
},
} as const;
/**

View File

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

View File

@@ -62,6 +62,17 @@ export function createBaseTheme(colors: ThemeColors): Extension {
outline: `0.5px solid ${colors.matchingBracket}`,
},
// 搜索匹配
'.cm-searchMatch': {
backgroundColor: `${colors.searchMatch} !important`,
borderRadius: '2px',
},
'.cm-searchMatch-selected': {
backgroundColor: `${colors.searchMatchSelected} !important`,
outline: `1px solid ${colors.searchMatchSelectedOutline}`,
borderRadius: '2px',
},
// 代码块层(自定义)
'.code-blocks-layer': {
width: '100%',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,6 +121,12 @@ func (es *ExtensionService) initDatabase() error {
es.logger.Error("Failed to insert default extensions", "error", err)
return err
}
} else {
// 检查并补充缺失的扩展
if err := es.syncExtensions(); err != nil {
es.logger.Error("Failed to ensure all extensions exist", "error", err)
return err
}
}
return nil
@@ -153,6 +159,80 @@ func (es *ExtensionService) insertDefaultExtensions() error {
return nil
}
// syncExtensions 确保数据库中的扩展与代码定义同步
func (es *ExtensionService) syncExtensions() error {
defaultSettings := models.NewDefaultExtensionSettings()
now := time.Now().Format("2006-01-02 15:04:05")
// 构建代码中定义的扩展ID集合
definedExtensions := make(map[string]bool)
for _, ext := range defaultSettings.Extensions {
definedExtensions[string(ext.ID)] = true
}
// 1. 添加缺失的扩展
for _, ext := range defaultSettings.Extensions {
var exists int
err := es.databaseService.db.QueryRow("SELECT COUNT(*) FROM extensions WHERE id = ?", string(ext.ID)).Scan(&exists)
if err != nil {
return &ExtensionError{"check_extension_exists", string(ext.ID), err}
}
if exists == 0 {
configJSON, err := json.Marshal(ext.Config)
if err != nil {
return &ExtensionError{"marshal_config", string(ext.ID), err}
}
_, err = es.databaseService.db.Exec(sqlInsertExtension,
string(ext.ID),
ext.Enabled,
ext.IsDefault,
string(configJSON),
now,
now,
)
if err != nil {
return &ExtensionError{"insert_missing_extension", string(ext.ID), err}
}
es.logger.Info("Added missing extension to database", "id", ext.ID)
}
}
// 2. 删除数据库中已不存在于代码定义的扩展
rows, err := es.databaseService.db.Query("SELECT id FROM extensions")
if err != nil {
return &ExtensionError{"query_all_extension_ids", "", err}
}
defer rows.Close()
var toDelete []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return &ExtensionError{"scan_extension_id", "", err}
}
if !definedExtensions[id] {
toDelete = append(toDelete, id)
}
}
if err = rows.Err(); err != nil {
return &ExtensionError{"iterate_extension_ids", "", err}
}
// 删除不再定义的扩展
for _, id := range toDelete {
_, err := es.databaseService.db.Exec("DELETE FROM extensions WHERE id = ?", id)
if err != nil {
return &ExtensionError{"delete_obsolete_extension", id, err}
}
es.logger.Info("Removed obsolete extension from database", "id", id)
}
return nil
}
// ServiceStartup 启动时调用
func (es *ExtensionService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
es.ctx = ctx