Compare commits
3 Commits
71ca541f78
...
a9c81c878e
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c81c878e | |||
| 3660d13d7d | |||
| 281f53c049 |
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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;
|
||||
/**
|
||||
* Build decorations from extracted link info.
|
||||
*/
|
||||
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
|
||||
const decorations: ReturnType<Decoration['range']>[] = [];
|
||||
|
||||
while ((match = defaultRegexp.exec(doc)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
|
||||
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 (!processedLines.has(i)) {
|
||||
processedLines.add(i);
|
||||
const line = view.state.doc.line(i);
|
||||
items.push({ pos: line.from, deco: LINE_DECO });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
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 = 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);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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' }
|
||||
});
|
||||
|
||||
@@ -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 nameNode = node.getChild('EmojiName');
|
||||
if (!nameNode) return;
|
||||
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).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);
|
||||
}
|
||||
}
|
||||
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()];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
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;
|
||||
|
||||
for (const range of ranges) {
|
||||
if (checkRangeOverlap(range, selRange)) {
|
||||
return range[0];
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations using RangeSetBuilder.
|
||||
*/
|
||||
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];
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FootnoteReference node.
|
||||
*/
|
||||
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;
|
||||
|
||||
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)! });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (contentNode && marks.length >= 2) {
|
||||
ctx.inlineFootnoteIdx++;
|
||||
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
|
||||
}
|
||||
}
|
||||
|
||||
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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),
|
||||
};
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which heading the cursor is in (-1 if none).
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build heading decorations using RangeSetBuilder.
|
||||
*/
|
||||
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;
|
||||
const header = 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);
|
||||
}
|
||||
}
|
||||
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
}
|
||||
|
||||
const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
/**
|
||||
* Handle SetextHeading node (underline style).
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for hidden heading marks.
|
||||
* Theme for headings.
|
||||
*/
|
||||
const headingTheme = EditorView.baseTheme({
|
||||
export const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
fontSize: '0'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Headings plugin.
|
||||
*/
|
||||
export const headings = (): Extension => [headingPlugin, headingTheme];
|
||||
|
||||
@@ -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
|
||||
})
|
||||
];
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
@@ -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%',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, node }) => {
|
||||
if (type.name !== 'URL') return;
|
||||
|
||||
export function handleURL(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||
if (seen.has(parent.from)) return;
|
||||
seen.add(parent.from);
|
||||
|
||||
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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which link the cursor is in (-1 if none).
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) 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) === ']');
|
||||
|
||||
// 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 (closeBracket && nf < closeBracket.from) return;
|
||||
|
||||
// If URL is before ']', it's part of display text, don't hide
|
||||
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||
return;
|
||||
// Get URL for the icon
|
||||
const url = ctx.view.state.sliceDoc(nf, nt);
|
||||
|
||||
// Add underline decoration to link text
|
||||
if (linkText) {
|
||||
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
|
||||
}
|
||||
|
||||
// Check if cursor overlaps with the parent link
|
||||
if (checkRangeOverlap([parent.from, parent.to], selRange)) {
|
||||
return;
|
||||
// Hide markdown syntax marks
|
||||
for (const m of marks) {
|
||||
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
// Hide URL
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
|
||||
// Hide link title if present
|
||||
if (linkTitle) {
|
||||
items.push({ from: linkTitle.from, to: linkTitle.to });
|
||||
}
|
||||
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
* Theme for markdown links.
|
||||
*/
|
||||
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 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
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;
|
||||
|
||||
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');
|
||||
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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which task the cursor is in (-1 if none).
|
||||
*/
|
||||
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 (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;
|
||||
|
||||
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 markerText = ctx.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
|
||||
});
|
||||
ctx.items.push({ from: nf, to: nt, 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();
|
||||
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list plugin with optimized updates.
|
||||
* Theme for lists.
|
||||
*/
|
||||
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)'
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
eq(other: BlockMathWidget): boolean {
|
||||
return this.latex === other.latex && this.lineCount === other.lineCount;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a math region in the document.
|
||||
*/
|
||||
interface MathRegion {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
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' });
|
||||
|
||||
/**
|
||||
* Result of building decorations, includes math regions for cursor tracking.
|
||||
* Handle InlineMath node ($...$).
|
||||
*/
|
||||
interface BuildResult {
|
||||
decorations: DecorationSet;
|
||||
mathRegions: MathRegion[];
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle block math ($$...$$)
|
||||
if (type.name === 'BlockMath') {
|
||||
// Collect math region for cursor tracking
|
||||
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
||||
/**
|
||||
* Handle BlockMath node ($$...$$).
|
||||
*/
|
||||
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;
|
||||
|
||||
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);
|
||||
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;
|
||||
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)
|
||||
);
|
||||
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 });
|
||||
}
|
||||
|
||||
// Add widget on the first line (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
||||
side: -1
|
||||
}).range(startLine.from)
|
||||
);
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
} 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;
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal 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];
|
||||
@@ -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)'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '"');
|
||||
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, '"');
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
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, '"')}">${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, '"')}">${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)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -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
|
||||
*/
|
||||
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);
|
||||
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 {
|
||||
this.lastTableIndex = currentTableIndex;
|
||||
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
|
||||
}
|
||||
|
||||
// Update tracking state
|
||||
this.lastSelectionFrom = mainSel.from;
|
||||
this.lastSelectionTo = mainSel.to;
|
||||
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;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
const tablePlugin = ViewPlugin.fromClass(
|
||||
TablePlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for tables.
|
||||
* 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;
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
@@ -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;
|
||||
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal 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;
|
||||
@@ -1,75 +1,96 @@
|
||||
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();
|
||||
const doc = view.state.doc;
|
||||
|
||||
// 遍历文档内容
|
||||
for (let pos = 0; pos < doc.length; pos++) {
|
||||
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.char !== BRACKET_PAIRS[char]) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (open && open.type === matchingBracket) {
|
||||
const color = colors[stack.length % colors.length];
|
||||
const className = `cm-rainbow-bracket-${color}`;
|
||||
// 阶段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: className }).range(open.from, open.from + 1),
|
||||
Decoration.mark({ class: className }).range(pos, pos + 1)
|
||||
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));
|
||||
}
|
||||
|
||||
private getMatchingBracket(closingBracket: string): string | null {
|
||||
switch (closingBracket) {
|
||||
case ')': return '(';
|
||||
case ']': return '[';
|
||||
case '}': return '{';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function index() {
|
||||
export default function rainbowBrackets() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export class SearchPlugin {
|
||||
private searchControl: CustomSearchPanel;
|
||||
private prevQuery: SearchQuery | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.searchControl = new CustomSearchPanel(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const currentQuery = getSearchQuery(update.state);
|
||||
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) {
|
||||
this.searchControl.findMatchesAndSelectClosest(update.state);
|
||||
}
|
||||
this.prevQuery = currentQuery;
|
||||
|
||||
for (const tr of update.transactions) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(SearchVisibilityEffect)) {
|
||||
this.searchControl.setVisibility(e.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
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();
|
||||
// Cleanup Vue component
|
||||
app?.unmount();
|
||||
app = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -192,5 +192,10 @@ export interface ThemeColors extends ThemeTagColors {
|
||||
|
||||
borderColor: string; // 边框颜色
|
||||
matchingBracket: string; // 匹配括号颜色
|
||||
|
||||
// 搜索匹配颜色
|
||||
searchMatch: string; // 搜索匹配背景色
|
||||
searchMatchSelected: string; // 当前选中匹配背景色
|
||||
searchMatchSelectedOutline: string; // 当前选中匹配边框色
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ const (
|
||||
ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号
|
||||
ExtensionHyperlink ExtensionID = "hyperlink" // 超链接
|
||||
ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器
|
||||
ExtensionFold ExtensionID = "fold"
|
||||
ExtensionTextHighlight ExtensionID = "textHighlight"
|
||||
ExtensionCheckbox ExtensionID = "checkbox" // 选择框
|
||||
ExtensionFold ExtensionID = "fold" // 代码折叠
|
||||
ExtensionTranslator ExtensionID = "translator" // 划词翻译
|
||||
ExtensionMarkdown ExtensionID = "markdown" // Markdown渲染
|
||||
ExtensionHighlightWhitespace ExtensionID = "highlightWhitespace" // 显示空白字符
|
||||
ExtensionHighlightTrailingWhitespace ExtensionID = "highlightTrailingWhitespace" // 高亮行尾空白
|
||||
|
||||
// UI增强扩展
|
||||
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{},
|
||||
},
|
||||
|
||||
// 工具扩展
|
||||
{
|
||||
|
||||
@@ -18,11 +18,6 @@ const (
|
||||
// 搜索扩展相关
|
||||
ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索
|
||||
HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索
|
||||
SearchToggleCaseCommand KeyBindingCommand = "searchToggleCase" // 搜索切换大小写
|
||||
SearchToggleWordCommand KeyBindingCommand = "searchToggleWord" // 搜索切换整词
|
||||
SearchToggleRegexCommand KeyBindingCommand = "searchToggleRegex" // 搜索切换正则
|
||||
SearchShowReplaceCommand KeyBindingCommand = "searchShowReplace" // 显示替换
|
||||
SearchReplaceAllCommand KeyBindingCommand = "searchReplaceAll" // 替换全部
|
||||
|
||||
// 代码块扩展相关
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user