Compare commits
6 Commits
fc5639d7bd
...
markdown
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c81c878e | |||
| 3660d13d7d | |||
| 281f53c049 | |||
| 71ca541f78 | |||
| 91f4f4afac | |||
|
|
6668c11846 |
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,31 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||
|
||||
/* Markdown 表格样式 - 暗色主题 */
|
||||
--cm-table-bg: rgba(35, 40, 52, 0.5);
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
@@ -113,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 内联代码样式 */
|
||||
@@ -125,6 +150,31 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@@ -187,5 +237,30 @@
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--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;
|
||||
|
||||
while ((match = defaultRegexp.exec(doc)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
/**
|
||||
* Build decorations from extracted link info.
|
||||
*/
|
||||
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
|
||||
const decorations: ReturnType<Decoration['range']>[] = [];
|
||||
|
||||
for (const link of links) {
|
||||
// Add text decoration
|
||||
decorations.push(Decoration.mark({
|
||||
class: 'cm-hyper-link-text'
|
||||
});
|
||||
widgets.push(linkMark.range(from, to));
|
||||
}).range(link.from, link.to));
|
||||
|
||||
const widget = Decoration.widget({
|
||||
// Add icon widget
|
||||
decorations.push(Decoration.widget({
|
||||
widget: new HyperLinkIcon({
|
||||
at: to,
|
||||
url: match[0],
|
||||
at: link.to,
|
||||
url: link.url,
|
||||
anchor,
|
||||
}),
|
||||
side: 1,
|
||||
});
|
||||
widgets.push(widget.range(to));
|
||||
}).range(link.to));
|
||||
}
|
||||
|
||||
return Decoration.set(widgets);
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
const linkDecorator = (
|
||||
regexp?: RegExp,
|
||||
matchData?: Record<string, string>,
|
||||
matchFn?: (str: string, input: string, from: number, to: number) => string,
|
||||
anchor?: HyperLinkExtensionOptions['anchor'],
|
||||
) =>
|
||||
new MatchDecorator({
|
||||
regexp: regexp || defaultRegexp,
|
||||
decorate: (add, from, to, match, _view) => {
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
urlStr = matchData[url];
|
||||
}
|
||||
const start = to,
|
||||
end = to;
|
||||
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
||||
|
||||
add(from, to, Decoration.mark({
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline'
|
||||
}));
|
||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||
},
|
||||
});
|
||||
|
||||
export type HyperLinkExtensionOptions = {
|
||||
regexp?: RegExp;
|
||||
match?: Record<string, string>;
|
||||
handle?: (value: string, input: string, from: number, to: number) => string;
|
||||
/** Custom anchor element transformer */
|
||||
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
|
||||
/**
|
||||
* Optimized hyperlink extension with visible-range-only scanning.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* 1. Only scans visible ranges (not the entire document)
|
||||
* 2. Incremental updates: maps positions when changes don't affect links
|
||||
* 3. Caches link info to avoid redundant re-extraction
|
||||
*/
|
||||
export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) {
|
||||
return ViewPlugin.fromClass(
|
||||
class HyperLinkView {
|
||||
decorator?: MatchDecorator;
|
||||
decorations: DecorationSet;
|
||||
links: HyperLinkInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
if (regexp) {
|
||||
this.decorator = linkDecorator(regexp, match, handle, anchor);
|
||||
this.decorations = this.decorator.createDeco(view);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(view, anchor);
|
||||
}
|
||||
this.links = extractVisibleLinks(view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
if (regexp && this.decorator) {
|
||||
this.decorations = this.decorator.updateDeco(update, this.decorations);
|
||||
// Always rebuild on viewport change (new content visible)
|
||||
if (update.viewportChanged) {
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, check if they affect link regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectLinks(update.changes, this.links);
|
||||
|
||||
if (needsRebuild) {
|
||||
// Changes affect links, full rebuild
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(update.view, anchor);
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.links = this.links.map(link => ({
|
||||
...link,
|
||||
from: update.changes.mapPos(link.from),
|
||||
to: update.changes.mapPos(link.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
|
||||
'.cm-hyper-link-icon svg': {
|
||||
display: 'block',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
width: 'inherit',
|
||||
height: 'inherit',
|
||||
},
|
||||
|
||||
'.cm-editor.cm-focused .cm-hyper-link-text': {
|
||||
|
||||
@@ -1,41 +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 {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(),
|
||||
html()
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
/**
|
||||
* Blockquote handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
|
||||
|
||||
/**
|
||||
* Blockquote plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Decorates blockquote with left border
|
||||
* - Hides quote marks (>) when cursor is outside
|
||||
* - Supports nested blockquotes
|
||||
* Handle Blockquote node.
|
||||
*/
|
||||
export function blockquote() {
|
||||
return [blockQuotePlugin, baseTheme];
|
||||
}
|
||||
export function handleBlockquote(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): boolean {
|
||||
if (ctx.seen.has(nf)) return false;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return false;
|
||||
|
||||
/**
|
||||
* Build blockquote decorations.
|
||||
*/
|
||||
function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const processedLines = new Set<number>();
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
enter(node) {
|
||||
if (node.type.name !== 'Blockquote') return;
|
||||
|
||||
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
||||
|
||||
// Only add decorations when cursor is outside the blockquote
|
||||
// This allows selection highlighting to be visible when editing
|
||||
if (!cursorInBlockquote) {
|
||||
// Add line decoration for each line in the blockquote
|
||||
const startLine = view.state.doc.lineAt(node.from).number;
|
||||
const endLine = view.state.doc.lineAt(node.to).number;
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!processedLines.has(i)) {
|
||||
processedLines.add(i);
|
||||
const line = view.state.doc.line(i);
|
||||
decorations.push(
|
||||
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide quote marks when cursor is outside
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'QuoteMark') {
|
||||
decorations.push(
|
||||
invisibleDecoration.range(child.from, child.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Don't recurse into nested blockquotes (handled by outer iteration)
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote plugin class.
|
||||
*/
|
||||
class BlockQuotePlugin {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildBlockQuoteDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = buildBlockQuoteDecorations(update.view);
|
||||
const startLine = ctx.view.state.doc.lineAt(nf).number;
|
||||
const endLine = ctx.view.state.doc.lineAt(nt).number;
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!ctx.processedLines.has(i)) {
|
||||
ctx.processedLines.add(i);
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for blockquotes.
|
||||
* 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,257 +1,107 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
/**
|
||||
* Code block handler and theme.
|
||||
*/
|
||||
|
||||
/** Code block node types in syntax tree */
|
||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
||||
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 (size controlled by CSS) */
|
||||
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>`;
|
||||
|
||||
/** Cache for code block metadata */
|
||||
interface CodeBlockData {
|
||||
from: number;
|
||||
to: number;
|
||||
language: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Widget for displaying language label and copy button.
|
||||
* Handles click events directly on the button element.
|
||||
*/
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly data: CodeBlockData,
|
||||
readonly view: EditorView
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: CodeBlockInfoWidget): boolean {
|
||||
return other.data.from === this.data.from &&
|
||||
other.data.language === this.data.language;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
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';
|
||||
|
||||
// Only show language label if specified
|
||||
if (this.data.language) {
|
||||
if (this.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'cm-code-block-lang';
|
||||
lang.textContent = this.data.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;
|
||||
|
||||
// Direct click handler - more reliable than eventHandlers
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleCopy(btn);
|
||||
});
|
||||
|
||||
// Prevent mousedown from affecting editor
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
container.append(btn);
|
||||
return container;
|
||||
}
|
||||
|
||||
private handleCopy(btn: HTMLButtonElement): void {
|
||||
const content = getCodeContent(this.view, this.data.from, this.data.to);
|
||||
if (!content) return;
|
||||
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
btn.innerHTML = ICON_CHECK;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = ICON_COPY;
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Ignore events to prevent editor focus changes
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract language from code block node.
|
||||
*/
|
||||
function getLanguage(view: EditorView, node: any, offset: number): string | null {
|
||||
let lang: string | null = null;
|
||||
node.toTree().iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name === 'CodeInfo') {
|
||||
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code content (without fence markers).
|
||||
*/
|
||||
function getCodeContent(view: EditorView, from: number, to: number): string {
|
||||
const lines = view.state.doc.sliceString(from, to).split('\n');
|
||||
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build decorations for visible code blocks.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const blocks = new Map<number, CodeBlockData>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
|
||||
|
||||
const key = `${nodeFrom}:${nodeTo}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
if (inBlock) return;
|
||||
|
||||
const language = getLanguage(view, node, nodeFrom);
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = view.state.doc.line(num);
|
||||
const pos: string[] = ['cm-codeblock'];
|
||||
if (num === startLine.number) pos.push('cm-codeblock-begin');
|
||||
if (num === endLine.number) pos.push('cm-codeblock-end');
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({ class: pos.join(' ') }).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Info widget with copy button (always show, language label only if specified)
|
||||
const content = getCodeContent(view, nodeFrom, nodeTo);
|
||||
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
||||
blocks.set(nodeFrom, data);
|
||||
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new CodeBlockInfoWidget(data, view),
|
||||
side: 1
|
||||
}).range(startLine.to)
|
||||
);
|
||||
|
||||
// Hide markers
|
||||
node.toTree().iterate({
|
||||
enter: ({ type: t, from: f, to: t2 }) => {
|
||||
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
|
||||
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
|
||||
}
|
||||
}
|
||||
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(); });
|
||||
container.append(btn);
|
||||
return container;
|
||||
}
|
||||
|
||||
return { decorations: Decoration.set(decorations, true), blocks };
|
||||
ignoreEvent() { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block plugin with optimized updates.
|
||||
* Handle FencedCode / CodeBlock node.
|
||||
*/
|
||||
class CodeBlockPluginClass {
|
||||
decorations: DecorationSet;
|
||||
blocks: Map<number, CodeBlockData>;
|
||||
private lastHead = -1;
|
||||
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]);
|
||||
|
||||
constructor(view: EditorView) {
|
||||
const result = buildDecorations(view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = view.state.selection.main.head;
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = ctx.view.state.doc.line(num);
|
||||
let deco = DECO_CODEBLOCK_LINE;
|
||||
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
|
||||
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
|
||||
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
|
||||
ctx.items.push({ from: line.from, to: line.from, deco });
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
const { docChanged, viewportChanged, selectionSet } = update;
|
||||
|
||||
// Skip rebuild if cursor stayed on same line
|
||||
if (selectionSet && !docChanged && !viewportChanged) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
|
||||
const newLine = update.state.doc.lineAt(newHead).number;
|
||||
|
||||
if (oldLine === newLine) {
|
||||
this.lastHead = newHead;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (docChanged || viewportChanged || selectionSet) {
|
||||
const result = buildDecorations(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = update.state.selection.main.head;
|
||||
}
|
||||
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)'
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
fontFamily: 'inherit'
|
||||
},
|
||||
'.cm-codeblock-begin': {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0 1px 0 var(--text-primary)'
|
||||
position: 'relative'
|
||||
},
|
||||
'.cm-codeblock-end': {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
},
|
||||
'.cm-code-block-info': {
|
||||
position: 'absolute',
|
||||
@@ -265,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',
|
||||
@@ -286,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,47 +1,16 @@
|
||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange } 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
|
||||
* - Uses RangeSetBuilder for optimal performance
|
||||
* - Supports 1900+ emojis from the comprehensive emoji dictionary
|
||||
*/
|
||||
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Emoji regex pattern for matching :emoji_name: syntax.
|
||||
*/
|
||||
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';
|
||||
@@ -52,130 +21,37 @@ class EmojiWidget extends WidgetType {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match result for emoji patterns.
|
||||
* 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 a text range.
|
||||
*/
|
||||
function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
|
||||
const matches: EmojiMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex state
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
||||
const name = match[1].toLowerCase();
|
||||
const emoji = emojies[name];
|
||||
|
||||
if (emoji) {
|
||||
matches.push({
|
||||
from: offset + match.index,
|
||||
to: offset + match.index + match[0].length,
|
||||
name,
|
||||
emoji
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build emoji decorations using RangeSetBuilder.
|
||||
*/
|
||||
function buildEmojiDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = doc.sliceString(from, to);
|
||||
const matches = findEmojiMatches(text, from);
|
||||
|
||||
for (const match of matches) {
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [match.from, match.to])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.add(
|
||||
match.from,
|
||||
match.to,
|
||||
Decoration.replace({
|
||||
widget: new EmojiWidget(match.emoji, match.name)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji plugin with optimized update detection.
|
||||
*/
|
||||
class EmojiPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildEmojiDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if we moved significantly
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
|
||||
// Only rebuild if cursor moved to a different position
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
const nameNode = node.getChild('EmojiName');
|
||||
if (!nameNode) return;
|
||||
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
|
||||
const emojiChar = emojies[name];
|
||||
if (emojiChar) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
|
||||
}
|
||||
}
|
||||
|
||||
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for emoji.
|
||||
* Inherits font size and line height from parent element.
|
||||
* 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,661 +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
|
||||
*
|
||||
* Syntax (MultiMarkdown/PHP Markdown Extra):
|
||||
* - Reference: [^id] → renders as superscript
|
||||
* - Definition: [^id]: content
|
||||
* - Inline footnote: ^[content] → renders as superscript with embedded content
|
||||
* Footnote handlers and theme.
|
||||
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
|
||||
*/
|
||||
|
||||
import { Extension, Range, StateField, EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
hoverTooltip,
|
||||
Tooltip,
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Information about a footnote definition.
|
||||
*/
|
||||
interface FootnoteDefinition {
|
||||
/** The footnote identifier (e.g., "1", "note") */
|
||||
id: string;
|
||||
/** The content of the footnote */
|
||||
content: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a footnote reference.
|
||||
*/
|
||||
interface FootnoteReference {
|
||||
/** The footnote identifier */
|
||||
id: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
/** Numeric index (1-based, for display) */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about an inline footnote.
|
||||
*/
|
||||
interface InlineFootnoteInfo {
|
||||
/** The content of the inline footnote */
|
||||
content: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
/** Numeric index (1-based, for display) */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collected footnote data from the document.
|
||||
* Uses Maps for O(1) lookup by position and id.
|
||||
*/
|
||||
interface FootnoteData {
|
||||
definitions: Map<string, FootnoteDefinition>;
|
||||
references: FootnoteReference[];
|
||||
inlineFootnotes: InlineFootnoteInfo[];
|
||||
// Index maps for O(1) lookup
|
||||
referencesByPos: Map<number, FootnoteReference>;
|
||||
inlineByPos: Map<number, InlineFootnoteInfo>;
|
||||
firstRefById: Map<string, FootnoteReference>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Footnote Collection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Collect all footnote definitions, references, and inline footnotes from the document.
|
||||
* Builds index maps for O(1) lookup during decoration and tooltip handling.
|
||||
*/
|
||||
function collectFootnotes(state: EditorState): FootnoteData {
|
||||
const definitions = new Map<string, FootnoteDefinition>();
|
||||
const references: FootnoteReference[] = [];
|
||||
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
||||
// Index maps for fast lookup
|
||||
const referencesByPos = new Map<number, FootnoteReference>();
|
||||
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
||||
const firstRefById = new Map<string, FootnoteReference>();
|
||||
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()
|
||||
: '';
|
||||
|
||||
definitions.set(id, { id, content, 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);
|
||||
|
||||
// Track first reference for each id
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
definitions,
|
||||
references,
|
||||
inlineFootnotes,
|
||||
referencesByPos,
|
||||
inlineByPos,
|
||||
firstRefById,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Field
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* State field to track footnote data across the document.
|
||||
* This allows efficient lookup for tooltips and navigation.
|
||||
*/
|
||||
export const footnoteDataField = StateField.define<FootnoteData>({
|
||||
create(state) {
|
||||
return collectFootnotes(state);
|
||||
},
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) {
|
||||
return collectFootnotes(tr.state);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display footnote reference as superscript.
|
||||
*/
|
||||
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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget to display inline footnote as superscript.
|
||||
*/
|
||||
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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget to display footnote definition label.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations for footnote references and inline footnotes.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle footnote references
|
||||
if (type.name === 'FootnoteReference') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
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 (!cursorInRange && ref && ref.id === id) {
|
||||
// Hide the entire syntax and show widget
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
const widget = new FootnoteRefWidget(
|
||||
id,
|
||||
ref.index,
|
||||
data.definitions.has(id)
|
||||
);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle footnote definitions
|
||||
if (type.name === 'FootnoteDefinition') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
|
||||
// Hide the entire [^id]: part
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
|
||||
|
||||
// Add widget to show [id]
|
||||
const widget = new FootnoteDefLabelWidget(id);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(marks[1].to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle inline footnotes
|
||||
if (type.name === 'InlineFootnote') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
const marks = node.getChildren('InlineFootnoteMark');
|
||||
|
||||
if (!contentNode || marks.length < 2) return;
|
||||
|
||||
const inlineNote = data.inlineByPos.get(nodeFrom);
|
||||
|
||||
if (!cursorInRange && inlineNote) {
|
||||
// Hide the entire syntax and show widget
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
const widget = new InlineFootnoteWidget(
|
||||
inlineNote.content,
|
||||
inlineNote.index
|
||||
);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Class
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Footnote view plugin with optimized update detection.
|
||||
*/
|
||||
class FootnotePlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hover Tooltip
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hover tooltip that shows footnote content.
|
||||
*/
|
||||
const footnoteHoverTooltip = hoverTooltip(
|
||||
(view, pos): Tooltip | null => {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
// Check if hovering over a footnote reference widget
|
||||
const target = document.elementFromPoint(
|
||||
view.coordsAtPos(pos)?.left ?? 0,
|
||||
view.coordsAtPos(pos)?.top ?? 0
|
||||
) 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hovering over an inline footnote widget
|
||||
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 if position is within a footnote reference node
|
||||
let foundId: string | null = null;
|
||||
let foundPos: number = pos;
|
||||
let foundInlineContent: string | null = null;
|
||||
let foundInlineIndex: number | null = null;
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
from: pos,
|
||||
to: pos,
|
||||
enter: ({ type, from, to, node }) => {
|
||||
if (type.name === 'FootnoteReference') {
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
if (labelNode && pos >= from && pos <= to) {
|
||||
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
foundPos = to;
|
||||
}
|
||||
} else if (type.name === 'InlineFootnote') {
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
if (contentNode && pos >= from && pos <= to) {
|
||||
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
|
||||
const inlineNote = data.inlineByPos.get(from);
|
||||
if (inlineNote) {
|
||||
foundInlineIndex = inlineNote.index;
|
||||
}
|
||||
foundPos = to;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (foundId) {
|
||||
const def = data.definitions.get(foundId);
|
||||
if (def) {
|
||||
const tooltipId = foundId;
|
||||
const tooltipPos = foundPos;
|
||||
return {
|
||||
pos: tooltipPos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createTooltipDom(tooltipId, def.content),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (foundInlineContent && foundInlineIndex !== null) {
|
||||
const tooltipContent = foundInlineContent;
|
||||
const tooltipIndex = foundInlineIndex;
|
||||
const tooltipPos = foundPos;
|
||||
return {
|
||||
pos: tooltipPos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
);
|
||||
|
||||
/**
|
||||
* Create tooltip DOM element for regular footnote.
|
||||
*/
|
||||
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 };
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip DOM element for inline footnote.
|
||||
* Handle FootnoteDefinition node.
|
||||
*/
|
||||
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-footnote-tooltip';
|
||||
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;
|
||||
|
||||
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 };
|
||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
if (marks.length >= 2 && labelNode) {
|
||||
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
ctx.definitionIds.add(id);
|
||||
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Click Handler
|
||||
// ============================================================================
|
||||
/**
|
||||
* 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)! });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for footnote navigation.
|
||||
* Uses mousedown to intercept before editor moves cursor.
|
||||
* - Click on reference → jump to definition
|
||||
* - Click on definition label → jump to first reference
|
||||
* Handle InlineFootnote node.
|
||||
*/
|
||||
const footnoteClickHandler = EditorView.domEventHandlers({
|
||||
mousedown(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
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;
|
||||
|
||||
// Handle click on footnote reference widget
|
||||
if (target.classList.contains('cm-footnote-ref')) {
|
||||
const id = target.dataset.footnoteId;
|
||||
if (id) {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
const def = data.definitions.get(id);
|
||||
if (def) {
|
||||
// Prevent default to stop cursor from moving to widget position
|
||||
event.preventDefault();
|
||||
// Use setTimeout to dispatch after mousedown completes
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: { anchor: def.from },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on definition label
|
||||
if (target.classList.contains('cm-footnote-def-label')) {
|
||||
const pos = view.posAtDOM(target);
|
||||
if (pos !== null) {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
// Find which definition this belongs to
|
||||
for (const [id, def] of data.definitions) {
|
||||
if (pos >= def.from && pos <= def.to) {
|
||||
// O(1) lookup for first reference
|
||||
const firstRef = data.firstRefById.get(id);
|
||||
if (firstRef) {
|
||||
event.preventDefault();
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: { anchor: firstRef.from },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
const marks = node.getChildren('InlineFootnoteMark');
|
||||
if (contentNode && marks.length >= 2) {
|
||||
ctx.inlineFootnoteIdx++;
|
||||
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base theme for footnotes.
|
||||
* Process pending footnote refs after all definitions are collected.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
// Footnote reference (superscript)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for footnotes.
|
||||
*/
|
||||
export const footnoteTheme = EditorView.baseTheme({
|
||||
'.cm-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -670,21 +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))'
|
||||
},
|
||||
|
||||
// Inline footnote reference (superscript) - uses distinct color
|
||||
'.cm-inline-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -699,84 +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))',
|
||||
},
|
||||
|
||||
// Footnote definition label
|
||||
'.cm-footnote-def-label': {
|
||||
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-footnote-def-label:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
// Tooltip
|
||||
'.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',
|
||||
},
|
||||
|
||||
// Tooltip animation
|
||||
'.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.
|
||||
*
|
||||
* Features:
|
||||
* - Parses footnote references [^id] and definitions [^id]: content
|
||||
* - Parses inline footnotes ^[content]
|
||||
* - Renders references and inline footnotes as superscript numbers
|
||||
* - Shows definition/content on hover
|
||||
* - Click to navigate between reference and definition
|
||||
*/
|
||||
export const footnote = (): Extension => [
|
||||
footnoteDataField,
|
||||
footnotePlugin,
|
||||
footnoteHoverTooltip,
|
||||
footnoteClickHandler,
|
||||
baseTheme,
|
||||
];
|
||||
|
||||
export default footnote;
|
||||
|
||||
|
||||
@@ -1,96 +1,63 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField, Range } from '@codemirror/state';
|
||||
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
|
||||
/**
|
||||
* Heading handler and theme.
|
||||
*/
|
||||
|
||||
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' });
|
||||
|
||||
/**
|
||||
* Hidden mark decoration - uses visibility: hidden to hide content
|
||||
* Handle ATXHeading node (# Heading).
|
||||
*/
|
||||
const hiddenMarkDecoration = Decoration.mark({
|
||||
class: 'cm-heading-mark-hidden'
|
||||
});
|
||||
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;
|
||||
|
||||
/**
|
||||
* Check if selection overlaps with a range.
|
||||
*/
|
||||
function isSelectionInRange(state: EditorState, from: number, to: number): boolean {
|
||||
return state.selection.ranges.some(
|
||||
(range) => from <= range.to && to >= range.from
|
||||
);
|
||||
const header = node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build heading decorations.
|
||||
* Hides # marks when cursor is not on the heading line.
|
||||
*/
|
||||
function buildHeadingDecorations(state: EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter(node) {
|
||||
// Skip if cursor is in this node's range
|
||||
if (isSelectionInRange(state, node.from, node.to)) return;
|
||||
|
||||
// Handle ATX headings (# Heading)
|
||||
if (node.type.name.startsWith('ATXHeading')) {
|
||||
const header = node.node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
const from = header.from;
|
||||
// Include the space after #
|
||||
const to = Math.min(header.to + 1, node.to);
|
||||
decorations.push(hiddenMarkDecoration.range(from, to));
|
||||
}
|
||||
}
|
||||
// Handle Setext headings (underline style)
|
||||
else if (node.type.name.startsWith('SetextHeading')) {
|
||||
// Hide the underline marks (=== or ---)
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'HeaderMark') {
|
||||
decorations.push(
|
||||
hiddenMarkDecoration.range(child.from, child.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading StateField - manages # mark visibility.
|
||||
* Handle SetextHeading node (underline style).
|
||||
*/
|
||||
const headingField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildHeadingDecorations(state);
|
||||
},
|
||||
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;
|
||||
|
||||
update(deco, tr) {
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return buildHeadingDecorations(tr.state);
|
||||
}
|
||||
return deco.map(tr.changes);
|
||||
},
|
||||
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
});
|
||||
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.
|
||||
*
|
||||
* Uses fontSize: 0 to hide the # mark without leaving whitespace.
|
||||
* This works correctly now because blockLayer uses lineBlockAt()
|
||||
* which calculates coordinates based on the entire line, not
|
||||
* individual characters, so fontSize: 0 doesn't affect boundaries.
|
||||
* Theme for headings.
|
||||
*/
|
||||
const headingTheme = EditorView.baseTheme({
|
||||
export const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
fontSize: '0'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Headings plugin.
|
||||
*/
|
||||
export const headings = () => [headingField, headingTheme];
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { checkRangeOverlap, isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Node types that contain markers as child elements.
|
||||
*/
|
||||
const TYPES_WITH_MARKS = new Set([
|
||||
'Emphasis',
|
||||
'StrongEmphasis',
|
||||
'InlineCode',
|
||||
'Strikethrough'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types that are markers themselves.
|
||||
*/
|
||||
const MARK_TYPES = new Set([
|
||||
'EmphasisMark',
|
||||
'CodeMark',
|
||||
'StrikethroughMark'
|
||||
]);
|
||||
|
||||
// Export for external use
|
||||
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
|
||||
export const markTypes = Array.from(MARK_TYPES);
|
||||
|
||||
/**
|
||||
* Build mark hiding decorations using RangeSetBuilder for optimal performance.
|
||||
*/
|
||||
function buildHideMarkDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const replaceDecoration = Decoration.replace({});
|
||||
|
||||
// Track processed ranges to avoid duplicate processing of nested marks
|
||||
let currentParentRange: [number, number] | null = null;
|
||||
|
||||
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;
|
||||
|
||||
// Skip if this is a nested element within a parent we're already processing
|
||||
if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update current parent range
|
||||
currentParentRange = [nodeFrom, nodeTo];
|
||||
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||
|
||||
// Iterate through child marks
|
||||
const innerTree = node.toTree();
|
||||
innerTree.iterate({
|
||||
enter({ type: markType, from: markFrom, to: markTo }) {
|
||||
if (!MARK_TYPES.has(markType.name)) return;
|
||||
|
||||
// Add decoration to hide the mark
|
||||
builder.add(
|
||||
nodeFrom + markFrom,
|
||||
nodeFrom + markTo,
|
||||
replaceDecoration
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide marks plugin with optimized update detection.
|
||||
*
|
||||
* This plugin:
|
||||
* - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside
|
||||
* - Uses RangeSetBuilder for efficient decoration construction
|
||||
* - Optimizes selection change detection
|
||||
*/
|
||||
class HideMarkPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionRanges: string = '';
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildHideMarkDecorations(view);
|
||||
this.lastSelectionRanges = this.serializeSelection(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildHideMarkDecorations(update.view);
|
||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if selection actually changed positions
|
||||
if (update.selectionSet) {
|
||||
const newRanges = this.serializeSelection(update.view);
|
||||
if (newRanges !== this.lastSelectionRanges) {
|
||||
this.decorations = buildHideMarkDecorations(update.view);
|
||||
this.lastSelectionRanges = newRanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize selection ranges for comparison.
|
||||
*/
|
||||
private serializeSelection(view: EditorView): string {
|
||||
return view.state.selection.ranges
|
||||
.map(r => `${r.from}:${r.to}`)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide marks plugin.
|
||||
*
|
||||
* This plugin:
|
||||
* - Hides marks when they are not in the editor selection
|
||||
* - Supports emphasis, strong, inline code, and strikethrough
|
||||
*/
|
||||
export const hideMarks = () => [
|
||||
ViewPlugin.fromClass(HideMarkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
})
|
||||
];
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Highlight plugin using syntax tree.
|
||||
*
|
||||
* Uses the custom Highlight extension to detect:
|
||||
* - Highlight: ==text== → renders as highlighted text
|
||||
*
|
||||
* Examples:
|
||||
* - This is ==important== text → This is <mark>important</mark> text
|
||||
* - Please ==review this section== carefully
|
||||
*/
|
||||
export const highlight = (): Extension => [
|
||||
highlightPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for highlight using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Highlight nodes
|
||||
if (type.name === 'Highlight') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the == characters)
|
||||
const marks = node.getChildren('HighlightMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing == marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply highlight style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-highlight'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class HighlightPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
HighlightPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for highlight.
|
||||
* Uses mark decoration with a subtle background color.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-highlight': {
|
||||
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||
borderRadius: '2px',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,100 +1,48 @@
|
||||
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* Horizontal rule plugin that renders beautiful horizontal lines.
|
||||
*
|
||||
* This plugin:
|
||||
* - 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 => [
|
||||
horizontalRuleField,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Widget to display a horizontal rule (inline version).
|
||||
*/
|
||||
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() { return true; }
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
eq(_other: HorizontalRuleWidget) {
|
||||
return true;
|
||||
}
|
||||
const hrWidget = new HorizontalRuleWidget();
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
/**
|
||||
* Handle HorizontalRule node.
|
||||
*/
|
||||
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 }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build horizontal rule decorations.
|
||||
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
|
||||
* Theme for horizontal rules.
|
||||
*/
|
||||
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name !== 'HorizontalRule') return;
|
||||
|
||||
// Skip if cursor is on this line
|
||||
if (isCursorInRange(state, [from, to])) return;
|
||||
|
||||
// Replace the entire horizontal rule with a styled widget
|
||||
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new HorizontalRuleWidget()
|
||||
}).range(from, to)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* StateField for horizontal rule decorations.
|
||||
*/
|
||||
const horizontalRuleField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildHorizontalRuleDecorations(state);
|
||||
},
|
||||
update(value, tx) {
|
||||
if (tx.docChanged || tx.selection) {
|
||||
return buildHorizontalRuleDecorations(tx.state);
|
||||
}
|
||||
return value.map(tx.changes);
|
||||
},
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for horizontal rules.
|
||||
* Uses inline-block display to render properly without block: true.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
export const horizontalRuleTheme = EditorView.baseTheme({
|
||||
'.cm-horizontal-rule-widget': {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
|
||||
@@ -1,208 +1,348 @@
|
||||
/**
|
||||
* HTML plugin for CodeMirror.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview rendered HTML
|
||||
*/
|
||||
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, Range } from '@codemirror/state';
|
||||
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
showTooltip,
|
||||
Tooltip
|
||||
} from '@codemirror/view';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { LruCache } from '@/common/utils/lruCache';
|
||||
|
||||
interface EmbedBlockData {
|
||||
interface HTMLBlockInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
content: string;
|
||||
sanitized: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
|
||||
* Returns all blocks regardless of cursor position.
|
||||
*/
|
||||
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
|
||||
const blocks = new Array<EmbedBlockData>();
|
||||
syntaxTree(state).iterate({
|
||||
enter({ from, to, name }) {
|
||||
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
||||
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
||||
const html = state.sliceDoc(from, to);
|
||||
const content = DOMPurify.sanitize(html);
|
||||
|
||||
// Skip empty content after sanitization
|
||||
if (!content.trim()) return;
|
||||
|
||||
blocks.push({ from, to, content });
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
// HTML5 official logo
|
||||
const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
|
||||
|
||||
/**
|
||||
* Build decorations for HTML blocks.
|
||||
* Only shows preview for blocks where cursor is not inside.
|
||||
* LRU cache for DOMPurify sanitize results.
|
||||
*/
|
||||
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
// Skip if cursor is in range
|
||||
if (isCursorInRange(state, [block.from, block.to])) continue;
|
||||
|
||||
// Hide the original HTML source code
|
||||
decorations.push(Decoration.replace({}).range(block.from, block.to));
|
||||
|
||||
// Add the preview widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new HTMLBlockWidget(block),
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
const sanitizeCache = new LruCache<string, string>(100);
|
||||
|
||||
/**
|
||||
* Sanitize HTML content with caching for performance.
|
||||
*/
|
||||
function sanitizeHTML(html: string): string {
|
||||
const cached = sanitizeCache.get(html);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ['img'],
|
||||
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
|
||||
ALLOW_DATA_ATTR: true
|
||||
});
|
||||
|
||||
sanitizeCache.set(html, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if selection affects any HTML block (cursor moved in/out of a block).
|
||||
* Check if document changes affect any of the given regions.
|
||||
*/
|
||||
function selectionAffectsBlocks(
|
||||
state: EditorState,
|
||||
prevState: EditorState,
|
||||
blocks: EmbedBlockData[]
|
||||
): boolean {
|
||||
for (const block of blocks) {
|
||||
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
|
||||
const isInRange = isCursorInRange(state, [block.from, block.to]);
|
||||
if (wasInRange !== isInRange) return true;
|
||||
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||
if (regions.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const region of regions) {
|
||||
if (fromA <= region.to && toA >= region.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is inside a table.
|
||||
*/
|
||||
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
const name = current.type.name;
|
||||
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
|
||||
return true;
|
||||
}
|
||||
current = current.parent as typeof current;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewPlugin for HTML block preview.
|
||||
* Uses smart caching to avoid unnecessary updates during text selection.
|
||||
* Extract all HTML blocks from visible ranges.
|
||||
* Excludes HTML inside tables (tables have their own rendering).
|
||||
*/
|
||||
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
|
||||
const result: HTMLBlockInfo[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (nodeRef) => {
|
||||
const { name, from: f, to: t, node } = nodeRef;
|
||||
|
||||
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
||||
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
||||
|
||||
// Skip HTML inside tables
|
||||
if (isInsideTable(node)) return;
|
||||
|
||||
const content = view.state.sliceDoc(f, t);
|
||||
const sanitized = sanitizeHTML(content);
|
||||
|
||||
// Skip empty content after sanitization
|
||||
if (!sanitized.trim()) return;
|
||||
|
||||
result.push({ from: f, to: t, content, sanitized });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Effect to toggle tooltip visibility */
|
||||
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
|
||||
|
||||
/** Effect to close tooltip */
|
||||
const closeHTMLTooltip = StateEffect.define<null>();
|
||||
|
||||
/** StateField to track active tooltip */
|
||||
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleHTMLTooltip)) {
|
||||
// Toggle: if same block, close; otherwise open new
|
||||
if (value && effect.value && value.from === effect.value.from) {
|
||||
return null;
|
||||
}
|
||||
return effect.value;
|
||||
}
|
||||
if (effect.is(closeHTMLTooltip)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Close tooltip on document changes
|
||||
if (tr.docChanged) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (field) =>
|
||||
showTooltip.from(field, (block): Tooltip | null => {
|
||||
if (!block) return null;
|
||||
return {
|
||||
pos: block.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-html-tooltip';
|
||||
dom.innerHTML = block.sanitized;
|
||||
|
||||
// Prevent clicks inside tooltip from closing it
|
||||
dom.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
/**
|
||||
* Indicator widget shown at the end of HTML blocks.
|
||||
* Clicking toggles the tooltip.
|
||||
*/
|
||||
class HTMLIndicatorWidget extends WidgetType {
|
||||
constructor(readonly info: HTMLBlockInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'cm-html-indicator';
|
||||
el.innerHTML = HTML_ICON;
|
||||
el.title = 'Click to preview HTML';
|
||||
|
||||
// Click handler to toggle tooltip
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
effects: toggleHTMLTooltip.of(this.info)
|
||||
});
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
eq(other: HTMLIndicatorWidget): boolean {
|
||||
return this.info.from === other.info.from && this.info.content === other.info.content;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to manage HTML block decorations.
|
||||
* Optimized with incremental updates when changes don't affect HTML regions.
|
||||
*/
|
||||
class HTMLBlockPlugin {
|
||||
decorations: DecorationSet;
|
||||
blocks: EmbedBlockData[];
|
||||
blocks: HTMLBlockInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.blocks = extractAllHTMLBlocks(view.state);
|
||||
this.decorations = buildDecorations(view.state, this.blocks);
|
||||
this.blocks = extractHTMLBlocks(view);
|
||||
this.decorations = this.build();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// If document changed, re-extract all blocks
|
||||
if (update.docChanged) {
|
||||
this.blocks = extractAllHTMLBlocks(update.state);
|
||||
this.decorations = buildDecorations(update.state, this.blocks);
|
||||
// Always rebuild on viewport change
|
||||
if (update.viewportChanged) {
|
||||
this.blocks = extractHTMLBlocks(update.view);
|
||||
this.decorations = this.build();
|
||||
return;
|
||||
}
|
||||
|
||||
// If selection changed, only rebuild if cursor moved in/out of a block
|
||||
if (update.selectionSet) {
|
||||
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
|
||||
this.decorations = buildDecorations(update.state, this.blocks);
|
||||
|
||||
// For document changes, only rebuild if changes affect HTML regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectRegions(update.changes, this.blocks);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.blocks = extractHTMLBlocks(update.view);
|
||||
this.decorations = this.build();
|
||||
} else {
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.blocks = this.blocks.map(block => ({
|
||||
...block,
|
||||
from: update.changes.mapPos(block.from),
|
||||
to: update.changes.mapPos(block.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private build(): DecorationSet {
|
||||
const deco: Range<Decoration>[] = [];
|
||||
for (const block of this.blocks) {
|
||||
deco.push(
|
||||
Decoration.widget({
|
||||
widget: new HTMLIndicatorWidget(block),
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
class HTMLBlockWidget extends WidgetType {
|
||||
constructor(public data: EmbedBlockData) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'cm-html-block-widget';
|
||||
|
||||
// Content container
|
||||
const content = document.createElement('span');
|
||||
content.className = 'cm-html-block-content';
|
||||
// This is sanitized!
|
||||
content.innerHTML = this.data.content;
|
||||
|
||||
// Edit button
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'cm-html-block-edit-btn';
|
||||
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>`;
|
||||
editBtn.title = 'Edit HTML';
|
||||
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
selection: { anchor: this.data.from }
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
|
||||
wrapper.appendChild(content);
|
||||
wrapper.appendChild(editBtn);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
eq(widget: HTMLBlockWidget): boolean {
|
||||
return JSON.stringify(widget.data) === JSON.stringify(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base theme for HTML blocks.
|
||||
* Close tooltip when clicking outside.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-html-block-widget': {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
verticalAlign: 'middle'
|
||||
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||
click(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on indicator or inside tooltip
|
||||
if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close tooltip if one is open
|
||||
const currentTooltip = view.state.field(htmlTooltipState);
|
||||
if (currentTooltip) {
|
||||
view.dispatch({
|
||||
effects: closeHTMLTooltip.of(null)
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
// Indicator icon
|
||||
'.cm-html-indicator': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '4px',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.5',
|
||||
color: 'var(--cm-html-color, #e44d26)',
|
||||
transition: 'opacity 0.15s',
|
||||
'& svg': { width: '14px', height: '14px' }
|
||||
},
|
||||
'.cm-html-block-content': {
|
||||
display: 'inline-block'
|
||||
'.cm-html-indicator:hover': {
|
||||
opacity: '1'
|
||||
},
|
||||
// Ensure images are properly sized
|
||||
'.cm-html-block-content img': {
|
||||
|
||||
// Tooltip content
|
||||
'.cm-html-tooltip': {
|
||||
padding: '8px 12px',
|
||||
maxWidth: '60vw',
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto'
|
||||
},
|
||||
|
||||
// Images inside tooltip
|
||||
'.cm-html-tooltip img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
},
|
||||
'.cm-html-block-edit-btn': {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
padding: '4px',
|
||||
border: 'none',
|
||||
|
||||
// Style the parent tooltip container
|
||||
'.cm-tooltip:has(.cm-html-tooltip)': {
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(128, 128, 128, 0.2)',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s, background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: '10'
|
||||
},
|
||||
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
|
||||
opacity: '1'
|
||||
},
|
||||
'.cm-html-block-edit-btn:hover': {
|
||||
background: 'rgba(128, 128, 128, 0.4)'
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}
|
||||
});
|
||||
|
||||
// Export the extension with theme
|
||||
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];
|
||||
/**
|
||||
* HTML block extension.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview rendered HTML
|
||||
*/
|
||||
export const html = (): Extension => [
|
||||
htmlBlockPlugin,
|
||||
htmlTooltipState,
|
||||
clickOutsideHandler,
|
||||
theme
|
||||
];
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
/**
|
||||
* Image plugin for CodeMirror.
|
||||
*
|
||||
* Features:
|
||||
* - Identifies markdown images
|
||||
* - Shows indicator icon at the end
|
||||
* - Click to preview image
|
||||
*/
|
||||
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
@@ -7,7 +16,7 @@ import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
hoverTooltip,
|
||||
showTooltip,
|
||||
Tooltip
|
||||
} from '@codemirror/view';
|
||||
|
||||
@@ -26,6 +35,25 @@ function isImageUrl(url: string): boolean {
|
||||
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document changes affect any of the given regions.
|
||||
*/
|
||||
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||
if (regions.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const region of regions) {
|
||||
if (fromA <= region.to && toA >= region.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
function extractImages(view: EditorView): ImageInfo[] {
|
||||
const result: ImageInfo[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
@@ -47,23 +75,115 @@ function extractImages(view: EditorView): ImageInfo[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Effect to toggle tooltip visibility */
|
||||
const toggleImageTooltip = StateEffect.define<ImageInfo | null>();
|
||||
|
||||
/** Effect to close tooltip */
|
||||
const closeImageTooltip = StateEffect.define<null>();
|
||||
|
||||
/** StateField to track active tooltip */
|
||||
const imageTooltipState = StateField.define<ImageInfo | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(toggleImageTooltip)) {
|
||||
// Toggle: if same image, close; otherwise open new
|
||||
if (value && effect.value && value.from === effect.value.from) {
|
||||
return null;
|
||||
}
|
||||
return effect.value;
|
||||
}
|
||||
if (effect.is(closeImageTooltip)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Close tooltip on document changes
|
||||
if (tr.docChanged) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (field) =>
|
||||
showTooltip.from(field, (img): Tooltip | null => {
|
||||
if (!img) return null;
|
||||
return {
|
||||
pos: img.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'cm-image-spinner';
|
||||
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.src;
|
||||
imgEl.alt = img.alt;
|
||||
|
||||
imgEl.onload = () => {
|
||||
dom.classList.remove('cm-image-loading');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
spinner.remove();
|
||||
imgEl.remove();
|
||||
dom.textContent = 'Failed to load image';
|
||||
dom.classList.remove('cm-image-loading');
|
||||
dom.classList.add('cm-image-tooltip-error');
|
||||
};
|
||||
|
||||
dom.append(spinner, imgEl);
|
||||
|
||||
// Prevent clicks inside tooltip from closing it
|
||||
dom.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Indicator widget shown at the end of images.
|
||||
* Clicking toggles the tooltip.
|
||||
*/
|
||||
class IndicatorWidget extends WidgetType {
|
||||
constructor(readonly info: ImageInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'cm-image-indicator';
|
||||
el.innerHTML = ICON;
|
||||
el.title = 'Click to preview image';
|
||||
|
||||
// Click handler to toggle tooltip
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
effects: toggleImageTooltip.of(this.info)
|
||||
});
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
eq(other: IndicatorWidget): boolean {
|
||||
return this.info.from === other.info.from && this.info.src === other.info.src;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to manage image decorations.
|
||||
* Optimized with incremental updates when changes don't affect image regions.
|
||||
*/
|
||||
class ImagePlugin {
|
||||
decorations: DecorationSet;
|
||||
images: ImageInfo[] = [];
|
||||
@@ -74,9 +194,29 @@ class ImagePlugin {
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
// Always rebuild on viewport change
|
||||
if (update.viewportChanged) {
|
||||
this.images = extractImages(update.view);
|
||||
this.decorations = this.build();
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, only rebuild if changes affect image regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectRegions(update.changes, this.images);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.images = extractImages(update.view);
|
||||
this.decorations = this.build();
|
||||
} else {
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.images = this.images.map(img => ({
|
||||
...img,
|
||||
from: update.changes.mapPos(img.from),
|
||||
to: update.changes.mapPos(img.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,62 +227,35 @@ class ImagePlugin {
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
|
||||
getImageAt(pos: number): ImageInfo | null {
|
||||
for (const img of this.images) {
|
||||
if (pos >= img.to && pos <= img.to + 1) {
|
||||
return img;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
const imageHoverTooltip = hoverTooltip(
|
||||
(view, pos): Tooltip | null => {
|
||||
const plugin = view.plugin(imagePlugin);
|
||||
if (!plugin) return null;
|
||||
/**
|
||||
* Close tooltip when clicking outside.
|
||||
*/
|
||||
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||
click(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const img = plugin.getImageAt(pos);
|
||||
if (!img) return null;
|
||||
// Don't close if clicking on indicator or inside tooltip
|
||||
if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
pos: img.to,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||
// Close tooltip if one is open
|
||||
const currentTooltip = view.state.field(imageTooltipState);
|
||||
if (currentTooltip) {
|
||||
view.dispatch({
|
||||
effects: closeImageTooltip.of(null)
|
||||
});
|
||||
}
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'cm-image-spinner';
|
||||
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.src;
|
||||
imgEl.alt = img.alt;
|
||||
|
||||
imgEl.onload = () => {
|
||||
dom.classList.remove('cm-image-loading');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
spinner.remove();
|
||||
imgEl.remove();
|
||||
dom.textContent = 'Failed to load image';
|
||||
dom.classList.remove('cm-image-loading');
|
||||
dom.classList.add('cm-image-tooltip-error');
|
||||
};
|
||||
|
||||
dom.append(spinner, imgEl);
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
'.cm-image-indicator': {
|
||||
@@ -157,6 +270,7 @@ const theme = EditorView.baseTheme({
|
||||
'& svg': { width: '14px', height: '14px' }
|
||||
},
|
||||
'.cm-image-indicator:hover': { opacity: '1' },
|
||||
|
||||
'.cm-image-tooltip': {
|
||||
position: 'relative',
|
||||
background: `
|
||||
@@ -205,16 +319,13 @@ const theme = EditorView.baseTheme({
|
||||
'.cm-image-tooltip-error': {
|
||||
padding: '16px 24px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-muted)'
|
||||
},
|
||||
'.cm-tooltip-arrow:before': {
|
||||
borderTopColor: 'var(--border-color) !important',
|
||||
borderBottomColor: 'var(--border-color) !important'
|
||||
},
|
||||
'.cm-tooltip-arrow:after': {
|
||||
borderTopColor: '#fff !important',
|
||||
borderBottomColor: '#fff !important'
|
||||
color: 'red'
|
||||
}
|
||||
});
|
||||
|
||||
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];
|
||||
export const image = (): Extension => [
|
||||
imagePlugin,
|
||||
imageTooltipState,
|
||||
clickOutsideHandler,
|
||||
theme
|
||||
];
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Inline code styling plugin.
|
||||
*
|
||||
* This plugin adds visual styling to inline code (`code`):
|
||||
* - Background color
|
||||
* - Border radius
|
||||
* - Padding effect via marks
|
||||
*/
|
||||
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Build inline code decorations.
|
||||
*/
|
||||
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||
if (type.name !== 'InlineCode') return;
|
||||
|
||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Skip background decoration when cursor is in the code
|
||||
// This allows selection highlighting to be visible when editing
|
||||
if (cursorInCode) return;
|
||||
|
||||
// Get the actual code content (excluding backticks)
|
||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||
|
||||
// Find backtick positions
|
||||
let codeStart = nodeFrom;
|
||||
let codeEnd = nodeTo;
|
||||
|
||||
// Skip opening backticks
|
||||
let i = 0;
|
||||
while (i < text.length && text[i] === '`') {
|
||||
codeStart++;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Skip closing backticks
|
||||
let j = text.length - 1;
|
||||
while (j >= 0 && text[j] === '`') {
|
||||
codeEnd--;
|
||||
j--;
|
||||
}
|
||||
|
||||
// Only add decoration if there's actual content
|
||||
if (codeStart < codeEnd) {
|
||||
// Add mark decoration for the code content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-inline-code'
|
||||
}).range(codeStart, codeEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code plugin class.
|
||||
*/
|
||||
class InlineCodePlugin {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildInlineCodeDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = buildInlineCodeDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for inline code.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
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,114 +0,0 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Insert plugin using syntax tree.
|
||||
*
|
||||
* Uses the custom Insert extension to detect:
|
||||
* - Insert: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Examples:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
* - Please ++review this section++ carefully
|
||||
*/
|
||||
export const insert = (): Extension => [
|
||||
insertPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for insert using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Insert nodes
|
||||
if (type.name === 'Insert') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ++ characters)
|
||||
const marks = node.getChildren('InsertMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ++ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply insert style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-insert'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class InsertPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertPlugin = ViewPlugin.fromClass(
|
||||
InsertPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for insert.
|
||||
* Uses underline decoration for inserted text.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-insert': {
|
||||
textDecoration: 'underline',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,142 +1,111 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
|
||||
/**
|
||||
* 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";
|
||||
|
||||
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
|
||||
|
||||
/** Link text decoration with underline */
|
||||
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
|
||||
|
||||
/** 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'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern for auto-link markers (< and >).
|
||||
* Handle URL node (within Link).
|
||||
*/
|
||||
const AUTO_LINK_MARK_RE = /^<|>$/g;
|
||||
export function handleURL(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
|
||||
if (ctx.seen.has(parent.from)) return;
|
||||
ctx.seen.add(parent.from);
|
||||
ranges.push([parent.from, parent.to]);
|
||||
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
|
||||
|
||||
/**
|
||||
* Parent node types that should not process.
|
||||
* - Image: handled by image plugin
|
||||
* - LinkReference: reference link definitions like [label]: url should be fully visible
|
||||
*/
|
||||
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
||||
// Get link text node (content between first [ and ])
|
||||
const linkText = parent.getChild('LinkLabel');
|
||||
const marks = parent.getChildren('LinkMark');
|
||||
const linkTitle = parent.getChild('LinkTitle');
|
||||
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
|
||||
|
||||
if (closeBracket && nf < closeBracket.from) return;
|
||||
|
||||
/**
|
||||
* Links plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Hides link markup when cursor is outside
|
||||
* - Link icons and click events are handled by hyperlink extension
|
||||
*/
|
||||
export const links = () => [goToLinkPlugin];
|
||||
// Get URL for the icon
|
||||
const url = ctx.view.state.sliceDoc(nf, nt);
|
||||
|
||||
/**
|
||||
* Build link decorations.
|
||||
* Only hides markdown syntax marks, no icons added.
|
||||
* Uses array + Decoration.set() for automatic sorting.
|
||||
*/
|
||||
function buildLinkDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const selectionRanges = view.state.selection.ranges;
|
||||
// Add underline decoration to link text
|
||||
if (linkText) {
|
||||
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
|
||||
}
|
||||
|
||||
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;
|
||||
// Hide markdown syntax marks
|
||||
for (const m of marks) {
|
||||
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// Hide URL
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
|
||||
// Hide link title if present
|
||||
if (linkTitle) {
|
||||
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||
|
||||
const marks = parent.getChildren('LinkMark');
|
||||
const linkTitle = parent.getChild('LinkTitle');
|
||||
|
||||
// Find the ']' mark position to distinguish between link text and link target
|
||||
// Link structure: [display text](url)
|
||||
// We should only hide the URL in the () part, not in the [] part
|
||||
const closeBracketMark = marks.find((mark) => {
|
||||
const text = view.state.sliceDoc(mark.from, mark.to);
|
||||
return text === ']';
|
||||
});
|
||||
|
||||
// If URL is before ']', it's part of the display text, don't hide it
|
||||
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if cursor overlaps with the link
|
||||
const cursorOverlaps = selectionRanges.some((range) =>
|
||||
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
|
||||
);
|
||||
|
||||
// Hide link marks and URL when cursor is outside
|
||||
if (!cursorOverlaps && marks.length > 0) {
|
||||
for (const mark of marks) {
|
||||
decorations.push(invisibleDecoration.range(mark.from, mark.to));
|
||||
}
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
if (linkTitle) {
|
||||
decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to));
|
||||
}
|
||||
}
|
||||
|
||||
// Get link content
|
||||
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||
|
||||
// Handle auto-links with < > markers
|
||||
if (AUTO_LINK_MARK_RE.test(linkContent)) {
|
||||
if (!isCursorInRange(view.state, [node.from, node.to])) {
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
|
||||
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link plugin with optimized update detection.
|
||||
* Theme for markdown links.
|
||||
*/
|
||||
class LinkPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionRanges: string = '';
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildLinkDecorations(view);
|
||||
this.lastSelectionRanges = this.serializeSelection(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildLinkDecorations(update.view);
|
||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if selection actually changed
|
||||
if (update.selectionSet) {
|
||||
const newRanges = this.serializeSelection(update.view);
|
||||
if (newRanges !== this.lastSelectionRanges) {
|
||||
this.decorations = buildLinkDecorations(update.view);
|
||||
this.lastSelectionRanges = newRanges;
|
||||
}
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
private serializeSelection(view: EditorView): string {
|
||||
return view.state.selection.ranges
|
||||
.map((r) => `${r.from}:${r.to}`)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { Range, StateField, Transaction } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Pattern for bullet list markers.
|
||||
* List handlers and theme.
|
||||
* Handles: ListMark (bullets), Task (checkboxes)
|
||||
*/
|
||||
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
||||
|
||||
/**
|
||||
* Lists plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Custom bullet mark rendering (- → •)
|
||||
* - Interactive task list checkboxes
|
||||
*/
|
||||
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// List Bullet Plugin
|
||||
// ============================================================================
|
||||
const BULLET_RE = /^[-+*]$/;
|
||||
|
||||
/**
|
||||
* Widget to render list bullet mark.
|
||||
*/
|
||||
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';
|
||||
@@ -48,209 +21,84 @@ class ListBulletWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list bullet decorations.
|
||||
*/
|
||||
function buildListBulletDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
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 if this is part of a task list (has Task sibling)
|
||||
const parent = node.parent;
|
||||
if (parent) {
|
||||
const task = parent.getChild('Task');
|
||||
if (task) return;
|
||||
}
|
||||
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||
|
||||
const listMark = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||
if (BULLET_LIST_MARKER_RE.test(listMark)) {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new ListBulletWidget(listMark)
|
||||
}).range(nodeFrom, nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* List bullet plugin.
|
||||
*/
|
||||
class ListBulletPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildListBulletDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildListBulletDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
const oldLine = update.startState.doc.lineAt(this.lastSelectionHead);
|
||||
const newLine = update.state.doc.lineAt(newHead);
|
||||
|
||||
if (oldLine.number !== newLine.number) {
|
||||
this.decorations = buildListBulletDecorations(update.view);
|
||||
}
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Task List Plugin (using StateField to avoid flickering)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to render checkbox for a task list item.
|
||||
*/
|
||||
class TaskCheckboxWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly checked: boolean,
|
||||
readonly pos: number // Position of the checkbox character in document
|
||||
) {
|
||||
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;
|
||||
|
||||
// Handle click directly in the widget
|
||||
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) }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build task list decorations from state.
|
||||
* Handle Task node (checkboxes).
|
||||
*/
|
||||
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
export function handleTask(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const listItem = node.parent;
|
||||
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||
const listMark = listItem.getChild('ListMark');
|
||||
const taskMarker = node.getChild('TaskMarker');
|
||||
if (!listMark || !taskMarker) return;
|
||||
if (ctx.seen.has(listMark.from)) return;
|
||||
ctx.seen.add(listMark.from);
|
||||
ranges.push([listMark.from, taskMarker.to]);
|
||||
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
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;
|
||||
|
||||
const replaceFrom = listMark.from;
|
||||
const replaceTo = taskMarker.to;
|
||||
|
||||
// Check if cursor is in this range
|
||||
if (isCursorInRange(state, [replaceFrom, replaceTo])) return;
|
||||
|
||||
// Check if task is checked - position of x or space is taskMarker.from + 1
|
||||
const markerText = state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||
const checkboxPos = taskMarker.from + 1; // Position of the x or space
|
||||
|
||||
if (isChecked) {
|
||||
decorations.push(
|
||||
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
|
||||
);
|
||||
}
|
||||
|
||||
// Replace "- [x]" or "- [ ]" with checkbox widget
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
||||
}).range(replaceFrom, replaceTo)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||
if (isChecked) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
|
||||
}
|
||||
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list StateField - uses incremental updates to avoid flickering.
|
||||
* Theme for lists.
|
||||
*/
|
||||
const taskListField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildTaskListDecorations(state);
|
||||
},
|
||||
|
||||
update(value, tr: Transaction) {
|
||||
// Only rebuild when document or selection changes
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return buildTaskListDecorations(tr.state);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for lists.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
export const listTheme = EditorView.baseTheme({
|
||||
'.cm-list-bullet': {
|
||||
color: 'var(--cm-list-bullet-color, inherit)'
|
||||
},
|
||||
|
||||
@@ -1,291 +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';
|
||||
|
||||
// ============================================================================
|
||||
// Inline Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display inline math formula.
|
||||
*/
|
||||
class InlineMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(readonly latex: string) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: true,
|
||||
displayMode: false,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
if (this.error) {
|
||||
try {
|
||||
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
|
||||
} catch (e) {
|
||||
span.textContent = this.latex;
|
||||
span.title = this.error;
|
||||
} else {
|
||||
span.innerHTML = this.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; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display block math formula.
|
||||
* Uses absolute positioning to overlay on source lines.
|
||||
*/
|
||||
class BlockMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly latex: string,
|
||||
readonly lineCount: number = 1,
|
||||
readonly lineHeight: number = 22
|
||||
) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
if (this.error) {
|
||||
try {
|
||||
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
|
||||
} catch (e) {
|
||||
inner.textContent = this.latex;
|
||||
inner.title = this.error;
|
||||
} else {
|
||||
inner.innerHTML = this.html;
|
||||
inner.title = e instanceof Error ? e.message : 'Render error';
|
||||
}
|
||||
|
||||
container.appendChild(inner);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
eq(other: BlockMathWidget): boolean {
|
||||
return this.latex === other.latex && this.lineCount === other.lineCount;
|
||||
}
|
||||
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
|
||||
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
/**
|
||||
* Handle InlineMath node ($...$).
|
||||
*/
|
||||
export function handleInlineMath(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations for math formulas.
|
||||
* Handle BlockMath node ($$...$$).
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
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;
|
||||
|
||||
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') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
|
||||
// Hide the entire syntax
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new InlineMathWidget(latex),
|
||||
side: 1
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle block math ($$...$$)
|
||||
if (type.name === 'BlockMath') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $$ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
|
||||
// Calculate line info
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
const lineHeight = view.defaultLineHeight;
|
||||
|
||||
// Check if block math spans multiple lines
|
||||
const hasLineBreak = lineCount > 1;
|
||||
|
||||
if (hasLineBreak) {
|
||||
// For multi-line: use line decorations to hide content
|
||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: 'cm-block-math-line'
|
||||
}).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Add widget on the first line (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
||||
side: -1
|
||||
}).range(startLine.from)
|
||||
);
|
||||
} else {
|
||||
// Single line: make content transparent, overlay widget
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-block-math-content-hidden'
|
||||
}).range(nodeFrom, nodeTo)
|
||||
);
|
||||
|
||||
// Add widget at the start (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, 1, lineHeight),
|
||||
side: -1
|
||||
}).range(nodeFrom)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Math plugin with optimized update detection.
|
||||
*/
|
||||
class MathPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
if (lineCount > 1) {
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
|
||||
}
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
} else {
|
||||
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
|
||||
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mathPlugin = ViewPlugin.fromClass(
|
||||
MathPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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',
|
||||
@@ -294,65 +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'
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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,152 +0,0 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Subscript and Superscript plugin using syntax tree.
|
||||
*
|
||||
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
|
||||
* - Superscript: ^text^ → renders as superscript
|
||||
* - Subscript: ~text~ → renders as subscript
|
||||
*
|
||||
* Note: Inline footnotes ^[content] are handled by the Footnote extension
|
||||
* which parses InlineFootnote before Superscript in the syntax tree.
|
||||
*
|
||||
* Examples:
|
||||
* - 19^th^ → 19ᵗʰ (superscript)
|
||||
* - H~2~O → H₂O (subscript)
|
||||
*/
|
||||
export const subscriptSuperscript = (): Extension => [
|
||||
subscriptSuperscriptPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for subscript and superscript using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Superscript nodes
|
||||
// Note: InlineFootnote ^[content] is parsed before Superscript,
|
||||
// so we don't need to check for bracket patterns here
|
||||
if (type.name === 'Superscript') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ^ characters)
|
||||
const marks = node.getChildren('SuperscriptMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ^ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply superscript style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-superscript'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Subscript nodes
|
||||
if (type.name === 'Subscript') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ~ characters)
|
||||
const marks = node.getChildren('SubscriptMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ~ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply subscript style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-subscript'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class SubscriptSuperscriptPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
||||
SubscriptSuperscriptPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for subscript and superscript.
|
||||
* Uses mark decoration instead of widget to avoid layout issues.
|
||||
* fontSize uses smaller value as subscript/superscript are naturally smaller.
|
||||
*/
|
||||
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)'
|
||||
}
|
||||
});
|
||||
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Table handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { foldedRanges } from '@codemirror/language';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
type CellAlign = 'left' | 'center' | 'right';
|
||||
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
|
||||
|
||||
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||
|
||||
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
||||
const CODE_RE = /`([^`]+)`/g;
|
||||
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const STRIKE_RE = /~~(.+?)~~/g;
|
||||
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
let html = text;
|
||||
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 {
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
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>');
|
||||
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
class TableWidget extends WidgetType {
|
||||
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
|
||||
eq(other: TableWidget) {
|
||||
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
|
||||
if (this.data === other.data) return true;
|
||||
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
|
||||
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
|
||||
for (let i = 0; i < this.data.rows.length; i++) {
|
||||
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
|
||||
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-table-container';
|
||||
container.style.height = `${this.visualHeight}px`;
|
||||
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;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||
const folded = foldedRanges(view.state);
|
||||
const cursor = folded.iter();
|
||||
while (cursor.value) {
|
||||
if (cursor.from < to && cursor.to > from) return true;
|
||||
cursor.next();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Table node.
|
||||
*/
|
||||
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;
|
||||
|
||||
const headerNode = node.getChild('TableHeader');
|
||||
if (!headerNode) return;
|
||||
const headers: string[] = [];
|
||||
const alignments: CellAlign[] = [];
|
||||
const rows: string[][] = [];
|
||||
const headerCells = headerNode.getChildren('TableCell');
|
||||
if (headerCells.length > 0) {
|
||||
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
|
||||
} else {
|
||||
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for tables.
|
||||
*/
|
||||
export const tableTheme = EditorView.baseTheme({
|
||||
'.cm-table-container': {
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
'.cm-table-wrapper': {
|
||||
display: 'inline-block',
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
'.cm-table': {
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
backgroundColor: 'var(--cm-table-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
||||
color: 'var(--text-primary) !important'
|
||||
},
|
||||
'.cm-table th, .cm-table td': {
|
||||
padding: '0 8px',
|
||||
border: 'none',
|
||||
color: 'inherit !important',
|
||||
verticalAlign: 'middle',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '300px'
|
||||
},
|
||||
'.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',
|
||||
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
|
||||
},
|
||||
'.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' },
|
||||
'.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' }
|
||||
});
|
||||
@@ -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,67 +1,88 @@
|
||||
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Range } from '@codemirror/state';
|
||||
|
||||
// 生成彩虹颜色数组
|
||||
function generateColors(): string[] {
|
||||
return ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'
|
||||
];
|
||||
}
|
||||
// 彩虹颜色数组
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
|
||||
|
||||
const OPEN_BRACKETS = new Set(['(', '[', '{']);
|
||||
const CLOSE_BRACKETS = new Set([')', ']', '}']);
|
||||
const BRACKET_PAIRS: Record<string, string> = { ')': '(', ']': '[', '}': '{' };
|
||||
|
||||
/**
|
||||
* 彩虹括号插件
|
||||
*/
|
||||
class RainbowBracketsView {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.getBracketDecorations(view);
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = this.getBracketDecorations(update.view);
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
private getBracketDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
private buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const stack: { type: string; from: number }[] = [];
|
||||
const colors = generateColors();
|
||||
|
||||
// 遍历文档内容
|
||||
for (let pos = 0; pos < doc.length; pos++) {
|
||||
const doc = view.state.doc;
|
||||
|
||||
const visibleRanges = view.visibleRanges;
|
||||
if (visibleRanges.length === 0) {
|
||||
return Decoration.set([]);
|
||||
}
|
||||
|
||||
const visibleFrom = visibleRanges[0].from;
|
||||
const visibleTo = visibleRanges[visibleRanges.length - 1].to;
|
||||
|
||||
// 阶段1: 预计算到可视范围开始位置的栈状态(只维护栈,不创建装饰)
|
||||
const stack: { char: string; from: number }[] = [];
|
||||
|
||||
for (let pos = 0; pos < visibleFrom && pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
// 遇到开括号
|
||||
if (char === '(' || char === '[' || char === '{') {
|
||||
stack.push({ type: char, from: pos });
|
||||
}
|
||||
// 遇到闭括号
|
||||
else if (char === ')' || char === ']' || char === '}') {
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
stack.push({ char, from: pos });
|
||||
} else if (CLOSE_BRACKETS.has(char)) {
|
||||
const open = stack.pop();
|
||||
const matchingBracket = this.getMatchingBracket(char);
|
||||
|
||||
if (open && open.type === matchingBracket) {
|
||||
const color = colors[stack.length % colors.length];
|
||||
const className = `cm-rainbow-bracket-${color}`;
|
||||
|
||||
// 为开括号和闭括号添加装饰
|
||||
decorations.push(
|
||||
Decoration.mark({ class: className }).range(open.from, open.from + 1),
|
||||
Decoration.mark({ class: className }).range(pos, pos + 1)
|
||||
);
|
||||
if (open && open.char !== BRACKET_PAIRS[char]) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
|
||||
}
|
||||
|
||||
private getMatchingBracket(closingBracket: string): string | null {
|
||||
switch (closingBracket) {
|
||||
case ')': return '(';
|
||||
case ']': return '[';
|
||||
case '}': return '{';
|
||||
default: return null;
|
||||
|
||||
// 阶段2: 处理可视范围内的括号(创建装饰)
|
||||
for (let pos = visibleFrom; pos < visibleTo && pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
const depth = stack.length;
|
||||
stack.push({ char, from: pos });
|
||||
|
||||
// 添加开括号装饰
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (CLOSE_BRACKETS.has(char)) {
|
||||
const open = stack.pop();
|
||||
|
||||
if (open && open.char === BRACKET_PAIRS[char]) {
|
||||
const depth = stack.length;
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
|
||||
// 添加闭括号装饰
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (open) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +90,7 @@ const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function index() {
|
||||
export default function rainbowBrackets() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
@@ -83,4 +104,4 @@ export default function index() {
|
||||
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
export class SearchPlugin {
|
||||
private searchControl: CustomSearchPanel;
|
||||
private prevQuery: SearchQuery | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.searchControl = new CustomSearchPanel(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const currentQuery = getSearchQuery(update.state);
|
||||
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) {
|
||||
this.searchControl.findMatchesAndSelectClosest(update.state);
|
||||
/**
|
||||
* Create custom search panel using Vue component
|
||||
* This integrates directly with CodeMirror's search extension
|
||||
*/
|
||||
function createSearchPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "vscode-search-container";
|
||||
|
||||
let app: App | null = null;
|
||||
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount() {
|
||||
// Mount Vue component after panel is added to DOM
|
||||
app = createApp(SearchPanel, { view });
|
||||
app.mount(dom);
|
||||
},
|
||||
destroy() {
|
||||
// Cleanup Vue component
|
||||
app?.unmount();
|
||||
app = null;
|
||||
}
|
||||
this.prevQuery = currentQuery;
|
||||
|
||||
for (const tr of update.transactions) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(SearchVisibilityEffect)) {
|
||||
this.searchControl.setVisibility(e.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.searchControl.dom.remove(); // Clean up
|
||||
}
|
||||
|
||||
toggleCaseInsensitive() {
|
||||
this.searchControl.toggleCase();
|
||||
}
|
||||
|
||||
toggleWholeWord() {
|
||||
this.searchControl.toggleWord();
|
||||
}
|
||||
|
||||
toggleRegex() {
|
||||
this.searchControl.toggleRegex();
|
||||
}
|
||||
|
||||
showReplace() {
|
||||
this.searchControl.setVisibility(true);
|
||||
this.searchControl.showReplace();
|
||||
}
|
||||
|
||||
findReplaceMatch() {
|
||||
this.searchControl.findReplaceMatch();
|
||||
}
|
||||
|
||||
findNext() {
|
||||
this.searchControl.matchNext();
|
||||
}
|
||||
|
||||
replace() {
|
||||
this.searchControl.replace();
|
||||
}
|
||||
|
||||
replaceAll() {
|
||||
this.searchControl.replaceAll();
|
||||
}
|
||||
|
||||
findPrevious() {
|
||||
this.searchControl.matchPrevious();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const VSCodeSearch = ViewPlugin.fromClass(SearchPlugin);
|
||||
/**
|
||||
* Custom scroll behavior - scroll match to center of viewport
|
||||
* This is called automatically by findNext/findPrevious
|
||||
*/
|
||||
function scrollMatchToCenter(range: { from: number }, view: EditorView): StateEffect<unknown> {
|
||||
return EditorView.scrollIntoView(range.from, { y: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* VSCode-style search extension
|
||||
* Uses CodeMirror's built-in search with custom Vue UI
|
||||
*
|
||||
* Config options set default values for search query:
|
||||
* - caseSensitive: false (default) - match case
|
||||
* - wholeWord: false (default) - match whole word
|
||||
* - regexp: false (default) - use regular expression
|
||||
* - literal: false (default) - literal string search
|
||||
*/
|
||||
export const vscodeSearch = [
|
||||
search({}),
|
||||
VSCodeSearch,
|
||||
searchBaseTheme
|
||||
];
|
||||
search({
|
||||
createPanel: createSearchPanel,
|
||||
top: true,
|
||||
scrollToMatch: scrollMatchToCenter,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regexp: false,
|
||||
literal: false,
|
||||
}),
|
||||
];
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { vscodeSearch as VSCodeSearch };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,7 +6,7 @@ export const defaultLightColors: ThemeColors = {
|
||||
dark: false,
|
||||
|
||||
background: '#ffffff',
|
||||
backgroundSecondary: '#f4f7fb',
|
||||
backgroundSecondary: '#f1faf1',
|
||||
|
||||
foreground: '#24292e',
|
||||
cursor: '#000000',
|
||||
@@ -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',
|
||||
|
||||
@@ -7,7 +7,7 @@ export const config: ThemeColors = {
|
||||
dark: false,
|
||||
|
||||
background: '#ffffff',
|
||||
backgroundSecondary: '#f1faf1',
|
||||
backgroundSecondary: '##f4f7fb ',
|
||||
|
||||
foreground: '#444d56',
|
||||
cursor: '#044289',
|
||||
@@ -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; // 当前选中匹配边框色
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,19 @@ type ExtensionID string
|
||||
|
||||
const (
|
||||
// 编辑增强扩展
|
||||
ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号
|
||||
ExtensionHyperlink ExtensionID = "hyperlink" // 超链接
|
||||
ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器
|
||||
ExtensionFold ExtensionID = "fold"
|
||||
ExtensionTextHighlight ExtensionID = "textHighlight"
|
||||
ExtensionCheckbox ExtensionID = "checkbox" // 选择框
|
||||
ExtensionTranslator ExtensionID = "translator" // 划词翻译
|
||||
ExtensionRainbowBrackets ExtensionID = "rainbowBrackets" // 彩虹括号
|
||||
ExtensionHyperlink ExtensionID = "hyperlink" // 超链接
|
||||
ExtensionColorSelector ExtensionID = "colorSelector" // 颜色选择器
|
||||
ExtensionFold ExtensionID = "fold" // 代码折叠
|
||||
ExtensionTranslator ExtensionID = "translator" // 划词翻译
|
||||
ExtensionMarkdown ExtensionID = "markdown" // Markdown渲染
|
||||
ExtensionHighlightWhitespace ExtensionID = "highlightWhitespace" // 显示空白字符
|
||||
ExtensionHighlightTrailingWhitespace ExtensionID = "highlightTrailingWhitespace" // 高亮行尾空白
|
||||
|
||||
// UI增强扩展
|
||||
ExtensionMinimap ExtensionID = "minimap" // 小地图
|
||||
ExtensionMinimap ExtensionID = "minimap" // 小地图
|
||||
ExtensionLineNumbers ExtensionID = "lineNumbers" // 行号显示
|
||||
ExtensionContextMenu ExtensionID = "contextMenu" // 上下文菜单
|
||||
|
||||
// 工具扩展
|
||||
ExtensionSearch ExtensionID = "search" // 搜索功能
|
||||
@@ -87,21 +90,6 @@ func NewDefaultExtensions() []Extension {
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
{
|
||||
ID: ExtensionTextHighlight,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{
|
||||
"backgroundColor": "#FFD700",
|
||||
"opacity": 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ExtensionCheckbox,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
{
|
||||
ID: ExtensionTranslator,
|
||||
Enabled: true,
|
||||
@@ -112,6 +100,24 @@ func NewDefaultExtensions() []Extension {
|
||||
"maxTranslationLength": 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ExtensionMarkdown,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
{
|
||||
ID: ExtensionHighlightWhitespace,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
{
|
||||
ID: ExtensionHighlightTrailingWhitespace,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
|
||||
// UI增强扩展
|
||||
{
|
||||
@@ -124,6 +130,18 @@ func NewDefaultExtensions() []Extension {
|
||||
"autohide": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: ExtensionLineNumbers,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
{
|
||||
ID: ExtensionContextMenu,
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
Config: ExtensionConfig{},
|
||||
},
|
||||
|
||||
// 工具扩展
|
||||
{
|
||||
|
||||
@@ -16,13 +16,8 @@ type KeyBindingCommand string
|
||||
|
||||
const (
|
||||
// 搜索扩展相关
|
||||
ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索
|
||||
HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索
|
||||
SearchToggleCaseCommand KeyBindingCommand = "searchToggleCase" // 搜索切换大小写
|
||||
SearchToggleWordCommand KeyBindingCommand = "searchToggleWord" // 搜索切换整词
|
||||
SearchToggleRegexCommand KeyBindingCommand = "searchToggleRegex" // 搜索切换正则
|
||||
SearchShowReplaceCommand KeyBindingCommand = "searchShowReplace" // 显示替换
|
||||
SearchReplaceAllCommand KeyBindingCommand = "searchReplaceAll" // 替换全部
|
||||
ShowSearchCommand KeyBindingCommand = "showSearch" // 显示搜索
|
||||
HideSearchCommand KeyBindingCommand = "hideSearch" // 隐藏搜索
|
||||
|
||||
// 代码块扩展相关
|
||||
BlockSelectAllCommand KeyBindingCommand = "blockSelectAll" // 块内选择全部
|
||||
@@ -78,9 +73,6 @@ const (
|
||||
HistoryRedoCommand KeyBindingCommand = "historyRedo" // 重做
|
||||
HistoryUndoSelectionCommand KeyBindingCommand = "historyUndoSelection" // 撤销选择
|
||||
HistoryRedoSelectionCommand KeyBindingCommand = "historyRedoSelection" // 重做选择
|
||||
|
||||
// 文本高亮扩展相关
|
||||
TextHighlightToggleCommand KeyBindingCommand = "textHighlightToggle" // 切换文本高亮
|
||||
)
|
||||
|
||||
// KeyBindingMetadata 快捷键配置元数据
|
||||
@@ -124,41 +116,6 @@ func NewDefaultKeyBindings() []KeyBinding {
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
{
|
||||
Command: SearchToggleCaseCommand,
|
||||
Extension: ExtensionSearch,
|
||||
Key: "Alt-c",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
{
|
||||
Command: SearchToggleWordCommand,
|
||||
Extension: ExtensionSearch,
|
||||
Key: "Alt-w",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
{
|
||||
Command: SearchToggleRegexCommand,
|
||||
Extension: ExtensionSearch,
|
||||
Key: "Alt-r",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
{
|
||||
Command: SearchShowReplaceCommand,
|
||||
Extension: ExtensionSearch,
|
||||
Key: "Mod-h",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
{
|
||||
Command: SearchReplaceAllCommand,
|
||||
Extension: ExtensionSearch,
|
||||
Key: "Mod-Alt-Enter",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
|
||||
// 代码块核心功能快捷键
|
||||
{
|
||||
@@ -496,15 +453,6 @@ func NewDefaultKeyBindings() []KeyBinding {
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
|
||||
// 文本高亮扩展快捷键
|
||||
{
|
||||
Command: TextHighlightToggleCommand,
|
||||
Extension: ExtensionTextHighlight,
|
||||
Key: "Mod-Shift-h",
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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