28 Commits

Author SHA1 Message Date
4e611db349 Optimize minmap extension completed 2025-12-11 23:59:06 +08:00
7e9fc0ac3f Optimize minmap extension 2025-12-10 22:25:19 +08:00
ff072d1a93 Optimize minmap performance 2025-12-10 00:41:24 +08:00
a9c81c878e 🚚 2025-12-08 23:28:36 +08:00
3660d13d7d ♻️ Refactor search 2025-12-08 23:20:37 +08:00
281f53c049 Optimized markdown preview performance 2025-12-07 00:09:52 +08:00
71ca541f78 🚧 Added support for markdown preview table 2025-12-04 00:47:51 +08:00
91f4f4afac Merge branch 'markdown'
# Conflicts:
#	frontend/package-lock.json
2025-12-03 00:46:17 +08:00
fc5639d7bd 🚧 Added support for markdown preview math 2025-12-03 00:45:01 +08:00
dependabot[bot]
6668c11846 ⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.2.0...13.2.1)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 04:14:35 +00:00
17f3351cea 🚧 Added support for markdown preview footnotes 2025-12-02 00:22:22 +08:00
dd3dd4ddb2 🚧 Refactor markdown preview extension 2025-12-01 00:00:05 +08:00
60d1494d45 🚧 Refactor markdown preview extension 2025-11-30 01:09:31 +08:00
1ef5350b3f 🚧 Refactor markdown preview extension 2025-11-29 22:54:38 +08:00
3521e5787b 🚧 Refactor markdown preview extension 2025-11-29 19:24:20 +08:00
8d9bcdad7e 🚧 Refactor markdown preview extension 2025-11-28 00:38:38 +08:00
ac086db1ed ♻️ Updated markdown preview extension 2025-11-26 22:11:16 +08:00
6dff0181d2 ♻️ Refactored markdown preview extension 2025-11-24 00:10:28 +08:00
ad24d3a140 ♻️ Refactored translation extension 2025-11-23 18:45:49 +08:00
4b0f39d747 Merge branch 'master' into dev 2025-11-21 23:37:36 +08:00
096cc1da94 🎨 Optimize hyperlink extension 2025-11-21 23:35:42 +08:00
2d3200ad97 ♻️ Refactor context menu 2025-11-21 22:30:47 +08:00
4e82e2f6f7 ♻️ Refactor the Markdown preview theme application logic 2025-11-21 20:20:06 +08:00
339ed53c2e ♻️ Refactor theme module 2025-11-21 00:03:03 +08:00
fc7c162e2f ♻️ Refactor theme module 2025-11-20 23:07:12 +08:00
dependabot[bot]
24f1549730 ⬆️ Bump golang.org/x/crypto from 0.44.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 03:04:41 +00:00
5584a46ca2 ♻️ Refactor theme module 2025-11-20 00:39:00 +08:00
4471441d6f ♻️ Refactor some code 2025-11-19 20:54:58 +08:00
156 changed files with 12295 additions and 16370 deletions

View File

@@ -415,25 +415,48 @@ export enum ExtensionID {
* 颜色选择器
*/
ExtensionColorSelector = "colorSelector",
ExtensionFold = "fold",
ExtensionTextHighlight = "textHighlight",
/**
* 选择框
* 代码折叠
*/
ExtensionCheckbox = "checkbox",
ExtensionFold = "fold",
/**
* 划词翻译
*/
ExtensionTranslator = "translator",
/**
* Markdown渲染
*/
ExtensionMarkdown = "markdown",
/**
* 显示空白字符
*/
ExtensionHighlightWhitespace = "highlightWhitespace",
/**
* 高亮行尾空白
*/
ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace",
/**
* UI增强扩展
* 小地图
*/
ExtensionMinimap = "minimap",
/**
* 行号显示
*/
ExtensionLineNumbers = "lineNumbers",
/**
* 上下文菜单
*/
ExtensionContextMenu = "contextMenu",
/**
* 工具扩展
* 搜索功能
@@ -810,31 +833,6 @@ export enum KeyBindingCommand {
*/
HideSearchCommand = "hideSearch",
/**
* 搜索切换大小写
*/
SearchToggleCaseCommand = "searchToggleCase",
/**
* 搜索切换整词
*/
SearchToggleWordCommand = "searchToggleWord",
/**
* 搜索切换正则
*/
SearchToggleRegexCommand = "searchToggleRegex",
/**
* 显示替换
*/
SearchShowReplaceCommand = "searchShowReplace",
/**
* 替换全部
*/
SearchReplaceAllCommand = "searchReplaceAll",
/**
* 代码块扩展相关
* 块内选择全部
@@ -1073,12 +1071,6 @@ export enum KeyBindingCommand {
* 重做选择
*/
HistoryRedoSelectionCommand = "historyRedoSelection",
/**
* 文本高亮扩展相关
* 切换文本高亮
*/
TextHighlightToggleCommand = "textHighlightToggle",
};
/**
@@ -1170,7 +1162,7 @@ export class Theme {
this["type"] = ("" as ThemeType);
}
if (!("colors" in $$source)) {
this["colors"] = (new ThemeColorConfig());
this["colors"] = ({} as ThemeColorConfig);
}
if (!("isDefault" in $$source)) {
this["isDefault"] = false;
@@ -1199,303 +1191,9 @@ export class Theme {
}
/**
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
*/
export class ThemeColorConfig {
/**
* 主题基本信息
* 主题名称
*/
"name": string;
/**
* 是否为深色主题
*/
"dark": boolean;
/**
* 基础色调
* 主背景色
*/
"background": string;
/**
* 次要背景色(用于代码块交替背景)
*/
"backgroundSecondary": string;
/**
* 面板背景
*/
"surface": string;
/**
* 下拉菜单背景
*/
"dropdownBackground": string;
/**
* 下拉菜单边框
*/
"dropdownBorder": string;
/**
* 文本颜色
* 主文本色
*/
"foreground": string;
/**
* 次要文本色
*/
"foregroundSecondary": string;
/**
* 注释色
*/
"comment": string;
/**
* 语法高亮色 - 核心
* 关键字
*/
"keyword": string;
/**
* 字符串
*/
"string": string;
/**
* 函数名
*/
"function": string;
/**
* 数字
*/
"number": string;
/**
* 操作符
*/
"operator": string;
/**
* 变量
*/
"variable": string;
/**
* 类型
*/
"type": string;
/**
* 语法高亮色 - 扩展
* 常量
*/
"constant": string;
/**
* 存储类型(如 static, const
*/
"storage": string;
/**
* 参数
*/
"parameter": string;
/**
* 类名
*/
"class": string;
/**
* 标题Markdown等
*/
"heading": string;
/**
* 无效内容/错误
*/
"invalid": string;
/**
* 正则表达式
*/
"regexp": string;
/**
* 界面元素
* 光标
*/
"cursor": string;
/**
* 选中背景
*/
"selection": string;
/**
* 失焦选中背景
*/
"selectionBlur": string;
/**
* 当前行高亮
*/
"activeLine": string;
/**
* 行号
*/
"lineNumber": string;
/**
* 活动行号颜色
*/
"activeLineNumber": string;
/**
* 边框和分割线
* 边框色
*/
"borderColor": string;
/**
* 浅色边框
*/
"borderLight": string;
/**
* 搜索和匹配
* 搜索匹配
*/
"searchMatch": string;
/**
* 匹配括号
*/
"matchingBracket": string;
/** Creates a new ThemeColorConfig instance. */
constructor($$source: Partial<ThemeColorConfig> = {}) {
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("dark" in $$source)) {
this["dark"] = false;
}
if (!("background" in $$source)) {
this["background"] = "";
}
if (!("backgroundSecondary" in $$source)) {
this["backgroundSecondary"] = "";
}
if (!("surface" in $$source)) {
this["surface"] = "";
}
if (!("dropdownBackground" in $$source)) {
this["dropdownBackground"] = "";
}
if (!("dropdownBorder" in $$source)) {
this["dropdownBorder"] = "";
}
if (!("foreground" in $$source)) {
this["foreground"] = "";
}
if (!("foregroundSecondary" in $$source)) {
this["foregroundSecondary"] = "";
}
if (!("comment" in $$source)) {
this["comment"] = "";
}
if (!("keyword" in $$source)) {
this["keyword"] = "";
}
if (!("string" in $$source)) {
this["string"] = "";
}
if (!("function" in $$source)) {
this["function"] = "";
}
if (!("number" in $$source)) {
this["number"] = "";
}
if (!("operator" in $$source)) {
this["operator"] = "";
}
if (!("variable" in $$source)) {
this["variable"] = "";
}
if (!("type" in $$source)) {
this["type"] = "";
}
if (!("constant" in $$source)) {
this["constant"] = "";
}
if (!("storage" in $$source)) {
this["storage"] = "";
}
if (!("parameter" in $$source)) {
this["parameter"] = "";
}
if (!("class" in $$source)) {
this["class"] = "";
}
if (!("heading" in $$source)) {
this["heading"] = "";
}
if (!("invalid" in $$source)) {
this["invalid"] = "";
}
if (!("regexp" in $$source)) {
this["regexp"] = "";
}
if (!("cursor" in $$source)) {
this["cursor"] = "";
}
if (!("selection" in $$source)) {
this["selection"] = "";
}
if (!("selectionBlur" in $$source)) {
this["selectionBlur"] = "";
}
if (!("activeLine" in $$source)) {
this["activeLine"] = "";
}
if (!("lineNumber" in $$source)) {
this["lineNumber"] = "";
}
if (!("activeLineNumber" in $$source)) {
this["activeLineNumber"] = "";
}
if (!("borderColor" in $$source)) {
this["borderColor"] = "";
}
if (!("borderLight" in $$source)) {
this["borderLight"] = "";
}
if (!("searchMatch" in $$source)) {
this["searchMatch"] = "";
}
if (!("matchingBracket" in $$source)) {
this["matchingBracket"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new ThemeColorConfig instance from a string or object.
*/
static createFrom($$source: any = {}): ThemeColorConfig {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
}
}
export type ThemeColorConfig = { [_: string]: any };
/**
* ThemeType 主题类型枚举
@@ -1636,6 +1334,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
});
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
const $$createType8 = HotkeyCombo.createFrom;
const $$createType9 = ThemeColorConfig.createFrom;
var $$createType9 = (function $$initCreateType9(...args): any {
if ($$createType9 === $$initCreateType9) {
$$createType9 = $$createType7;
}
return $$createType9(...args);
});
const $$createType10 = GithubConfig.createFrom;
const $$createType11 = GiteaConfig.createFrom;

View File

@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
import * as models$0 from "../models/models.js";
/**
* GetAllThemes 获取所有主题
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
*/
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(2425053076) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetThemeByID 根据ID或名称获取主题
* 如果 id > 0按ID查询如果 id = 0按名称查询
*/
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(127385338, id, name) as any;
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1938954770, name) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
@@ -43,10 +30,10 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
}
/**
* ResetTheme 重置主题为预设配置
* ResetTheme 删除指定主题的覆盖配置
*/
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1806334457, name) as any;
return $resultPromise;
}
@@ -59,7 +46,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
}
/**
* ServiceStartup 服务启动时初始化
* ServiceStartup 服务启动
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2915959937, options) as any;
@@ -67,14 +54,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
}
/**
* UpdateTheme 更新主题
* UpdateTheme 保存或更新主题覆盖
*/
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Theme.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1);

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"app:generate": "cd .. && wails3 generate bindings -ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.19.1",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
@@ -50,59 +50,56 @@
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.6",
"@codemirror/view": "^6.38.8",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.3",
"@mdit/plugin-katex": "^0.23.2",
"@mdit/plugin-tasklist": "^0.22.2",
"@lezer/lr": "^1.4.4",
"@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/markdown-it": "^14.1.2",
"@types/katex": "^0.16.7",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"groovy-beautify": "^0.0.17",
"highlight.js": "^11.11.1",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"katex": "^0.16.25",
"linguist-languages": "^9.1.0",
"markdown-it": "^14.1.0",
"marked": "^17.0.1",
"mermaid": "^11.12.1",
"npm": "^11.6.2",
"php-parser": "^3.2.5",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.6.2",
"sass": "^1.94.0",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"prettier": "^3.7.2",
"sass": "^1.94.2",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@lezer/generator": "^1.8.0",
"@types/node": "^24.9.2",
"@vitejs/plugin-vue": "^6.0.1",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@wailsio/runtime": "latest",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"happy-dom": "^20.0.10",
"happy-dom": "^20.0.11",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"typescript-eslint": "^8.48.0",
"unplugin-vue-components": "^30.0.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-node-polyfills": "^0.24.0",
"vitepress": "^2.0.0-alpha.12",
"vitest": "^4.0.8",
"vitest": "^4.0.14",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.3"
"vue-tsc": "^3.1.5"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"

View File

@@ -1,8 +1,9 @@
/* 导入所有CSS文件 */
@import 'normalize.css';
@import 'variables.css';
@import 'scrollbar.css';
@import "harmony_fonts.css";
@import 'hack_fonts.css';
@import 'opensans_fonts.css';
@import "monocraft_fonts.css";
@import "monocraft_fonts.css";
@import 'variables.css';
@import 'scrollbar.css';
@import 'styles.css';

View File

@@ -0,0 +1,3 @@
body {
background-color: var(--bg-primary);
}

View File

@@ -1,255 +1,266 @@
:root {
/* 编辑器区域 */
--text-primary: #9BB586; /* 内容区域字体颜色 */
/* 深色主题颜色变量 */
--dark-toolbar-bg: #2d2d2d;
--dark-toolbar-border: #404040;
--dark-toolbar-text: #ffffff;
--dark-toolbar-text-secondary: #cccccc;
--dark-toolbar-button-hover: #404040;
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
--dark-bg-secondary: #0E1217;
--dark-text-secondary: #a0aec0;
--dark-text-muted: #666;
--dark-border-color: #2d3748;
--dark-settings-bg: #2a2a2a;
--dark-settings-card-bg: #333333;
--dark-settings-text: #ffffff;
--dark-settings-text-secondary: #cccccc;
--dark-settings-border: #444444;
--dark-settings-input-bg: #3a3a3a;
--dark-settings-input-border: #555555;
--dark-settings-hover: #404040;
--dark-scrollbar-track: #2a2a2a;
--dark-scrollbar-thumb: #555555;
--dark-scrollbar-thumb-hover: #666666;
--dark-selection-bg: rgba(181, 206, 168, 0.1);
--dark-selection-text: #b5cea8;
--dark-danger-color: #ff6b6b;
--dark-bg-primary: #1a1a1a;
--dark-bg-hover: #2a2a2a;
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
--dark-loading-color: #fff;
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
--dark-loading-done-color: #6f6;
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
/* 浅色主题颜色变量 */
--light-toolbar-bg: #f8f9fa;
--light-toolbar-border: #e9ecef;
--light-toolbar-text: #212529;
--light-toolbar-text-secondary: #495057;
--light-toolbar-button-hover: #e9ecef;
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
--light-bg-secondary: #f7fef7;
--light-text-secondary: #374151;
--light-text-muted: #6b7280;
--light-border-color: #e5e7eb;
--light-settings-bg: #ffffff;
--light-settings-card-bg: #f8f9fa;
--light-settings-text: #212529;
--light-settings-text-secondary: #6c757d;
--light-settings-border: #dee2e6;
--light-settings-input-bg: #ffffff;
--light-settings-input-border: #ced4da;
--light-settings-hover: #e9ecef;
--light-scrollbar-track: #f1f3f4;
--light-scrollbar-thumb: #c1c1c1;
--light-scrollbar-thumb-hover: #a8a8a8;
--light-selection-bg: rgba(59, 130, 246, 0.15);
--light-selection-text: #2563eb;
--light-danger-color: #dc3545;
--light-bg-primary: #ffffff;
--light-bg-hover: #f1f3f4;
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
--light-loading-color: #1a3c1a;
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
--light-loading-done-color: #008800;
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
/* 默认使用深色主题 */
--toolbar-bg: var(--dark-toolbar-bg);
--toolbar-border: var(--dark-toolbar-border);
--toolbar-text: var(--dark-toolbar-text);
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
--border-color: var(--dark-border-color);
--settings-bg: var(--dark-settings-bg);
--settings-card-bg: var(--dark-settings-card-bg);
--settings-text: var(--dark-settings-text);
--settings-text-secondary: var(--dark-settings-text-secondary);
--settings-border: var(--dark-settings-border);
--settings-input-bg: var(--dark-settings-input-bg);
--settings-input-border: var(--dark-settings-input-border);
--settings-hover: var(--dark-settings-hover);
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
color-scheme: light dark;
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
/* 监听系统深色主题 */
@media (prefers-color-scheme: dark) {
:root[data-theme="auto"] {
--toolbar-bg: var(--dark-toolbar-bg);
--toolbar-border: var(--dark-toolbar-border);
--toolbar-text: var(--dark-toolbar-text);
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
--border-color: var(--dark-border-color);
--settings-bg: var(--dark-settings-bg);
--settings-card-bg: var(--dark-settings-card-bg);
--settings-text: var(--dark-settings-text);
--settings-text-secondary: var(--dark-settings-text-secondary);
--settings-border: var(--dark-settings-border);
--settings-input-bg: var(--dark-settings-input-bg);
--settings-input-border: var(--dark-settings-input-border);
--settings-hover: var(--dark-settings-hover);
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
}
/* 默认/暗色主题 */
:root,
:root[data-theme="dark"],
:root[data-theme="auto"] {
color-scheme: dark;
--text-primary: #ffffff;
--toolbar-bg: #2d2d2d;
--toolbar-border: #404040;
--toolbar-text: #ffffff;
--toolbar-text-secondary: #cccccc;
--toolbar-button-hover: #404040;
--toolbar-separator: #404040;
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
--bg-secondary: #0e1217;
--bg-primary: #1a1a1a;
--bg-hover: #2a2a2a;
--text-secondary: #a0aec0;
--text-muted: #666666;
--text-danger: #ff6b6b;
--border-color: #2d3748;
--settings-bg: #2a2a2a;
--settings-card-bg: #333333;
--settings-text: #ffffff;
--settings-text-secondary: #cccccc;
--settings-border: #444444;
--settings-input-bg: #3a3a3a;
--settings-input-border: #555555;
--settings-hover: #404040;
--scrollbar-track: #2a2a2a;
--scrollbar-thumb: #555555;
--scrollbar-thumb-hover: #666666;
--selection-bg: rgba(181, 206, 168, 0.1);
--selection-text: #b5cea8;
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
--voidraft-loading-color: #ffffff;
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
--voidraft-loading-done-color: #66ff66;
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
/* Markdown 代码块样式 - 暗色主题 */
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
--cm-codeblock-radius: 0.4rem;
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(28% 0.02 255);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* 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;
}
/* 监听系统浅色主题 */
/* 色主题 */
:root[data-theme="light"] {
color-scheme: light;
--text-primary: #000000;
--toolbar-bg: #f8f9fa;
--toolbar-border: #e9ecef;
--toolbar-text: #212529;
--toolbar-text-secondary: #495057;
--toolbar-button-hover: #e9ecef;
--toolbar-separator: #e9ecef;
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
--bg-secondary: #f7fef7;
--bg-primary: #ffffff;
--bg-hover: #f1f3f4;
--text-secondary: #374151;
--text-muted: #6b7280;
--text-danger: #dc3545;
--border-color: #e5e7eb;
--settings-bg: #ffffff;
--settings-card-bg: #f8f9fa;
--settings-text: #212529;
--settings-text-secondary: #6c757d;
--settings-border: #dee2e6;
--settings-input-bg: #ffffff;
--settings-input-border: #ced4da;
--settings-hover: #e9ecef;
--scrollbar-track: #f1f3f4;
--scrollbar-thumb: #c1c1c1;
--scrollbar-thumb-hover: #a8a8a8;
--selection-bg: rgba(59, 130, 246, 0.15);
--selection-text: #2563eb;
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
--voidraft-loading-color: #1a3c1a;
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
--voidraft-loading-done-color: #008800;
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
/* Markdown 代码块样式 - 亮色主题 */
--cm-codeblock-bg: #f3f3f3;
--cm-codeblock-radius: 0.4rem;
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* 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;
}
/* 跟随系统的浅色偏好 */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--toolbar-bg: var(--light-toolbar-bg);
--toolbar-border: var(--light-toolbar-border);
--toolbar-text: var(--light-toolbar-text);
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
--toolbar-button-hover: var(--light-toolbar-button-hover);
--toolbar-separator: var(--light-toolbar-button-hover);
--tab-active-line: var(--light-tab-active-line);
--bg-secondary: var(--light-bg-secondary);
--text-secondary: var(--light-text-secondary);
--text-muted: var(--light-text-muted);
--border-color: var(--light-border-color);
--settings-bg: var(--light-settings-bg);
--settings-card-bg: var(--light-settings-card-bg);
--settings-text: var(--light-settings-text);
--settings-text-secondary: var(--light-settings-text-secondary);
--settings-border: var(--light-settings-border);
--settings-input-bg: var(--light-settings-input-bg);
--settings-input-border: var(--light-settings-input-border);
--settings-hover: var(--light-settings-hover);
--scrollbar-track: var(--light-scrollbar-track);
--scrollbar-thumb: var(--light-scrollbar-thumb);
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
--selection-bg: var(--light-selection-bg);
--selection-text: var(--light-selection-text);
--text-danger: var(--light-danger-color);
--bg-primary: var(--light-bg-primary);
--bg-hover: var(--light-bg-hover);
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
--voidraft-loading-color: var(--light-loading-color);
--voidraft-loading-glow: var(--light-loading-glow);
--voidraft-loading-done-color: var(--light-loading-done-color);
--voidraft-loading-overlay: var(--light-loading-overlay);
color-scheme: light;
--text-primary: #000000;
--toolbar-bg: #f8f9fa;
--toolbar-border: #e9ecef;
--toolbar-text: #212529;
--toolbar-text-secondary: #495057;
--toolbar-button-hover: #e9ecef;
--toolbar-separator: #e9ecef;
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
--bg-secondary: #f7fef7;
--bg-primary: #ffffff;
--bg-hover: #f1f3f4;
--text-secondary: #374151;
--text-muted: #6b7280;
--text-danger: #dc3545;
--border-color: #e5e7eb;
--settings-bg: #ffffff;
--settings-card-bg: #f8f9fa;
--settings-text: #212529;
--settings-text-secondary: #6c757d;
--settings-border: #dee2e6;
--settings-input-bg: #ffffff;
--settings-input-border: #ced4da;
--settings-hover: #e9ecef;
--scrollbar-track: #f1f3f4;
--scrollbar-thumb: #c1c1c1;
--scrollbar-thumb-hover: #a8a8a8;
--selection-bg: rgba(59, 130, 246, 0.15);
--selection-text: #2563eb;
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
--voidraft-loading-color: #1a3c1a;
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
--voidraft-loading-done-color: #008800;
--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-radius: 0.4rem;
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* 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;
}
}
/* 手动选择浅色主题 */
:root[data-theme="light"] {
--toolbar-bg: var(--light-toolbar-bg);
--toolbar-border: var(--light-toolbar-border);
--toolbar-text: var(--light-toolbar-text);
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
--toolbar-button-hover: var(--light-toolbar-button-hover);
--toolbar-separator: var(--light-toolbar-button-hover);
--tab-active-line: var(--light-tab-active-line);
--bg-secondary: var(--light-bg-secondary);
--text-secondary: var(--light-text-secondary);
--text-muted: var(--light-text-muted);
--border-color: var(--light-border-color);
--settings-bg: var(--light-settings-bg);
--settings-card-bg: var(--light-settings-card-bg);
--settings-text: var(--light-settings-text);
--settings-text-secondary: var(--light-settings-text-secondary);
--settings-border: var(--light-settings-border);
--settings-input-bg: var(--light-settings-input-bg);
--settings-input-border: var(--light-settings-input-border);
--settings-hover: var(--light-settings-hover);
--scrollbar-track: var(--light-scrollbar-track);
--scrollbar-thumb: var(--light-scrollbar-thumb);
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
--selection-bg: var(--light-selection-bg);
--selection-text: var(--light-selection-text);
--text-danger: var(--light-danger-color);
--bg-primary: var(--light-bg-primary);
--bg-hover: var(--light-bg-hover);
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
--voidraft-loading-color: var(--light-loading-color);
--voidraft-loading-glow: var(--light-loading-glow);
--voidraft-loading-done-color: var(--light-loading-done-color);
--voidraft-loading-overlay: var(--light-loading-overlay);
}
/* 手动选择深色主题 */
:root[data-theme="dark"] {
--toolbar-bg: var(--dark-toolbar-bg);
--toolbar-border: var(--dark-toolbar-border);
--toolbar-text: var(--dark-toolbar-text);
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
--border-color: var(--dark-border-color);
--settings-bg: var(--dark-settings-bg);
--settings-card-bg: var(--dark-settings-card-bg);
--settings-text: var(--dark-settings-text);
--settings-text-secondary: var(--dark-settings-text-secondary);
--settings-border: var(--dark-settings-border);
--settings-input-bg: var(--dark-settings-input-bg);
--settings-input-border: var(--dark-settings-input-border);
--settings-hover: var(--dark-settings-hover);
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
}

View File

@@ -1,43 +1,19 @@
import {
AppConfig,
AppearanceConfig,
EditingConfig,
GeneralConfig,
AuthMethod,
LanguageType,
SystemThemeType,
TabType,
UpdatesConfig,
UpdateSourceType,
GitBackupConfig,
AuthMethod
UpdateSourceType
} from '@/../bindings/voidraft/internal/models/models';
import {FONT_OPTIONS} from './fonts';
// 配置键映射和限制的类型定义
export type GeneralConfigKeyMap = {
readonly [K in keyof GeneralConfig]: string;
};
export type EditingConfigKeyMap = {
readonly [K in keyof EditingConfig]: string;
};
export type AppearanceConfigKeyMap = {
readonly [K in keyof AppearanceConfig]: string;
};
export type UpdatesConfigKeyMap = {
readonly [K in keyof UpdatesConfig]: string;
};
export type BackupConfigKeyMap = {
readonly [K in keyof GitBackupConfig]: string;
};
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
// 配置键映射
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
// 统一配置键映射(平级展开)
export const CONFIG_KEY_MAP = {
// general
alwaysOnTop: 'general.alwaysOnTop',
dataPath: 'general.dataPath',
enableSystemTray: 'general.enableSystemTray',
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
enableWindowSnap: 'general.enableWindowSnap',
enableLoadingAnimation: 'general.enableLoadingAnimation',
enableTabs: 'general.enableTabs',
} as const;
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
// editing
fontSize: 'editing.fontSize',
fontFamily: 'editing.fontFamily',
fontWeight: 'editing.fontWeight',
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
enableTabIndent: 'editing.enableTabIndent',
tabSize: 'editing.tabSize',
tabType: 'editing.tabType',
autoSaveDelay: 'editing.autoSaveDelay'
} as const;
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
autoSaveDelay: 'editing.autoSaveDelay',
// appearance
language: 'appearance.language',
systemTheme: 'appearance.systemTheme',
currentTheme: 'appearance.currentTheme'
} as const;
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
currentTheme: 'appearance.currentTheme',
// updates
version: 'updates.version',
autoUpdate: 'updates.autoUpdate',
primarySource: 'updates.primarySource',
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
backupBeforeUpdate: 'updates.backupBeforeUpdate',
updateTimeout: 'updates.updateTimeout',
github: 'updates.github',
gitea: 'updates.gitea'
} as const;
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
gitea: 'updates.gitea',
// backup
enabled: 'backup.enabled',
repo_url: 'backup.repo_url',
auth_method: 'backup.auth_method',
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
auto_backup: 'backup.auto_backup',
} as const;
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
// 配置限制
export const CONFIG_LIMITS = {
fontSize: {min: 12, max: 28, default: 13},

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,3 @@
/**
* 默认翻译配置
*/
export const DEFAULT_TRANSLATION_CONFIG = {
minSelectionLength: 2,
maxTranslationLength: 5000,
} as const;
/**
* 翻译相关的错误消息
*/
export const TRANSLATION_ERRORS = {
NO_TEXT: 'no text to translate',
TRANSLATION_FAILED: 'translation failed',
} as const;
/**
* 翻译结果接口
*/
export interface TranslationResult {
translatedText: string;
error?: string;
}
/**
* 语言信息接口
*/
export interface LanguageInfo {
Code: string; // 语言代码
Name: string; // 语言名称
}
/**
* 翻译器扩展配置
*/
export interface TranslatorConfig {
/** 最小选择字符数才显示翻译按钮 */
minSelectionLength: number;
/** 最大翻译字符数 */
maxTranslationLength: number;
}
/**
* 翻译图标SVG
*/

View File

@@ -1,159 +0,0 @@
// Enclose abbreviations in <abbr> tags
//
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
/**
* 环境接口,包含缩写定义
*/
interface AbbrEnv {
abbreviations?: { [key: string]: string };
}
/**
* markdown-it-abbr 插件
* 用于支持缩写语法
*/
export default function abbr_plugin(md: MarkdownIt): void {
const escapeRE = md.utils.escapeRE;
const arrayReplaceAt = md.utils.arrayReplaceAt;
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
// you can check character classes here:
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
const OTHER_CHARS = ' \r\n$+<=>^`|~';
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
let labelEnd: number;
let pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
if (pos + 2 >= max) { return false; }
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
const labelStart = pos;
for (; pos < max; pos++) {
const ch = state.src.charCodeAt(pos);
if (ch === 0x5B /* [ */) {
return false;
} else if (ch === 0x5D /* ] */) {
labelEnd = pos;
break;
} else if (ch === 0x5C /* \ */) {
pos++;
}
}
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
return false;
}
if (silent) { return true; }
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
const title = state.src.slice(labelEnd! + 2, max).trim();
if (label.length === 0) { return false; }
if (title.length === 0) { return false; }
const env = state.env as AbbrEnv;
if (!env.abbreviations) { env.abbreviations = {}; }
// prepend ':' to avoid conflict with Object.prototype members
if (typeof env.abbreviations[':' + label] === 'undefined') {
env.abbreviations[':' + label] = title;
}
state.line = startLine + 1;
return true;
}
function abbr_replace(state: StateCore): void {
const blockTokens = state.tokens;
const env = state.env as AbbrEnv;
if (!env.abbreviations) { return; }
const regSimple = new RegExp('(?:' +
Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') +
')');
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
'(' + Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') + ')' +
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
const reg = new RegExp(regText, 'g');
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
for (let i = tokens.length - 1; i >= 0; i--) {
const currentToken = tokens[i];
if (currentToken.type !== 'text') { continue; }
let pos = 0;
const text = currentToken.content;
reg.lastIndex = 0;
const nodes: Token[] = [];
// fast regexp run to determine whether there are any abbreviated words
// in the current token
if (!regSimple.test(text)) { continue; }
let m: RegExpExecArray | null;
while ((m = reg.exec(text))) {
if (m.index > 0 || m[1].length > 0) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos, m.index + m[1].length);
nodes.push(token);
}
const token_o = new state.Token('abbr_open', 'abbr', 1);
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
nodes.push(token_o);
const token_t = new state.Token('text', '', 0);
token_t.content = m[2];
nodes.push(token_t);
const token_c = new state.Token('abbr_close', 'abbr', -1);
nodes.push(token_c);
reg.lastIndex -= m[3].length;
pos = reg.lastIndex;
}
if (!nodes.length) { continue; }
if (pos < text.length) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos);
nodes.push(token);
}
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
}
}
}
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
}

View File

@@ -1,209 +0,0 @@
// Process definition lists
//
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
/**
* markdown-it-deflist 插件
* 用于支持定义列表语法
*/
export default function deflist_plugin(md: MarkdownIt): void {
const isSpace = md.utils.isSpace;
// Search `[:~][\n ]`, returns next pos after marker on success
// or -1 on fail.
function skipMarker(state: StateBlock, line: number): number {
let start = state.bMarks[line] + state.tShift[line];
const max = state.eMarks[line];
if (start >= max) { return -1; }
// Check bullet
const marker = state.src.charCodeAt(start++);
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
const pos = state.skipSpaces(start);
// require space after ":"
if (start === pos) { return -1; }
// no empty definitions, e.g. " : "
if (pos >= max) { return -1; }
return start;
}
function markTightParagraphs(state: StateBlock, idx: number): void {
const level = state.level + 2;
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
state.tokens[i + 2].hidden = true;
state.tokens[i].hidden = true;
i += 2;
}
}
}
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
if (silent) {
// quirk: validation mode validates a dd block only, not a whole deflist
if (state.ddIndent < 0) { return false; }
return skipMarker(state, startLine) >= 0;
}
let nextLine = startLine + 1;
if (nextLine >= endLine) { return false; }
if (state.isEmpty(nextLine)) {
nextLine++;
if (nextLine >= endLine) { return false; }
}
if (state.sCount[nextLine] < state.blkIndent) { return false; }
let contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { return false; }
// Start list
const listTokIdx = state.tokens.length;
let tight = true;
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
const listLines: [number, number] = [startLine, 0];
token_dl_o.map = listLines;
//
// Iterate list items
//
let dtLine = startLine;
let ddLine = nextLine;
// One definition list can contain multiple DTs,
// and one DT can be followed by multiple DDs.
//
// Thus, there is two loops here, and label is
// needed to break out of the second one
//
/* eslint no-labels:0,block-scoped-var:0 */
OUTER:
for (;;) {
let prevEmptyEnd = false;
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
token_dt_o.map = [dtLine, dtLine];
const token_i: Token = state.push('inline', '', 0);
token_i.map = [dtLine, dtLine];
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
token_i.children = [];
state.push('dt_close', 'dt', -1);
for (;;) {
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
const itemLines: [number, number] = [nextLine, 0];
token_dd_o.map = itemLines;
let pos = contentStart;
const max = state.eMarks[ddLine];
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
contentStart = pos;
const oldTight = state.tight;
const oldDDIndent = state.ddIndent;
const oldIndent = state.blkIndent;
const oldTShift = state.tShift[ddLine];
const oldSCount = state.sCount[ddLine];
const oldParentType = state.parentType;
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
state.sCount[ddLine] = offset;
state.tight = true;
state.parentType = 'deflist' as any;
state.md.block.tokenize(state, ddLine, endLine);
// If any of list item is tight, mark list as tight
if (!state.tight || prevEmptyEnd) {
tight = false;
}
// Item become loose if finish with empty line,
// but we should filter last element, because it means list finish
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
state.tShift[ddLine] = oldTShift;
state.sCount[ddLine] = oldSCount;
state.tight = oldTight;
state.parentType = oldParentType;
state.blkIndent = oldIndent;
state.ddIndent = oldDDIndent;
state.push('dd_close', 'dd', -1);
itemLines[1] = nextLine = state.line;
if (nextLine >= endLine) { break OUTER; }
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { break; }
ddLine = nextLine;
// go to the next loop iteration:
// insert DD tag and repeat checking
}
if (nextLine >= endLine) { break; }
dtLine = nextLine;
if (state.isEmpty(dtLine)) { break; }
if (state.sCount[dtLine] < state.blkIndent) { break; }
ddLine = dtLine + 1;
if (ddLine >= endLine) { break; }
if (state.isEmpty(ddLine)) { ddLine++; }
if (ddLine >= endLine) { break; }
if (state.sCount[ddLine] < state.blkIndent) { break; }
contentStart = skipMarker(state, ddLine);
if (contentStart < 0) { break; }
// go to the next loop iteration:
// insert DT and DD tags and repeat checking
}
// Finilize list
state.push('dl_close', 'dl', -1);
listLines[1] = nextLine;
state.line = nextLine;
// mark paragraphs tight if needed
if (tight) {
markTightParagraphs(state, listTokIdx);
}
return true;
}
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
}

View File

@@ -1,4 +0,0 @@
export { default as bare } from './lib/bare';
export { default as light } from './lib/light';
export { default as full } from './lib/full';

View File

@@ -1,26 +0,0 @@
import MarkdownIt from 'markdown-it';
import emoji_html from './render';
import emoji_replace from './replace';
import normalize_opts, { EmojiOptions } from './normalize_opts';
/**
* Bare emoji 插件(不包含预定义的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: {},
shortcuts: {},
enabled: []
};
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
md.renderer.rules.emoji = emoji_html;
md.core.ruler.after(
'linkify',
'emoji',
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
);
}

View File

@@ -1,158 +0,0 @@
// Generated, don't edit
import { EmojiDefs } from '../normalize_opts';
const emojies: EmojiDefs = {
"grinning": "😀",
"smiley": "😃",
"smile": "😄",
"grin": "😁",
"laughing": "😆",
"satisfied": "😆",
"sweat_smile": "😅",
"joy": "😂",
"wink": "😉",
"blush": "😊",
"innocent": "😇",
"heart_eyes": "😍",
"kissing_heart": "😘",
"kissing": "😗",
"kissing_closed_eyes": "😚",
"kissing_smiling_eyes": "😙",
"yum": "😋",
"stuck_out_tongue": "😛",
"stuck_out_tongue_winking_eye": "😜",
"stuck_out_tongue_closed_eyes": "😝",
"neutral_face": "😐",
"expressionless": "😑",
"no_mouth": "😶",
"smirk": "😏",
"unamused": "😒",
"relieved": "😌",
"pensive": "😔",
"sleepy": "😪",
"sleeping": "😴",
"mask": "😷",
"dizzy_face": "😵",
"sunglasses": "😎",
"confused": "😕",
"worried": "😟",
"open_mouth": "😮",
"hushed": "😯",
"astonished": "😲",
"flushed": "😳",
"frowning": "😦",
"anguished": "😧",
"fearful": "😨",
"cold_sweat": "😰",
"disappointed_relieved": "😥",
"cry": "😢",
"sob": "😭",
"scream": "😱",
"confounded": "😖",
"persevere": "😣",
"disappointed": "😞",
"sweat": "😓",
"weary": "😩",
"tired_face": "😫",
"rage": "😡",
"pout": "😡",
"angry": "😠",
"smiling_imp": "😈",
"smiley_cat": "😺",
"smile_cat": "😸",
"joy_cat": "😹",
"heart_eyes_cat": "😻",
"smirk_cat": "😼",
"kissing_cat": "😽",
"scream_cat": "🙀",
"crying_cat_face": "😿",
"pouting_cat": "😾",
"heart": "❤️",
"hand": "✋",
"raised_hand": "✋",
"v": "✌️",
"point_up": "☝️",
"fist_raised": "✊",
"fist": "✊",
"monkey_face": "🐵",
"cat": "🐱",
"cow": "🐮",
"mouse": "🐭",
"coffee": "☕",
"hotsprings": "♨️",
"anchor": "⚓",
"airplane": "✈️",
"hourglass": "⌛",
"watch": "⌚",
"sunny": "☀️",
"star": "⭐",
"cloud": "☁️",
"umbrella": "☔",
"zap": "⚡",
"snowflake": "❄️",
"sparkles": "✨",
"black_joker": "🃏",
"mahjong": "🀄",
"phone": "☎️",
"telephone": "☎️",
"envelope": "✉️",
"pencil2": "✏️",
"black_nib": "✒️",
"scissors": "✂️",
"wheelchair": "♿",
"warning": "⚠️",
"aries": "♈",
"taurus": "♉",
"gemini": "♊",
"cancer": "♋",
"leo": "♌",
"virgo": "♍",
"libra": "♎",
"scorpius": "♏",
"sagittarius": "♐",
"capricorn": "♑",
"aquarius": "♒",
"pisces": "♓",
"heavy_multiplication_x": "✖️",
"heavy_plus_sign": "",
"heavy_minus_sign": "",
"heavy_division_sign": "➗",
"bangbang": "‼️",
"interrobang": "⁉️",
"question": "❓",
"grey_question": "❔",
"grey_exclamation": "❕",
"exclamation": "❗",
"heavy_exclamation_mark": "❗",
"wavy_dash": "〰️",
"recycle": "♻️",
"white_check_mark": "✅",
"ballot_box_with_check": "☑️",
"heavy_check_mark": "✔️",
"x": "❌",
"negative_squared_cross_mark": "❎",
"curly_loop": "➰",
"loop": "➿",
"part_alternation_mark": "〽️",
"eight_spoked_asterisk": "✳️",
"eight_pointed_black_star": "✴️",
"sparkle": "❇️",
"copyright": "©️",
"registered": "®️",
"tm": "™️",
"information_source": "",
"m": "Ⓜ️",
"black_circle": "⚫",
"white_circle": "⚪",
"black_large_square": "⬛",
"white_large_square": "⬜",
"black_medium_square": "◼️",
"white_medium_square": "◻️",
"black_medium_small_square": "◾",
"white_medium_small_square": "◽",
"black_small_square": "▪️",
"white_small_square": "▫️"
};
export default emojies;

View File

@@ -1,45 +0,0 @@
// Emoticons -> Emoji mapping.
//
// (!) Some patterns skipped, to avoid collisions
// without increase matcher complicity. Than can change in future.
//
// Places to look for more emoticons info:
//
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
// - https://github.com/wooorm/emoticon/blob/master/Support.md
// - http://factoryjoe.com/projects/emoticons/
//
import { EmojiShortcuts } from '../normalize_opts';
const shortcuts: EmojiShortcuts = {
angry: ['>:(', '>:-('],
blush: [':")', ':-")'],
broken_heart: ['</3', '<\\3'],
// :\ and :-\ not used because of conflict with markdown escaping
confused: [':/', ':-/'], // twemoji shows question
cry: [":'(", ":'-(", ':,(', ':,-('],
frowning: [':(', ':-('],
heart: ['<3'],
imp: [']:(', ']:-('],
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
kissing: [':*', ':-*'],
laughing: ['x-)', 'X-)'],
neutral_face: [':|', ':-|'],
open_mouth: [':o', ':-o', ':O', ':-O'],
rage: [':@', ':-@'],
smile: [':D', ':-D'],
smiley: [':)', ':-)'],
smiling_imp: [']:)', ']:-)'],
sob: [":,'(", ":,'-(", ';(', ';-('],
stuck_out_tongue: [':P', ':-P'],
sunglasses: ['8-)', 'B-)'],
sweat: [',:(', ',:-('],
sweat_smile: [',:)', ',:-)'],
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
wink: [';)', ';-)']
};
export default shortcuts;

View File

@@ -1,21 +0,0 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/full';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Full emoji 插件(包含完整的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -1,21 +0,0 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/light';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Light emoji 插件(包含常用的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -1,95 +0,0 @@
/**
* Emoji 定义类型
*/
export interface EmojiDefs {
[key: string]: string;
}
/**
* Emoji 快捷方式类型
*/
export interface EmojiShortcuts {
[key: string]: string | string[];
}
/**
* 输入选项接口
*/
export interface EmojiOptions {
defs: EmojiDefs;
shortcuts: EmojiShortcuts;
enabled: string[];
}
/**
* 标准化后的选项接口
*/
export interface NormalizedEmojiOptions {
defs: EmojiDefs;
shortcuts: { [key: string]: string };
scanRE: RegExp;
replaceRE: RegExp;
}
/**
* 转义正则表达式特殊字符
*/
function quoteRE(str: string): string {
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
}
/**
* 将输入选项转换为更可用的格式并编译搜索正则表达式
*/
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
let emojies = options.defs;
// Filter emojies by whitelist, if needed
if (options.enabled.length) {
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
return acc;
}, {});
}
// Flatten shortcuts to simple object: { alias: emoji_name }
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
// Skip aliases for filtered emojies, to reduce regexp
if (!emojies[key]) return acc;
if (Array.isArray(options.shortcuts[key])) {
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
return acc;
}
acc[options.shortcuts[key] as string] = key;
return acc;
}, {});
const keys = Object.keys(emojies);
let names: string;
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
if (keys.length === 0) {
names = '^$';
} else {
// Compile regexp
names = keys
.map((name: string) => { return `:${name}:`; })
.concat(Object.keys(shortcuts))
.sort()
.reverse()
.map((name: string) => { return quoteRE(name); })
.join('|');
}
const scanRE = RegExp(names);
const replaceRE = RegExp(names, 'g');
return {
defs: emojies,
shortcuts,
scanRE,
replaceRE
};
}

View File

@@ -1,9 +0,0 @@
import { Token } from 'markdown-it';
/**
* Emoji 渲染函数
*/
export default function emoji_html(tokens: Token[], idx: number): string {
return tokens[idx].content;
}

View File

@@ -1,97 +0,0 @@
import MarkdownIt, { StateCore, Token } from 'markdown-it';
import { EmojiDefs } from './normalize_opts';
/**
* Emoji 和快捷方式替换逻辑
*
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
* 但是,谁在乎呢...
*/
export default function create_rule(
md: MarkdownIt,
emojies: EmojiDefs,
shortcuts: { [key: string]: string },
scanRE: RegExp,
replaceRE: RegExp
) {
const arrayReplaceAt = md.utils.arrayReplaceAt;
const ucm = md.utils.lib.ucmicro;
const has = md.utils.has;
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
let last_pos = 0;
const nodes: Token[] = [];
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
let emoji_name: string;
// Validate emoji name
if (has(shortcuts, match)) {
// replace shortcut with full name
emoji_name = shortcuts[match];
// Don't allow letters before any shortcut (as in no ":/" in http://)
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
// Don't allow letters after any shortcut
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
return '';
}
} else {
emoji_name = match.slice(1, -1);
}
// Add new tokens to pending list
if (offset > last_pos) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos, offset);
nodes.push(token);
}
const token = new TokenConstructor('emoji', '', 0);
token.markup = emoji_name;
token.content = emojies[emoji_name];
nodes.push(token);
last_pos = offset + match.length;
return '';
});
if (last_pos < text.length) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos);
nodes.push(token);
}
return nodes;
}
return function emoji_replace(state: StateCore): void {
let token: Token;
const blockTokens = state.tokens;
let autolinkLevel = 0;
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
// Use reversed logic in links start/end match
for (let i = tokens.length - 1; i >= 0; i--) {
token = tokens[i];
if (token.type === 'link_open' || token.type === 'link_close') {
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
}
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(
tokens, i, splitTextToken(token.content, token.level, state.Token)
);
}
}
}
};
}

View File

@@ -1,390 +0,0 @@
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
/**
* 脚注元数据接口
*/
interface FootnoteMeta {
id: number;
subId: number;
label: string;
}
/**
* 脚注列表项接口
*/
interface FootnoteItem {
label?: string;
content?: string;
tokens?: Token[];
count: number;
}
/**
* 环境接口
*/
interface FootnoteEnv {
footnotes?: {
refs?: { [key: string]: number };
list?: FootnoteItem[];
};
docId?: string;
}
/// /////////////////////////////////////////////////////////////////////////////
// Renderer partials
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
const n = Number(tokens[idx].meta.id + 1).toString();
let prefix = '';
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
return prefix + n;
}
function render_footnote_caption(tokens: Token[], idx: number): string {
let n = Number(tokens[idx].meta.id + 1).toString();
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
return `[${n}]`;
}
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
let refid = id;
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
}
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
'<section class="footnotes">\n' +
'<ol class="footnotes-list">\n';
}
function render_footnote_block_close(): string {
return '</ol>\n</section>\n';
}
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
return `<li id="fn${id}" class="footnote-item">`;
}
function render_footnote_close(): string {
return '</li>\n';
}
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
}
/**
* markdown-it-footnote 插件
* 用于支持脚注语法
*/
export default function footnote_plugin(md: MarkdownIt): void {
const parseLinkLabel = md.helpers.parseLinkLabel;
const isSpace = md.utils.isSpace;
md.renderer.rules.footnote_ref = render_footnote_ref;
md.renderer.rules.footnote_block_open = render_footnote_block_open;
md.renderer.rules.footnote_block_close = render_footnote_block_close;
md.renderer.rules.footnote_open = render_footnote_open;
md.renderer.rules.footnote_close = render_footnote_close;
md.renderer.rules.footnote_anchor = render_footnote_anchor;
// helpers (only used in other rules, no tokens are attached to those)
md.renderer.rules.footnote_caption = render_footnote_caption;
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
// Process footnote block definition
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// line should be at least 5 chars - "[^x]:"
if (start + 4 > max) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
if (silent) return true;
pos++;
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.refs) env.footnotes.refs = {};
const label = state.src.slice(start + 2, pos - 2);
env.footnotes.refs[`:${label}`] = -1;
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
token_fref_o.meta = { label };
token_fref_o.level = state.level++;
state.tokens.push(token_fref_o);
const oldBMark = state.bMarks[startLine];
const oldTShift = state.tShift[startLine];
const oldSCount = state.sCount[startLine];
const oldParentType = state.parentType;
const posAfterColon = pos;
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
let offset = initial;
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
state.tShift[startLine] = pos - posAfterColon;
state.sCount[startLine] = offset - initial;
state.bMarks[startLine] = posAfterColon;
state.blkIndent += 4;
state.parentType = 'footnote' as any;
if (state.sCount[startLine] < state.blkIndent) {
state.sCount[startLine] += state.blkIndent;
}
state.md.block.tokenize(state, startLine, endLine);
state.parentType = oldParentType;
state.blkIndent -= 4;
state.tShift[startLine] = oldTShift;
state.sCount[startLine] = oldSCount;
state.bMarks[startLine] = oldBMark;
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
token_fref_c.level = --state.level;
state.tokens.push(token_fref_c);
return true;
}
// Process inline footnotes (^[...])
function footnote_inline(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (start + 2 >= max) return false;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
const labelStart = start + 2;
const labelEnd = parseLinkLabel(state, start + 1);
// parser failed to find ']', so it's not a valid note
if (labelEnd < 0) return false;
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.list) env.footnotes.list = [];
const footnoteId = env.footnotes.list.length;
const tokens: Token[] = [];
state.md.inline.parse(
state.src.slice(labelStart, labelEnd),
state.md,
state.env,
tokens
);
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId };
env.footnotes.list[footnoteId] = {
content: state.src.slice(labelStart, labelEnd),
tokens,
count: 0
};
}
state.pos = labelEnd + 1;
state.posMax = max;
return true;
}
// Process footnote references ([^...])
function footnote_ref(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
// should be at least 4 chars - "[^x]"
if (start + 3 > max) return false;
const env = state.env as FootnoteEnv;
if (!env.footnotes || !env.footnotes.refs) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x0A) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos >= max) return false;
pos++;
const label = state.src.slice(start + 2, pos - 1);
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
if (!silent) {
if (!env.footnotes.list) env.footnotes.list = [];
let footnoteId: number;
if (env.footnotes.refs[`:${label}`] < 0) {
footnoteId = env.footnotes.list.length;
env.footnotes.list[footnoteId] = { label, count: 0 };
env.footnotes.refs[`:${label}`] = footnoteId;
} else {
footnoteId = env.footnotes.refs[`:${label}`];
}
const footnoteSubId = env.footnotes.list[footnoteId].count;
env.footnotes.list[footnoteId].count++;
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId, subId: footnoteSubId, label };
}
state.pos = pos;
state.posMax = max;
return true;
}
// Glue footnote tokens to end of token stream
function footnote_tail(state: StateCore): void {
let tokens: Token[] | null = null;
let current: Token[];
let currentLabel: string;
let insideRef = false;
const refTokens: { [key: string]: Token[] } = {};
const env = state.env as FootnoteEnv;
if (!env.footnotes) { return; }
state.tokens = state.tokens.filter(function (tok) {
if (tok.type === 'footnote_reference_open') {
insideRef = true;
current = [];
currentLabel = tok.meta.label;
return false;
}
if (tok.type === 'footnote_reference_close') {
insideRef = false;
// prepend ':' to avoid conflict with Object.prototype members
refTokens[':' + currentLabel] = current;
return false;
}
if (insideRef) { current.push(tok); }
return !insideRef;
});
if (!env.footnotes.list) { return; }
const list = env.footnotes.list;
state.tokens.push(new state.Token('footnote_block_open', '', 1));
for (let i = 0, l = list.length; i < l; i++) {
const token_fo = new state.Token('footnote_open', '', 1);
token_fo.meta = { id: i, label: list[i].label };
state.tokens.push(token_fo);
if (list[i].tokens) {
tokens = [];
const token_po = new state.Token('paragraph_open', 'p', 1);
token_po.block = true;
tokens.push(token_po);
const token_i = new state.Token('inline', '', 0);
token_i.children = list[i].tokens || null;
token_i.content = list[i].content || '';
tokens.push(token_i);
const token_pc = new state.Token('paragraph_close', 'p', -1);
token_pc.block = true;
tokens.push(token_pc);
} else if (list[i].label) {
tokens = refTokens[`:${list[i].label}`] || null;
}
if (tokens) state.tokens = state.tokens.concat(tokens);
let lastParagraph: Token | null;
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
lastParagraph = state.tokens.pop()!;
} else {
lastParagraph = null;
}
const t = list[i].count > 0 ? list[i].count : 1;
for (let j = 0; j < t; j++) {
const token_a = new state.Token('footnote_anchor', '', 0);
token_a.meta = { id: i, subId: j, label: list[i].label };
state.tokens.push(token_a);
}
if (lastParagraph) {
state.tokens.push(lastParagraph);
}
state.tokens.push(new state.Token('footnote_close', '', -1));
}
state.tokens.push(new state.Token('footnote_block_close', '', -1));
}
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
}

View File

@@ -1,160 +0,0 @@
import MarkdownIt, { StateInline, Token } from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-ins 插件
* 用于支持插入文本语法 ++text++
*/
export default function ins_plugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x2B/* + */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
let token: Token;
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x2B/* + */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
token = state.tokens[startDelim.token];
token.type = 'ins_open';
token.tag = 'ins';
token.nesting = 1;
token.markup = '++';
token.content = '';
token = state.tokens[endDelim.token];
token.type = 'ins_close';
token.tag = 'ins';
token.nesting = -1;
token.markup = '++';
token.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '+') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
j++;
}
j--;
if (i !== j) {
token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'ins', tokenize);
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -1,160 +0,0 @@
import MarkdownIt, {StateInline, Token} from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-mark 插件
* 用于支持 ==标记文本== 语法
*/
export default function markPlugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x3D/* = */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x3D/* = */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
const token_o = state.tokens[startDelim.token];
token_o.type = 'mark_open';
token_o.tag = 'mark';
token_o.nesting = 1;
token_o.markup = '==';
token_o.content = '';
const token_c = state.tokens[endDelim.token];
token_c.type = 'mark_close';
token_c.tag = 'mark';
token_c.nesting = -1;
token_c.markup = '==';
token_c.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '=') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
j++;
}
j--;
if (i !== j) {
const token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'mark', tokenize);
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
let curr: number;
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -1,106 +0,0 @@
import mermaid from "mermaid";
import {genUid, hashCode, sleep} from "./utils";
const mermaidCache = new Map<string, HTMLElement>();
// 缓存计数器,用于清除缓存
const mermaidCacheCount = new Map<string, number>();
let count = 0;
let countTmo = setTimeout(() => undefined, 0);
const addCount = () => {
clearTimeout(countTmo);
countTmo = setTimeout(() => {
count++;
clearCache();
}, 500);
};
const clearCache = () => {
for (const key of mermaidCacheCount.keys()) {
const value = mermaidCacheCount.get(key)!;
if (value + 3 < count) {
mermaidCache.delete(key);
mermaidCacheCount.delete(key);
}
}
};
/**
* 渲染 mermaid
* @param code mermaid 代码
* @param targetId 目标 id
* @param count 计数器
*/
const renderMermaid = async (code: string, targetId: string, count: number) => {
let limit = 100;
while (limit-- > 0) {
const container = document.getElementById(targetId);
if (!container) {
await sleep(100);
continue;
}
try {
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
container.innerHTML = svg;
mermaidCache.set(targetId, container);
mermaidCacheCount.set(targetId, count);
} catch (e) {
}
break;
}
};
export interface MermaidItOptions {
theme?: "default" | "dark" | "forest" | "neutral" | "base";
}
/**
* 更新 mermaid 主题
*/
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
mermaid.initialize({
startOnLoad: false,
theme: theme
});
// 清空缓存,强制重新渲染
mermaidCache.clear();
mermaidCacheCount.clear();
};
/**
* mermaid 插件
* @param md markdown-it
* @param options 配置选项
* @constructor MermaidIt
*/
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
const theme = options?.theme || "default";
mermaid.initialize({
startOnLoad: false,
theme: theme
});
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
addCount();
const token = tokens[idx];
const info = token.info.trim();
if (info === "mermaid") {
const containerId = "mermaid-container-" + hashCode(token.content);
const container = document.createElement("div");
container.id = containerId;
if (mermaidCache.has(containerId)) {
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
mermaidCacheCount.set(containerId, count);
} else {
renderMermaid(token.content, containerId, count).then();
}
return container.outerHTML;
}
// 使用默认的渲染规则
return defaultRenderer(tokens, idx, options, env, self);
};
};

View File

@@ -1,49 +0,0 @@
import { v4 as uuidv4 } from "uuid";
/**
* uuid 生成函数
* @param split 分隔符
*/
export const genUid = (split = "") => {
return uuidv4().split("-").join(split);
};
/**
* 一个简易的sleep函数
*/
export const sleep = async (ms: number) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
/**
* 计算字符串的hash值
* 返回一个数字
* @param str
*/
export const hashCode = (str: string) => {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
};
/**
* 一个简易的阻塞函数
*/
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
const start = Date.now();
while (true) {
if (cb()) return true;
if (timeout && Date.now() - start > timeout) {
console.error("阻塞超时: " + errText);
return false;
}
await sleep(100);
}
};

View File

@@ -1,66 +0,0 @@
// Process ~subscript~
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function subscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sub_open', 'sub', 1);
token_so.markup = '~';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sub_close', 'sub', -1);
token_sc.markup = '~';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sub 插件
* 用于支持下标语法 ~text~
*/
export default function sub_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sub', subscript);
}

View File

@@ -1,66 +0,0 @@
// Process ^superscript^
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function superscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sup_open', 'sup', 1);
token_so.markup = '^';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sup_close', 'sup', -1);
token_sc.markup = '^';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sup 插件
* 用于支持上标语法 ^text^
*/
export default function sup_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sup', superscript);
}

View File

@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--voidraft-mono-font, monospace),serif;
font-family: Menlo, monospace,serif;
}
.loading-word {
@@ -175,4 +175,4 @@ onBeforeUnmount(() => {
top: 0;
pointer-events: none;
}
</style>
</style>

View File

@@ -147,7 +147,7 @@ onUnmounted(() => {
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
color: var(--text-primary);
transition: all 0.15s ease;
gap: 8px;
@@ -165,7 +165,7 @@ onUnmounted(() => {
flex-shrink: 0;
width: 12px;
height: 12px;
color: var(--text-muted);
color: var(--text-primary);
transition: color 0.15s ease;
.menu-item:hover & {

View File

@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
const { t } = useI18n();
const editorStore = readonly(useEditorStore());
const editorStore = useEditorStore();
// 组件状态
const showLanguageMenu = shallowRef(false);

View File

@@ -13,20 +13,16 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
import {createDebounce} from '@/common/utils/debounce';
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
import {usePanelStore} from '@/stores/panelStore';
const editorStore = readonly(useEditorStore());
const configStore = readonly(useConfigStore());
const updateStore = readonly(useUpdateStore());
const windowStore = readonly(useWindowStore());
const systemStore = readonly(useSystemStore());
const panelStore = readonly(usePanelStore());
const editorStore = useEditorStore();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
const windowStore = useWindowStore();
const systemStore = useSystemStore();
const {t} = useI18n();
const router = useRouter();
const canFormatCurrentBlock = ref(false);
const canPreviewMarkdown = ref(false);
const isLoaded = shallowRef(false);
const { documentStats } = toRefs(editorStore);
@@ -37,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
});
// 当前文档的预览是否打开
const isCurrentBlockPreviewing = computed(() => {
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
});
// 切换窗口置顶状态
const toggleAlwaysOnTop = async () => {
@@ -69,22 +61,12 @@ const formatCurrentBlock = () => {
formatBlockContent(editorStore.editorView);
};
// 切换 Markdown 预览
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
toggleMarkdownPreview(editorStore.editorView as any);
}, { delay: 200 });
const togglePreview = () => {
debouncedTogglePreview();
};
// 统一更新按钮状态
const updateButtonStates = () => {
const view: any = editorStore.editorView;
if (!view) {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return;
}
@@ -95,7 +77,6 @@ const updateButtonStates = () => {
// 提前返回,减少不必要的计算
if (!activeBlock) {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return;
}
@@ -103,11 +84,9 @@ const updateButtonStates = () => {
const language = getLanguage(languageName as any);
canFormatCurrentBlock.value = Boolean(language?.prettier);
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
} catch (error) {
console.warn('Error checking block capabilities:', error);
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
}
};
@@ -161,7 +140,6 @@ watch(
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
}
});
},
@@ -255,21 +233,6 @@ const statsData = computed(() => ({
<!-- 块语言选择器 -->
<BlockLanguageSelector/>
<!-- Markdown预览按钮 -->
<div
v-if="canPreviewMarkdown"
class="preview-button"
:class="{ 'active': isCurrentBlockPreviewing }"
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
@click="togglePreview"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</div>
<!-- 格式化按钮 - 支持点击操作 -->
<div
v-if="canFormatCurrentBlock"

View File

@@ -111,7 +111,6 @@ export default {
deleteCharForward: 'Delete character forward',
deleteGroupBackward: 'Delete group backward',
deleteGroupForward: 'Delete group forward',
textHighlightToggle: 'Toggle text highlight',
}
},
tabs: {
@@ -161,53 +160,6 @@ export default {
customThemeColors: 'Custom Theme Colors',
resetToDefault: 'Reset to Default',
colorValue: 'Color Value',
themeColors: {
basic: 'Basic Colors',
text: 'Text Colors',
syntax: 'Syntax Highlighting',
interface: 'Interface Elements',
border: 'Borders & Dividers',
search: 'Search & Matching',
// Base Colors
background: 'Main Background',
backgroundSecondary: 'Secondary Background',
surface: 'Panel Background',
dropdownBackground: 'Dropdown Background',
dropdownBorder: 'Dropdown Border',
// Text Colors
foreground: 'Primary Text',
foregroundSecondary: 'Secondary Text',
comment: 'Comments',
// Syntax Highlighting - Core
keyword: 'Keywords',
string: 'Strings',
function: 'Functions',
number: 'Numbers',
operator: 'Operators',
variable: 'Variables',
type: 'Types',
// Syntax Highlighting - Extended
constant: 'Constants',
storage: 'Storage Type',
parameter: 'Parameters',
class: 'Class Names',
heading: 'Headings',
invalid: 'Invalid/Error',
regexp: 'Regular Expressions',
// Interface Elements
cursor: 'Cursor',
selection: 'Selection Background',
selectionBlur: 'Unfocused Selection',
activeLine: 'Active Line Highlight',
lineNumber: 'Line Numbers',
activeLineNumber: 'Active Line Number',
// Borders & Dividers
borderColor: 'Border Color',
borderLight: 'Light Border',
// Search & Matching
searchMatch: 'Search Match',
matchingBracket: 'Matching Bracket'
},
lineHeight: 'Line Height',
tabSettings: 'Tab Settings',
tabSize: 'Tab Size',
@@ -304,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',
@@ -322,23 +274,33 @@ 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: {
memory: 'Memory',
clickToClean: 'Click to clean memory'
}
};
};

View File

@@ -111,7 +111,6 @@ export default {
deleteCharForward: '向前删除字符',
deleteGroupBackward: '向后删除组',
deleteGroupForward: '向前删除组',
textHighlightToggle: '切换文本高亮',
}
},
tabs: {
@@ -202,54 +201,6 @@ export default {
customThemeColors: '自定义主题颜色',
resetToDefault: '重置为默认',
colorValue: '颜色值',
themeColors: {
basic: '基础色调',
text: '文本颜色',
syntax: '语法高亮',
interface: '界面元素',
border: '边框分割线',
search: '搜索匹配',
// 基础色调
background: '主背景色',
backgroundSecondary: '次要背景色',
surface: '面板背景',
dropdownBackground: '下拉菜单背景',
dropdownBorder: '下拉菜单边框',
// 文本颜色
foreground: '主文本色',
foregroundSecondary: '次要文本色',
comment: '注释色',
// 语法高亮 - 核心
keyword: '关键字',
string: '字符串',
function: '函数名',
number: '数字',
operator: '操作符',
variable: '变量',
type: '类型',
// 语法高亮 - 扩展
constant: '常量',
storage: '存储类型',
parameter: '参数',
class: '类名',
heading: '标题',
invalid: '无效内容',
regexp: '正则表达式',
// 界面元素
cursor: '光标',
selection: '选中背景',
selectionBlur: '失焦选中背景',
activeLine: '当前行高亮',
lineNumber: '行号',
activeLineNumber: '活动行号',
// 边框和分割线
borderColor: '边框色',
borderLight: '浅色边框',
// 搜索和匹配
searchMatch: '搜索匹配',
matchingBracket: '匹配括号'
},
hotkeyPreview: '预览:',
none: '无',
backup: {
@@ -307,7 +258,7 @@ export default {
},
colorSelector: {
name: '颜色选择器',
description: '颜色值的可视化和选择'
description: 'CSS代码块颜色值的可视化和选择'
},
translator: {
name: '划词翻译',
@@ -325,23 +276,33 @@ 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: {
memory: '内存',
clickToClean: '点击清理内存'
}
};
};

View File

@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
import {
AppConfig,
AppearanceConfig,
AuthMethod,
EditingConfig,
GeneralConfig,
GitBackupConfig,
LanguageType,
SystemThemeType,
TabType,
UpdatesConfig
TabType
} from '@/../bindings/voidraft/internal/models/models';
import {useI18n} from 'vue-i18n';
import {ConfigUtils} from '@/common/utils/configUtils';
import {FONT_OPTIONS} from '@/common/constant/fonts';
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
import {
APPEARANCE_CONFIG_KEY_MAP,
BACKUP_CONFIG_KEY_MAP,
CONFIG_KEY_MAP,
CONFIG_LIMITS,
ConfigKey,
ConfigSection,
DEFAULT_CONFIG,
EDITING_CONFIG_KEY_MAP,
GENERAL_CONFIG_KEY_MAP,
NumberConfigKey,
UPDATES_CONFIG_KEY_MAP
NumberConfigKey
} from '@/common/constant/config';
import * as runtime from '@wailsio/runtime';
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
// Font options (no longer localized)
const fontOptions = computed(() => FONT_OPTIONS);
// 计算属性 - 使用工厂函数简化
// 计算属性
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
const limits = Object.fromEntries(
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
// 通用配置更新方法
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
// 确保配置已加载
// 统一配置更新方法
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
const backendKey = CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for general.${key.toString()}`);
throw new Error(`No backend key mapping found for ${String(key)}`);
}
// 从 backendKey 提取 section例如 'general.alwaysOnTop' -> 'general'
const section = backendKey.split('.')[0] as ConfigSection;
await ConfigService.Set(backendKey, value);
state.config.general[key] = value;
(state.config[section] as any)[key] = value;
};
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = EDITING_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.editing[key] = value;
// 只更新本地状态,不保存到后端
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
const backendKey = CONFIG_KEY_MAP[key];
const section = backendKey.split('.')[0] as ConfigSection;
(state.config[section] as any)[key] = value;
};
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.appearance[key] = value;
};
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.updates[key] = value;
};
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.backup[key] = value;
// 保存指定配置到后端
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
const backendKey = CONFIG_KEY_MAP[key];
const section = backendKey.split('.')[0] as ConfigSection;
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
};
// 加载配置
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
return {
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
reset: async () => await updateEditingConfig(key, limit.default)
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
set: async (value: number) => await updateConfig(key, clamp(value)),
reset: async () => await updateConfig(key, limit.default),
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
};
};
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
// 枚举值切换器
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
async () => {
const currentIndex = values.indexOf(state.config.editing[key] as T);
const nextIndex = (currentIndex + 1) % values.length;
return await updateEditingConfig(key, values[nextIndex]);
return await updateConfig(key, values[nextIndex]);
};
// 重置配置
@@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => {
// 语言设置方法
const setLanguage = async (language: LanguageType): Promise<void> => {
await updateAppearanceConfig('language', language);
// 同步更新前端语言
await updateConfig('language', language);
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
locale.value = frontendLocale as any;
};
// 系统主题设置方法
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
await updateAppearanceConfig('systemTheme', systemTheme);
await updateConfig('systemTheme', systemTheme);
};
// 当前主题设置方法
const setCurrentTheme = async (themeName: string): Promise<void> => {
await updateAppearanceConfig('currentTheme', themeName);
await updateConfig('currentTheme', themeName);
};
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
const togglers = {
tabIndent: createEditingToggler('enableTabIndent'),
alwaysOnTop: async () => {
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
// 立即应用窗口置顶状态
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
},
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
};
// 字符串配置设置器
const setters = {
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
};
return {
// 状态
config: computed(() => state.config),
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
decreaseFontSize: adjusters.fontSize.decrease,
resetFontSize: adjusters.fontSize.reset,
setFontSize: adjusters.fontSize.set,
// 字体大小操作
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
saveFontSize: () => saveConfig('fontSize'),
// Tab操作
toggleTabIndent: togglers.tabIndent,
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
...adjusters.tabSize,
increaseTabSize: adjusters.tabSize.increase,
decreaseTabSize: adjusters.tabSize.decrease,
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
// 窗口操作
toggleAlwaysOnTop: togglers.alwaysOnTop,
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
// 字体操作
setFontFamily: setters.fontFamily,
setFontWeight: setters.fontWeight,
setFontFamily: (value: string) => updateConfig('fontFamily', value),
setFontWeight: (value: string) => updateConfig('fontWeight', value),
// 路径操作
setDataPath: setters.dataPath,
setDataPath: (value: string) => updateConfig('dataPath', value),
// 保存配置相关方法
setAutoSaveDelay: setters.autoSaveDelay,
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
// 热键配置相关方法
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
// 系统托盘配置相关方法
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
// 开机启动配置相关方法
setStartAtLogin: async (value: boolean) => {
// 先更新配置文件
await updateGeneralConfig('startAtLogin', value);
// 再调用系统设置API
await updateConfig('startAtLogin', value);
await StartupService.SetEnabled(value);
},
// 窗口吸附配置相关方法
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
// 加载动画配置相关方法
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
// 标签页配置相关方法
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
// 更新配置相关方法
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
// 备份配置相关方法
setEnableBackup: async (value: boolean) => {
await updateBackupConfig('enabled', value);
},
setAutoBackup: async (value: boolean) => {
await updateBackupConfig('auto_backup', value);
},
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
setUsername: async (value: string) => await updateBackupConfig('username', value),
setPassword: async (value: string) => await updateBackupConfig('password', value),
setToken: async (value: string) => await updateBackupConfig('token', value),
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
setRepoUrl: (value: string) => updateConfig('repo_url', value),
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
setUsername: (value: string) => updateConfig('username', value),
setPassword: (value: string) => updateConfig('password', value),
setToken: (value: string) => updateConfig('token', value),
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
};
});

View File

@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
import {EditorState, Extension} from '@codemirror/state';
import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore';
import {usePanelStore} from './panelStore';
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
import {ensureSyntaxTree} from "@codemirror/language";
@@ -14,6 +13,7 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
import {
createDynamicExtensions,
@@ -29,7 +29,6 @@ import {generateContentHash} from "@/common/utils/hashUtils";
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor';
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
import {createDebounce} from '@/common/utils/debounce';
export interface DocumentStats {
@@ -242,6 +241,13 @@ export const useEditorStore = defineStore('editor', () => {
fontWeight: configStore.config.editing.fontWeight
});
const wheelZoomExtension = createWheelZoomExtension({
increaseFontSize: () => configStore.increaseFontSizeLocal(),
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
onSave: () => configStore.saveFontSize(),
saveDelay: 500
});
// 统计扩展
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
@@ -256,8 +262,6 @@ export const useEditorStore = defineStore('editor', () => {
const httpExtension = createHttpClientExtension();
// Markdown预览扩展
const previewExtension = markdownPreviewExtension();
// 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
@@ -287,12 +291,12 @@ export const useEditorStore = defineStore('editor', () => {
themeExtension,
...tabExtensions,
fontExtension,
wheelZoomExtension,
statsExtension,
contentChangeExtension,
codeBlockExtension,
...dynamicExtensions,
...httpExtension,
previewExtension
];
// 创建编辑器状态
@@ -635,6 +639,7 @@ export const useEditorStore = defineStore('editor', () => {
});
};
// 应用Tab设置
const applyTabSettings = () => {
editorCache.values().forEach(instance => {
@@ -687,10 +692,6 @@ export const useEditorStore = defineStore('editor', () => {
instance.view.destroy();
});
// 清理 panelStore 状态(导航离开编辑器页面时)
const panelStore = usePanelStore();
panelStore.reset();
currentEditor.value = null;
};
@@ -707,12 +708,15 @@ export const useEditorStore = defineStore('editor', () => {
// 更新前端编辑器扩展 - 应用于所有实例
const manager = getExtensionManager();
if (manager) {
// 使用立即更新模式,跳过防抖
manager.updateExtensionImmediate(id, enabled, config || {});
// 直接更新前端扩展至所有视图
manager.updateExtension(id, enabled, config);
}
// 重新加载扩展配置
await extensionStore.loadExtensions();
if (manager) {
manager.initExtensions(extensionStore.extensions);
}
await applyKeymapSettings();
};
@@ -781,4 +785,4 @@ export const useEditorStore = defineStore('editor', () => {
editorView: currentEditor,
};
});
});

View File

@@ -1,170 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { EditorView } from '@codemirror/view';
import { useDocumentStore } from './documentStore';
/**
* 单个文档的预览状态
*/
interface DocumentPreviewState {
isOpen: boolean;
isClosing: boolean;
blockFrom: number;
blockTo: number;
}
/**
* 面板状态管理 Store
* 管理编辑器中各种面板的显示状态按文档ID区分
*/
export const usePanelStore = defineStore('panel', () => {
// 当前编辑器视图引用
const editorView = ref<EditorView | null>(null);
// 每个文档的预览状态 Map<documentId, PreviewState>
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
/**
* 获取当前文档的预览状态
*/
const markdownPreview = computed(() => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) {
return {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
}
return documentPreviews.value.get(currentDocId) || {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
});
/**
* 设置编辑器视图
*/
const setEditorView = (view: EditorView | null) => {
editorView.value = view;
};
/**
* 打开 Markdown 预览面板
*/
const openMarkdownPreview = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: true,
isClosing: false,
blockFrom: from,
blockTo: to
});
};
/**
* 开始关闭 Markdown 预览面板
*/
const startClosingMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
isClosing: true
});
}
};
/**
* 关闭 Markdown 预览面板
*/
const closeMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
});
};
/**
* 更新预览块的范围(用于实时预览)
*/
const updatePreviewRange = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
blockFrom: from,
blockTo: to
});
}
};
/**
* 检查指定块是否正在预览
*/
const isBlockPreviewing = (from: number, to: number): boolean => {
const preview = markdownPreview.value;
return preview.isOpen &&
preview.blockFrom === from &&
preview.blockTo === to;
};
/**
* 重置所有面板状态
*/
const reset = () => {
documentPreviews.value.clear();
editorView.value = null;
};
/**
* 清理指定文档的预览状态(文档关闭时调用)
*/
const clearDocumentPreview = (documentId: number) => {
documentPreviews.value.delete(documentId);
};
return {
// 状态
editorView,
markdownPreview,
// 方法
setEditorView,
openMarkdownPreview,
startClosingMarkdownPreview,
closeMarkdownPreview,
updatePreviewRange,
isBlockPreviewing,
reset,
clearDocumentPreview
};
});

View File

@@ -1,195 +1,159 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
import { ThemeService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from './configStore';
import { useEditorStore } from './editorStore';
import type { ThemeColors } from '@/views/editor/theme/types';
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
type ThemeOption = { name: string; type: ThemeType };
const resolveThemeName = (name?: string) =>
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
themePresetList
.filter(preset => preset.type === type)
.map(preset => ({ name: preset.name, type: preset.type }));
const darkThemeOptions = createThemeOptions(ThemeType.ThemeTypeDark);
const lightThemeOptions = createThemeOptions(ThemeType.ThemeTypeLight);
const cloneColors = (colors: ThemeColorConfig): ThemeColors =>
JSON.parse(JSON.stringify(colors)) as ThemeColors;
const getPresetColors = (name: string): ThemeColors => {
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
const colors = cloneThemeColors(preset.colors);
colors.themeName = name;
return colors;
};
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
const safeName = resolveThemeName(themeName);
try {
const theme = await ThemeService.GetThemeByName(safeName);
if (theme?.colors) {
const colors = cloneColors(theme.colors);
colors.themeName = safeName;
return colors;
}
} catch (error) {
console.error('Failed to load theme override:', error);
}
return getPresetColors(safeName);
};
/**
* 主题管理 Store
* 职责:管理主题状态、颜色配置和预设主题列表
*/
export const useThemeStore = defineStore('theme', () => {
const configStore = useConfigStore();
// 所有主题列表
const allThemes = ref<Theme[]>([]);
// 当前主题的颜色配置
const currentColors = ref<ThemeColors | null>(null);
// 计算属性:当前系统主题模式
const currentTheme = computed(() =>
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
const currentTheme = computed(
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
);
// 计算属性:当前是否为深色模式
const isDarkMode = computed(() =>
currentTheme.value === SystemThemeType.SystemThemeDark ||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
const isDarkMode = computed(
() =>
currentTheme.value === SystemThemeType.SystemThemeDark ||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
);
// 计算属性:根据类型获取主题列表
const darkThemes = computed(() =>
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
);
const lightThemes = computed(() =>
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
);
// 计算属性:当前可用的主题列表
const availableThemes = computed(() =>
isDarkMode.value ? darkThemes.value : lightThemes.value
const availableThemes = computed<ThemeOption[]>(() =>
isDarkMode.value ? darkThemeOptions : lightThemeOptions
);
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
const themeMap = {
[SystemThemeType.SystemThemeAuto]: 'auto',
[SystemThemeType.SystemThemeDark]: 'dark',
[SystemThemeType.SystemThemeLight]: 'light'
[SystemThemeType.SystemThemeLight]: 'light',
};
document.documentElement.setAttribute('data-theme', themeMap[theme]);
};
// 从数据库加载所有主题
const loadAllThemes = async () => {
try {
const themes = await ThemeService.GetAllThemes();
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
return allThemes.value;
} catch (error) {
console.error('Failed to load themes from database:', error);
allThemes.value = [];
return [];
}
const loadThemeColors = async (themeName?: string) => {
const targetName = resolveThemeName(
themeName || configStore.config?.appearance?.currentTheme
);
currentColors.value = await fetchThemeColors(targetName);
};
// 初始化主题颜色
const initializeThemeColors = async () => {
// 加载所有主题
await loadAllThemes();
// 从配置获取当前主题名称并加载
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
const theme = allThemes.value.find(t => t.name === currentThemeName);
if (!theme) {
console.error(`Theme not found: ${currentThemeName}`);
return;
}
// 直接设置当前主题颜色
currentColors.value = theme.colors as ThemeColors;
};
// 初始化主题
const initializeTheme = async () => {
const theme = currentTheme.value;
applyThemeToDOM(theme);
await initializeThemeColors();
applyThemeToDOM(currentTheme.value);
await loadThemeColors();
};
// 设置系统主题模式(深色/浅色/自动)
const setTheme = async (theme: SystemThemeType) => {
await configStore.setSystemTheme(theme);
applyThemeToDOM(theme);
refreshEditorTheme();
};
// 切换到指定的预设主题
const switchToTheme = async (themeName: string) => {
const theme = allThemes.value.find(t => t.name === themeName);
if (!theme) {
if (!themePresetMap[themeName]) {
console.error('Theme not found:', themeName);
return false;
}
// 直接设置当前主题颜色
currentColors.value = theme.colors as ThemeColors;
// 持久化到配置
await loadThemeColors(themeName);
await configStore.setCurrentTheme(themeName);
// 刷新编辑器
refreshEditorTheme();
return true;
};
// 更新当前主题的颜色配置
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
if (!currentColors.value) return;
Object.assign(currentColors.value, colors);
};
// 保存当前主题颜色到数据库
const saveCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
if (!theme) {
throw new Error('Theme not found');
}
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
const themeName = resolveThemeName(currentColors.value.themeName);
currentColors.value.themeName = themeName;
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
await loadThemeColors(themeName);
refreshEditorTheme();
return true;
};
// 重置当前主题为预设配置
const resetCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
// 调用后端重置
await ThemeService.ResetTheme(0, currentColors.value.name);
// 重新加载所有主题
await loadAllThemes();
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
if (updatedTheme) {
currentColors.value = updatedTheme.colors as ThemeColors;
}
const themeName = resolveThemeName(currentColors.value.themeName);
await ThemeService.ResetTheme(themeName);
await loadThemeColors(themeName);
refreshEditorTheme();
return true;
};
// 刷新编辑器主题
const refreshEditorTheme = () => {
applyThemeToDOM(currentTheme.value);
const editorStore = useEditorStore();
editorStore?.applyThemeSettings();
};
return {
// 状态
allThemes,
darkThemes,
lightThemes,
availableThemes,
currentTheme,
currentColors,
isDarkMode,
// 方法
setTheme,
switchToTheme,
initializeTheme,
loadAllThemes,
updateCurrentColors,
saveCurrentTheme,
resetCurrentTheme,
refreshEditorTheme,
applyThemeToDOM,
};
});
});

View File

@@ -1,7 +1,28 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {TranslationService} from '@/../bindings/voidraft/internal/services';
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
/**
* 翻译结果接口
*/
export interface TranslationResult {
translatedText: string;
error?: string;
}
/**
* 语言信息接口
*/
export interface LanguageInfo {
Code: string; // 语言代码
Name: string; // 语言名称
}
/**
* 翻译相关的错误消息
*/
export const TRANSLATION_ERRORS = {
NO_TEXT: 'no text to translate',
TRANSLATION_FAILED: 'translation failed',
} as const;
export const useTranslationStore = defineStore('translation', () => {
// 基础状态

View File

@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import {useEditorStore} from '@/stores/editorStore';
import {useDocumentStore} from '@/stores/documentStore';
import {useConfigStore} from '@/stores/configStore';
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
import Toolbar from '@/components/toolbar/Toolbar.vue';
import {useWindowStore} from "@/stores/windowStore";
import {useWindowStore} from '@/stores/windowStore';
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
import {useTabStore} from "@/stores/tabStore";
import {useTabStore} from '@/stores/tabStore';
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';
const editorStore = useEditorStore();
const documentStore = useDocumentStore();
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
// 创建滚轮缩放处理器
const wheelHandler = createWheelZoomHandler(
configStore.increaseFontSize,
configStore.decreaseFontSize
);
onMounted(async () => {
if (!editorElement.value) return;
// 从URL查询参数中获取documentId
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
// 初始化文档存储优先使用URL参数中的文档ID
await documentStore.initialize(urlDocumentId);
// 设置编辑器容器
editorStore.setEditorContainer(editorElement.value);
await tabStore.initializeTab();
// 添加滚轮事件监听
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
});
onBeforeUnmount(() => {
// 移除滚轮事件监听
if (editorElement.value) {
editorElement.value.removeEventListener('wheel', wheelHandler);
}
editorStore.clearAllEditors();
contextMenuManager.destroy();
translatorManager.destroy();
});
</script>
<template>
<div class="editor-container">
<div ref="editorElement" class="editor"></div>
<Toolbar/>
<!-- 加载动画 -->
<transition name="loading-fade">
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
</transition>
<!-- 编辑器区域 -->
<div ref="editorElement" class="editor"></div>
<!-- 工具栏 -->
<Toolbar/>
<!-- 右键菜单 -->
<ContextMenu :portal-target="editorElement"/>
<!-- 翻译器弹窗 -->
<TranslatorDialog :portal-target="editorElement"/>
</div>
</template>
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
.editor {
width: 100%;
flex: 1;
height: 100%;
overflow: hidden;
position: relative;
}
}
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
overflow: auto;
}
// 加载动画过渡效果
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.3s ease;
@@ -98,4 +94,4 @@ onBeforeUnmount(() => {
.loading-fade-leave-to {
opacity: 0;
}
</style>
</style>

View File

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

View File

@@ -1,34 +1,48 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { useEditorStore } from '@/stores/editorStore';
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
import type {Text} from '@codemirror/state';
import {useEditorStore} from '@/stores/editorStore';
/**
* 内容变化监听插件 - 集成文档和编辑器管理
*/
export function createContentChangePlugin() {
return ViewPlugin.fromClass(
class ContentChangePlugin {
private editorStore = useEditorStore();
private lastContent = '';
private readonly editorStore = useEditorStore();
private lastDoc: Text;
private rafId: number | null = null;
private pendingNotification = false;
constructor(private view: EditorView) {
this.lastContent = view.state.doc.toString();
this.lastDoc = view.state.doc;
}
update(update: ViewUpdate) {
if (!update.docChanged) return;
if (!update.docChanged || update.state.doc === this.lastDoc) {
return;
}
const newContent = this.view.state.doc.toString();
if (newContent === this.lastContent) return;
this.lastContent = newContent;
this.editorStore.onContentChange();
this.lastDoc = update.state.doc;
this.scheduleNotification();
}
destroy() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.pendingNotification = false;
}
private scheduleNotification() {
if (this.pendingNotification) return;
this.pendingNotification = true;
this.rafId = requestAnimationFrame(() => {
this.pendingNotification = false;
this.rafId = null;
this.editorStore.onContentChange();
});
}
}
);
}
}

View File

@@ -1,22 +1,61 @@
// 处理滚轮缩放字体的事件处理函数
export const createWheelZoomHandler = (
increaseFontSize: () => void,
decreaseFontSize: () => void
) => {
return (event: WheelEvent) => {
// 检查是否按住了Ctrl键
if (event.ctrlKey) {
// 阻止默认行为(防止页面缩放)
import {EditorView} from '@codemirror/view';
import type {Extension} from '@codemirror/state';
import {createDebounce} from '@/common/utils/debounce';
type FontAdjuster = () => void;
type SaveCallback = () => Promise<void> | void;
export interface WheelZoomOptions {
/** 增加字体大小的回调(立即执行) */
increaseFontSize: FontAdjuster;
/** 减少字体大小的回调(立即执行) */
decreaseFontSize: FontAdjuster;
/** 保存回调(防抖执行),在滚动结束后调用 */
onSave?: SaveCallback;
/** 保存防抖延迟(毫秒),默认 300ms */
saveDelay?: number;
}
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
// 如果有 onSave 回调,创建防抖版本
const {debouncedFn: debouncedSave} = onSave
? createDebounce(() => {
try {
const result = onSave();
if (result && typeof (result as Promise<void>).then === 'function') {
(result as Promise<void>).catch((error) => {
console.error('Failed to save font size:', error);
});
}
} catch (error) {
console.error('Failed to save font size:', error);
}
}, {delay: saveDelay})
: {debouncedFn: null};
return EditorView.domEventHandlers({
wheel(event) {
if (!event.ctrlKey) {
return false;
}
event.preventDefault();
// 根据滚轮方向增大或减小字体
// 立即更新字体大小
if (event.deltaY < 0) {
// 向上滚动,增大字体
increaseFontSize();
} else {
// 向下滚动,减小字体
} else if (event.deltaY > 0) {
decreaseFontSize();
}
// 防抖保存
if (debouncedSave) {
debouncedSave();
}
return true;
}
};
};
});
};

View File

@@ -1,156 +0,0 @@
/**
* 编辑器上下文菜单样式
* 支持系统主题自动适配
*/
.cm-context-menu {
position: fixed;
background-color: var(--settings-card-bg);
color: var(--settings-text);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 4px 0;
/* 优化阴影效果,只在右下角显示自然的阴影 */
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
min-width: 200px;
max-width: 320px;
z-index: 9999;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transform: scale(0.95);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
overflow: visible; /* 确保子菜单可以显示在外部 */
}
.cm-context-menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
transition: all 0.1s ease;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-context-menu-item:hover {
background-color: var(--toolbar-button-hover);
color: var(--toolbar-text);
}
.cm-context-menu-item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cm-context-menu-item-shortcut {
opacity: 0.7;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
background-color: var(--settings-input-bg);
color: var(--settings-text-secondary);
margin-left: 16px;
}
.cm-context-menu-item-ripple {
position: absolute;
border-radius: 50%;
background-color: var(--selection-bg);
width: 100px;
height: 100px;
opacity: 0.5;
transform: scale(0);
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}
/* 菜单分组标题样式 */
.cm-context-menu-group-title {
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
user-select: none;
}
/* 菜单分隔线样式 */
.cm-context-menu-divider {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
/* 子菜单样式 */
.cm-context-submenu-container {
position: relative;
}
.cm-context-menu-item-with-submenu {
position: relative;
}
.cm-context-menu-item-with-submenu::after {
content: "";
position: absolute;
right: 12px;
font-size: 16px;
opacity: 0.7;
}
.cm-context-submenu {
position: fixed; /* 改为fixed定位避免受父元素影响 */
min-width: 180px;
opacity: 0;
pointer-events: none;
transform: translateX(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 10000;
border-radius: 6px;
background-color: var(--settings-card-bg);
color: var(--settings-text);
border: 1px solid var(--border-color);
padding: 4px 0;
/* 子菜单也使用相同的阴影效果 */
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
/* 深色主题下的特殊样式 */
:root[data-theme="dark"] .cm-context-menu {
/* 深色主题下阴影更深,但仍然只在右下角 */
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
}
:root[data-theme="dark"] .cm-context-submenu {
/* 深色主题下子菜单阴影 */
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
}
:root[data-theme="dark"] .cm-context-menu-divider {
background-color: var(--dark-border-color);
opacity: 0.6;
}
/* 动画相关类 */
.cm-context-menu.show {
opacity: 1;
transform: scale(1);
}
.cm-context-menu.hide {
opacity: 0;
}

View File

@@ -1,585 +0,0 @@
/**
* 上下文菜单视图实现
*/
import { EditorView } from "@codemirror/view";
import { MenuItem } from "../contextMenu";
import "./contextMenu.css";
// 为Window对象添加cmSubmenus属性
declare global {
interface Window {
cmSubmenus?: Map<string, HTMLElement>;
}
}
/**
* 菜单项元素池用于复用DOM元素
*/
class MenuItemPool {
private pool: HTMLElement[] = [];
private maxPoolSize = 50; // 最大池大小
/**
* 获取或创建菜单项元素
*/
get(): HTMLElement {
if (this.pool.length > 0) {
return this.pool.pop()!;
}
const menuItem = document.createElement("div");
menuItem.className = "cm-context-menu-item";
return menuItem;
}
/**
* 回收菜单项元素
*/
release(element: HTMLElement): void {
if (this.pool.length < this.maxPoolSize) {
// 清理元素状态
element.className = "cm-context-menu-item";
element.innerHTML = "";
element.style.cssText = "";
// 移除所有事件监听器(通过克隆节点)
const cleanElement = element.cloneNode(false) as HTMLElement;
this.pool.push(cleanElement);
}
}
/**
* 清空池
*/
clear(): void {
this.pool.length = 0;
}
}
/**
* 上下文菜单管理器
*/
class ContextMenuManager {
private static instance: ContextMenuManager;
private menuElement: HTMLElement | null = null;
private submenuPool: Map<string, HTMLElement> = new Map();
private menuItemPool = new MenuItemPool();
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
private currentView: EditorView | null = null;
private activeSubmenus: Set<HTMLElement> = new Set();
private ripplePool: HTMLElement[] = [];
// 事件委托处理器
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
private constructor() {
this.initializeEventHandlers();
}
/**
* 获取单例实例
*/
static getInstance(): ContextMenuManager {
if (!ContextMenuManager.instance) {
ContextMenuManager.instance = new ContextMenuManager();
}
return ContextMenuManager.instance;
}
/**
* 初始化事件处理器
*/
private initializeEventHandlers(): void {
// 点击事件委托
this.menuClickHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
if (menuItem && menuItem.dataset.command) {
e.preventDefault();
e.stopPropagation();
// 添加点击动画
this.addRippleEffect(menuItem, e);
// 执行命令
const commandName = menuItem.dataset.command;
const command = this.getCommandByName(commandName);
if (command && this.currentView) {
command(this.currentView);
}
// 隐藏菜单
this.hide();
}
};
// 鼠标事件委托
this.menuMouseHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
if (!menuItem) return;
if (e.type === 'mouseenter') {
this.handleMenuItemMouseEnter(menuItem);
} else if (e.type === 'mouseleave') {
this.handleMenuItemMouseLeave(menuItem, e);
}
};
// 键盘事件处理器
this.keyDownHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.hide();
}
};
// 点击外部关闭处理器
this.clickOutsideHandler = (e: MouseEvent) => {
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
this.hide();
}
};
}
/**
* 获取或创建主菜单元素
*/
private getOrCreateMenuElement(): HTMLElement {
if (!this.menuElement) {
this.menuElement = document.createElement("div");
this.menuElement.className = "cm-context-menu";
this.menuElement.style.display = "none";
document.body.appendChild(this.menuElement);
// 阻止菜单内右键点击冒泡
this.menuElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// 添加事件委托
this.menuElement.addEventListener('click', this.menuClickHandler!);
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
}
return this.menuElement;
}
/**
* 创建或获取子菜单元素
*/
private getOrCreateSubmenu(id: string): HTMLElement {
if (!this.submenuPool.has(id)) {
const submenu = document.createElement("div");
submenu.className = "cm-context-menu cm-context-submenu";
submenu.style.display = "none";
document.body.appendChild(submenu);
this.submenuPool.set(id, submenu);
// 阻止子菜单点击事件冒泡
submenu.addEventListener('click', (e) => {
e.stopPropagation();
});
// 添加事件委托
submenu.addEventListener('click', this.menuClickHandler!);
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
}
return this.submenuPool.get(id)!;
}
/**
* 创建菜单项DOM元素
*/
private createMenuItemElement(item: MenuItem): HTMLElement {
const menuItem = this.menuItemPool.get();
// 如果有子菜单,添加相应类
if (item.submenu && item.submenu.length > 0) {
menuItem.classList.add("cm-context-menu-item-with-submenu");
}
// 创建内容容器
const contentContainer = document.createElement("div");
contentContainer.className = "cm-context-menu-item-label";
// 标签文本
const label = document.createElement("span");
label.textContent = item.label;
contentContainer.appendChild(label);
menuItem.appendChild(contentContainer);
// 快捷键提示(如果有)
if (item.shortcut) {
const shortcut = document.createElement("span");
shortcut.className = "cm-context-menu-item-shortcut";
shortcut.textContent = item.shortcut;
menuItem.appendChild(shortcut);
}
// 存储命令信息用于事件委托
if (item.command) {
menuItem.dataset.command = this.registerCommand(item.command);
}
// 处理子菜单
if (item.submenu && item.submenu.length > 0) {
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
menuItem.dataset.submenuId = submenuId;
const submenu = this.getOrCreateSubmenu(submenuId);
this.populateSubmenu(submenu, item.submenu);
// 记录子菜单
if (!window.cmSubmenus) {
window.cmSubmenus = new Map();
}
window.cmSubmenus.set(submenuId, submenu);
}
return menuItem;
}
/**
* 填充子菜单内容
*/
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
// 清空现有内容
while (submenu.firstChild) {
submenu.removeChild(submenu.firstChild);
}
// 添加子菜单项
items.forEach(item => {
const subMenuItemElement = this.createMenuItemElement(item);
submenu.appendChild(subMenuItemElement);
});
// 初始状态设置为隐藏
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.display = 'block';
}
/**
* 命令注册和管理
*/
private commands: Map<string, (view: EditorView) => void> = new Map();
private commandCounter = 0;
private registerCommand(command: (view: EditorView) => void): string {
const commandId = `cmd_${this.commandCounter++}`;
this.commands.set(commandId, command);
return commandId;
}
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
return this.commands.get(commandId);
}
/**
* 处理菜单项鼠标进入事件
*/
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
const submenuId = menuItem.dataset.submenuId;
if (!submenuId) return;
const submenu = this.submenuPool.get(submenuId);
if (!submenu) return;
const rect = menuItem.getBoundingClientRect();
// 计算子菜单位置
submenu.style.left = `${rect.right}px`;
submenu.style.top = `${rect.top}px`;
// 检查子菜单是否会超出屏幕
requestAnimationFrame(() => {
const submenuRect = submenu.getBoundingClientRect();
if (submenuRect.right > window.innerWidth) {
submenu.style.left = `${rect.left - submenuRect.width}px`;
}
if (submenuRect.bottom > window.innerHeight) {
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
submenu.style.top = `${Math.max(0, newTop)}px`;
}
});
// 显示子菜单
submenu.style.opacity = '1';
submenu.style.pointerEvents = 'auto';
submenu.style.visibility = 'visible';
submenu.style.transform = 'translateX(0)';
this.activeSubmenus.add(submenu);
}
/**
* 处理菜单项鼠标离开事件
*/
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
const submenuId = menuItem.dataset.submenuId;
if (!submenuId) return;
const submenu = this.submenuPool.get(submenuId);
if (!submenu) return;
// 检查是否移动到子菜单上
const toElement = e.relatedTarget as HTMLElement;
if (submenu.contains(toElement)) {
return;
}
this.hideSubmenu(submenu);
}
/**
* 隐藏子菜单
*/
private hideSubmenu(submenu: HTMLElement): void {
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.transform = 'translateX(10px)';
setTimeout(() => {
if (submenu.style.opacity === '0') {
submenu.style.visibility = 'hidden';
}
}, 200);
this.activeSubmenus.delete(submenu);
}
/**
* 添加点击波纹效果
*/
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
let ripple: HTMLElement;
if (this.ripplePool.length > 0) {
ripple = this.ripplePool.pop()!;
} else {
ripple = document.createElement("div");
ripple.className = "cm-context-menu-item-ripple";
}
// 计算相对位置
const rect = menuItem.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ripple.style.left = (x - 50) + "px";
ripple.style.top = (y - 50) + "px";
ripple.style.transform = "scale(0)";
ripple.style.opacity = "1";
menuItem.appendChild(ripple);
// 执行动画
requestAnimationFrame(() => {
ripple.style.transform = "scale(1)";
ripple.style.opacity = "0";
setTimeout(() => {
if (ripple.parentNode === menuItem) {
menuItem.removeChild(ripple);
this.ripplePool.push(ripple);
}
}, 300);
});
}
/**
* 检查点击是否在菜单内
*/
private isClickInsideMenu(target: Node): boolean {
if (this.menuElement && this.menuElement.contains(target)) {
return true;
}
// 检查是否在子菜单内
for (const submenu of this.activeSubmenus) {
if (submenu.contains(target)) {
return true;
}
}
return false;
}
/**
* 定位菜单元素
*/
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = clientX;
let top = clientY;
requestAnimationFrame(() => {
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
if (left + menuWidth > windowWidth) {
left = windowWidth - menuWidth - 5;
}
if (top + menuHeight > windowHeight) {
top = windowHeight - menuHeight - 5;
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
});
}
/**
* 显示上下文菜单
*/
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
this.currentView = view;
// 获取或创建菜单元素
const menu = this.getOrCreateMenuElement();
// 隐藏所有子菜单
this.hideAllSubmenus();
// 清空现有菜单项并回收到池中
while (menu.firstChild) {
const child = menu.firstChild as HTMLElement;
if (child.classList.contains('cm-context-menu-item')) {
this.menuItemPool.release(child);
}
menu.removeChild(child);
}
// 清空命令注册
this.commands.clear();
this.commandCounter = 0;
// 添加主菜单项
items.forEach(item => {
const menuItemElement = this.createMenuItemElement(item);
menu.appendChild(menuItemElement);
});
// 显示菜单
menu.style.display = "block";
// 定位菜单
this.positionMenu(menu, clientX, clientY);
// 添加全局事件监听器
document.addEventListener("click", this.clickOutsideHandler!, true);
document.addEventListener("keydown", this.keyDownHandler!);
// 触发显示动画
requestAnimationFrame(() => {
if (menu) {
menu.classList.add("show");
}
});
}
/**
* 隐藏所有子菜单
*/
private hideAllSubmenus(): void {
this.activeSubmenus.forEach(submenu => {
this.hideSubmenu(submenu);
});
this.activeSubmenus.clear();
if (window.cmSubmenus) {
window.cmSubmenus.forEach((submenu) => {
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.transform = 'translateX(10px)';
});
}
}
/**
* 隐藏上下文菜单
*/
hide(): void {
// 隐藏所有子菜单
this.hideAllSubmenus();
if (this.menuElement) {
// 添加淡出动画
this.menuElement.classList.remove("show");
this.menuElement.classList.add("hide");
// 等待动画完成后隐藏
setTimeout(() => {
if (this.menuElement) {
this.menuElement.style.display = "none";
this.menuElement.classList.remove("hide");
}
}, 150);
}
// 移除全局事件监听器
if (this.clickOutsideHandler) {
document.removeEventListener("click", this.clickOutsideHandler, true);
}
if (this.keyDownHandler) {
document.removeEventListener("keydown", this.keyDownHandler);
}
this.currentView = null;
}
/**
* 销毁管理器
*/
destroy(): void {
this.hide();
if (this.menuElement) {
document.body.removeChild(this.menuElement);
this.menuElement = null;
}
this.submenuPool.forEach(submenu => {
if (submenu.parentNode) {
document.body.removeChild(submenu);
}
});
this.submenuPool.clear();
this.menuItemPool.clear();
this.commands.clear();
this.activeSubmenus.clear();
this.ripplePool.length = 0;
if (window.cmSubmenus) {
window.cmSubmenus.clear();
}
}
}
// 获取单例实例
const contextMenuManager = ContextMenuManager.getInstance();
/**
* 显示上下文菜单
*/
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
contextMenuManager.show(view, clientX, clientY, items);
}

View File

@@ -1,174 +0,0 @@
/**
* 编辑器上下文菜单实现
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
*/
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 { useKeybindingStore } from "@/stores/keybindingStore";
import {
undo, redo
} from "@codemirror/commands";
import i18n from "@/i18n";
import {useSystemStore} from "@/stores/systemStore";
/**
* 菜单项类型定义
*/
export interface MenuItem {
/** 菜单项显示文本 */
label: string;
/** 点击时执行的命令 (如果有子菜单可以为null) */
command?: (view: EditorView) => boolean;
/** 快捷键提示文本 (可选) */
shortcut?: string;
/** 子菜单项 (可选) */
submenu?: MenuItem[];
}
// 导入相关功能
import { showContextMenu } from "./contextMenuView";
/**
* 获取翻译文本
* @param key 翻译键
* @returns 翻译后的文本
*/
function t(key: string): string {
return i18n.global.t(key);
}
/**
* 获取快捷键显示文本
* @param command 命令ID
* @returns 快捷键显示文本
*/
function getShortcutText(command: KeyBindingCommand): string {
try {
const keybindingStore = useKeybindingStore();
// 如果找到该命令的快捷键配置
const binding = keybindingStore.keyBindings.find(kb =>
kb.command === command && kb.enabled
);
if (binding && binding.key) {
// 格式化快捷键显示
return formatKeyBinding(binding.key);
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
return "";
}
/**
* 格式化快捷键显示
* @param keyBinding 快捷键字符串
* @returns 格式化后的显示文本
*/
function formatKeyBinding(keyBinding: string): string {
// 获取系统信息
const systemStore = useSystemStore();
const isMac = systemStore.isMacOS;
// 替换修饰键名称为更友好的显示
return keyBinding
.replace("Mod", isMac ? "⌘" : "Ctrl")
.replace("Shift", isMac ? "⇧" : "Shift")
.replace("Alt", isMac ? "⌥" : "Alt")
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
.replace(/-/g, " + ");
}
/**
* 创建编辑菜单项
*/
function createEditItems(): MenuItem[] {
return [
{
label: t("keybindings.commands.blockCopy"),
command: copyCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
},
{
label: t("keybindings.commands.blockCut"),
command: cutCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
},
{
label: t("keybindings.commands.blockPaste"),
command: pasteCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
}
];
}
/**
* 创建历史操作菜单项
*/
function createHistoryItems(): MenuItem[] {
return [
{
label: t("keybindings.commands.historyUndo"),
command: undo,
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
},
{
label: t("keybindings.commands.historyRedo"),
command: redo,
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
}
];
}
/**
* 创建主菜单项
*/
function createMainMenuItems(): MenuItem[] {
// 基本编辑操作放在主菜单
const basicItems = createEditItems();
// 历史操作放在主菜单
const historyItems = createHistoryItems();
// 构建主菜单
return [
...basicItems,
...historyItems
];
}
/**
* 创建编辑器上下文菜单
*/
export function createEditorContextMenu(): Extension {
// 为编辑器添加右键事件处理
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
// 阻止默认右键菜单
event.preventDefault();
// 获取菜单项
const menuItems = createMainMenuItems();
// 显示上下文菜单
showContextMenu(view, event.clientX, event.clientY, menuItems);
return true;
}
});
}
/**
* 默认导出
*/
export default createEditorContextMenu;

View File

@@ -1,194 +0,0 @@
import { EditorView, Decoration } from "@codemirror/view";
import { WidgetType } from "@codemirror/view";
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view";
import { Extension, StateEffect } from "@codemirror/state";
// 创建字体变化效果
const fontChangeEffect = StateEffect.define<void>();
/**
* 复选框小部件类
*/
class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) {
super();
}
eq(other: CheckboxWidget) {
return other.checked == this.checked;
}
toDOM() {
const wrap = document.createElement("span");
wrap.setAttribute("aria-hidden", "true");
wrap.className = "cm-checkbox-toggle";
const box = document.createElement("input");
box.type = "checkbox";
box.checked = this.checked;
box.tabIndex = -1;
box.style.margin = "0";
box.style.padding = "0";
box.style.cursor = "pointer";
box.style.position = "relative";
box.style.top = "0.1em";
box.style.marginRight = "0.5em";
// 设置相对单位,让复选框跟随字体大小变化
box.style.width = "1em";
box.style.height = "1em";
wrap.appendChild(box);
return wrap;
}
ignoreEvent() {
return false;
}
}
/**
* 查找并创建复选框装饰
*/
function findCheckboxes(view: EditorView) {
const widgets: any = [];
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
// 使用正则表达式查找 [x] 或 [ ] 模式
const text = doc.sliceString(from, to);
const checkboxRegex = /\[([ x])\]/gi;
let match;
while ((match = checkboxRegex.exec(text)) !== null) {
const matchPos = from + match.index;
const matchEnd = matchPos + match[0].length;
// 检查前面是否有 "- " 模式
const beforeTwoChars = matchPos >= 2 ? doc.sliceString(matchPos - 2, matchPos) : "";
const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : "";
// 只有当前面是 "- " 且后面跟空格或行尾时才渲染
if (beforeTwoChars === "- " &&
(afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) {
const isChecked = match[1].toLowerCase() === "x";
const deco = Decoration.replace({
widget: new CheckboxWidget(isChecked),
inclusive: false,
});
// 替换整个 "- [ ]" 或 "- [x]" 模式,包括前面的 "- "
widgets.push(deco.range(matchPos - 2, matchEnd));
}
}
}
return Decoration.set(widgets);
}
/**
* 切换复选框状态
*/
function toggleCheckbox(view: EditorView, pos: number) {
const doc = view.state.doc;
// 查找当前位置附近的复选框模式(需要前面有 "- "
for (let offset = -5; offset <= 0; offset++) {
const checkPos = pos + offset;
if (checkPos >= 2 && checkPos + 3 <= doc.length) {
// 检查是否有 "- " 前缀
const prefix = doc.sliceString(checkPos - 2, checkPos);
const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase();
if (prefix === "- ") {
let change;
if (text === "[x]") {
// 替换整个 "- [x]" 为 "- [ ]"
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [ ]" };
} else if (text === "[ ]") {
// 替换整个 "- [ ]" 为 "- [x]"
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [x]" };
}
if (change) {
view.dispatch({ changes: change });
return true;
}
}
}
}
return false;
}
// 创建字体变化效果的便捷函数
export const triggerFontChange = (view: EditorView) => {
view.dispatch({
effects: fontChangeEffect.of(undefined)
});
};
/**
* 创建复选框扩展
*/
export function createCheckboxExtension(): Extension {
return [
// 主要的复选框插件
ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = findCheckboxes(view);
}
update(update: ViewUpdate) {
// 检查是否需要重新渲染复选框
const shouldUpdate = update.docChanged ||
update.viewportChanged ||
update.geometryChanged ||
update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect)));
if (shouldUpdate) {
this.decorations = findCheckboxes(update.view);
}
}
}, {
decorations: v => v.decorations,
eventHandlers: {
mousedown: (e, view) => {
const target = e.target as HTMLElement;
if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) {
const pos = view.posAtDOM(target);
return toggleCheckbox(view, pos);
}
}
}
}),
// 复选框样式
EditorView.theme({
".cm-checkbox-toggle": {
display: "inline-block",
verticalAlign: "baseline",
},
".cm-checkbox-toggle input[type=checkbox]": {
margin: "0",
padding: "0",
verticalAlign: "baseline",
cursor: "pointer",
// 确保复选框大小跟随字体
fontSize: "inherit",
}
})
];
}
// 默认导出
export const checkboxExtension = createCheckboxExtension();
// 导出类型和工具函数
export {
CheckboxWidget,
toggleCheckbox,
findCheckboxes
};

View File

@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
/**
* 块背景层 - 修复高度计算问题
*
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0
* 行的坐标也不会受影响,边界线位置正确。
*/
const blockLayer = layer({
above: false,
@@ -135,14 +139,17 @@ const blockLayer = layer({
return;
}
// view.coordsAtPos 如果编辑器不可见则返回 null
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
idx++;
return;
}
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0影响
const fromLineBlock = view.lineBlockAt(fromPos);
const toLineBlock = view.lineBlockAt(toPos);
// lineBlockAt 返回的 top 是相对于内容区域的偏移
// 转换为视口坐标进行后续计算
const fromCoordsTop = fromLineBlock.top + view.documentTop;
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
// 对最后一个块进行特殊处理,让它直接延伸到底部
if (idx === blocks.length - 1) {
@@ -151,7 +158,7 @@ const blockLayer = layer({
// 让最后一个块直接延伸到编辑器底部
if (contentBottom < editorHeight) {
const extraHeight = editorHeight - contentBottom-10;
const extraHeight = editorHeight - contentBottom - 10;
toCoordsBottom += extraHeight;
}
}

View File

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

View File

@@ -5,9 +5,15 @@
import {jsonLanguage} from "@codemirror/lang-json";
import {pythonLanguage} from "@codemirror/lang-python";
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
import {htmlLanguage} from "@codemirror/lang-html";
import {html, htmlLanguage} from "@codemirror/lang-html";
import {StandardSQL} from "@codemirror/lang-sql";
import {markdownLanguage} from "@codemirror/lang-markdown";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {Subscript, Superscript, Table} from "@lezer/markdown";
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";
@@ -22,9 +28,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
import {sassLanguage} from "@codemirror/lang-sass";
import {lessLanguage} from "@codemirror/lang-less";
import {angularLanguage} from "@codemirror/lang-angular";
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
import { mermaidLanguage } from '@/views/editor/language/mermaid';
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
import {mermaidLanguage} from '@/views/editor/language/mermaid';
import {StreamLanguage} from "@codemirror/language";
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
import {shell} from "@codemirror/legacy-modes/mode/shell";
@@ -64,6 +70,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
import webPrettierPlugin from "@/common/prettier/plugins/web";
import * as prettierPluginEstree from "prettier/plugins/estree";
import {languages} from "@codemirror/language-data";
/**
* 语言信息类
@@ -110,7 +117,19 @@ export const LANGUAGES: LanguageInfo[] = [
parser: "sql",
plugins: [sqlPrettierPlugin]
}),
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({
matchClosingTags: true,
autoCloseTags: true
}),
addKeymap: true,
codeLanguages: languages,
}).language.parser, ["md"], {
parser: "markdown",
plugins: [markdownPrettierPlugin]
}),

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { contextMenuManager } from './manager';
import type { RenderMenuItem } from './menuSchema';
const props = defineProps<{
portalTarget?: HTMLElement | null;
}>();
const menuState = contextMenuManager.useState();
const menuRef = ref<HTMLDivElement | null>(null);
const adjustedPosition = ref({ x: 0, y: 0 });
const isVisible = computed(() => menuState.value.visible);
const items = computed(() => menuState.value.items);
const position = computed(() => menuState.value.position);
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
watch(
position,
(newPosition) => {
adjustedPosition.value = { ...newPosition };
if (isVisible.value) {
nextTick(adjustMenuWithinViewport);
}
},
{ deep: true }
);
watch(isVisible, (visible) => {
if (visible) {
nextTick(adjustMenuWithinViewport);
// 显示时添加 outside 点击监听
document.addEventListener('mousedown', handleClickOutside);
} else {
// 隐藏时移除监听
document.removeEventListener('mousedown', handleClickOutside);
}
});
// 清理
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
const menuStyle = computed(() => ({
left: `${adjustedPosition.value.x}px`,
top: `${adjustedPosition.value.y}px`
}));
async function adjustMenuWithinViewport() {
await nextTick();
const menuEl = menuRef.value;
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
let nextX = adjustedPosition.value.x;
let nextY = adjustedPosition.value.y;
if (rect.right > window.innerWidth) {
nextX = Math.max(0, window.innerWidth - rect.width - 8);
}
if (rect.bottom > window.innerHeight) {
nextY = Math.max(0, window.innerHeight - rect.height - 8);
}
adjustedPosition.value = { x: nextX, y: nextY };
}
function handleItemClick(item: RenderMenuItem) {
if (item.type !== "action" || item.disabled) {
return;
}
contextMenuManager.runCommand(item);
}
function handleClickOutside(event: MouseEvent) {
// 如果点击在菜单内部,不关闭
if (menuRef.value?.contains(event.target as Node)) {
return;
}
contextMenuManager.hide();
}
</script>
<template>
<Teleport :to="teleportTarget">
<template v-if="isVisible">
<div
ref="menuRef"
class="cm-context-menu show"
:style="menuStyle"
role="menu"
@contextmenu.prevent
>
<template v-for="item in items" :key="item.id">
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
<div
v-else
class="cm-context-menu-item"
:class="{ 'is-disabled': item.disabled }"
role="menuitem"
:aria-disabled="item.disabled ? 'true' : 'false'"
@click="handleItemClick(item)"
>
<div class="cm-context-menu-item-label">
<span>{{ item.label }}</span>
</div>
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
{{ item.shortcut }}
</span>
</div>
</template>
</div>
</template>
</Teleport>
</template>
<style scoped lang="scss">
.cm-context-menu {
position: fixed;
min-width: 180px;
max-width: 320px;
padding: 4px 0;
border-radius: 3px;
background-color: var(--settings-card-bg, #1c1c1e);
color: var(--settings-text, #f6f6f6);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
z-index: 10000;
opacity: 0;
transform: scale(0.96);
transform-origin: top left;
transition: opacity 0.12s ease, transform 0.12s ease;
}
.cm-context-menu.show {
opacity: 1;
transform: scale(1);
}
.cm-context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
white-space: nowrap;
}
.cm-context-menu-item:hover {
background-color: var(--toolbar-button-hover);
color: var(--toolbar-text, #ffffff);
}
.cm-context-menu-item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cm-context-menu-item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cm-context-menu-item-shortcut {
font-size: 12px;
opacity: 0.65;
}
.cm-context-menu-divider {
height: 1px;
margin: 4px 0;
border: none;
background-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,141 @@
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
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';
import { useSystemStore } from '@/stores/systemStore';
import { showContextMenu } from './manager';
import {
buildRegisteredMenu,
createMenuContext,
registerMenuNodes
} from './menuSchema';
import type { MenuSchemaNode } from './menuSchema';
function t(key: string): string {
return i18n.global.t(key);
}
function formatKeyBinding(keyBinding: string): string {
const systemStore = useSystemStore();
const isMac = systemStore.isMacOS;
return keyBinding
.replace("Mod", isMac ? "Cmd" : "Ctrl")
.replace("Shift", "Shift")
.replace("Alt", isMac ? "Option" : "Alt")
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
.replace(/-/g, " + ");
}
const shortcutCache = new Map<KeyBindingCommand, string>();
function getShortcutText(command?: KeyBindingCommand): string {
if (command === undefined) {
return "";
}
const cached = shortcutCache.get(command);
if (cached !== undefined) {
return cached;
}
try {
const keybindingStore = useKeybindingStore();
const binding = keybindingStore.keyBindings.find(
(kb) => kb.command === command && kb.enabled
);
if (binding?.key) {
const formatted = formatKeyBinding(binding.key);
shortcutCache.set(command, formatted);
return formatted;
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
shortcutCache.set(command, "");
return "";
}
function getBuiltinMenuNodes(): MenuSchemaNode[] {
return [
{
id: "copy",
labelKey: "keybindings.commands.blockCopy",
command: copyCommand,
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
enabled: (context) => context.hasSelection
},
{
id: "cut",
labelKey: "keybindings.commands.blockCut",
command: cutCommand,
shortcutCommand: KeyBindingCommand.BlockCutCommand,
visible: (context) => context.isEditable,
enabled: (context) => context.hasSelection && context.isEditable
},
{
id: "paste",
labelKey: "keybindings.commands.blockPaste",
command: pasteCommand,
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
visible: (context) => context.isEditable
},
{
id: "undo",
labelKey: "keybindings.commands.historyUndo",
command: undo,
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
visible: (context) => context.isEditable
},
{
id: "redo",
labelKey: "keybindings.commands.historyRedo",
command: redo,
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
visible: (context) => context.isEditable
}
];
}
let builtinMenuRegistered = false;
function ensureBuiltinMenuRegistered(): void {
if (builtinMenuRegistered) return;
registerMenuNodes(getBuiltinMenuNodes());
builtinMenuRegistered = true;
}
export function createEditorContextMenu(): Extension {
ensureBuiltinMenuRegistered();
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
event.preventDefault();
const context = createMenuContext(view, event as MouseEvent);
const menuItems = buildRegisteredMenu(context, {
translate: t,
formatShortcut: getShortcutText
});
if (menuItems.length === 0) {
return false;
}
showContextMenu(view, event.clientX, event.clientY, menuItems);
return true;
}
});
}
export default createEditorContextMenu;

View File

@@ -0,0 +1,108 @@
import type { EditorView } from '@codemirror/view';
import { readonly, shallowRef, type ShallowRef } from 'vue';
import type { RenderMenuItem } from './menuSchema';
interface MenuPosition {
x: number;
y: number;
}
interface ContextMenuState {
visible: boolean;
position: MenuPosition;
items: RenderMenuItem[];
view: EditorView | null;
}
class ContextMenuManager {
private state: ShallowRef<ContextMenuState> = shallowRef({
visible: false,
position: { x: 0, y: 0 },
items: [] as RenderMenuItem[],
view: null as EditorView | null
});
useState() {
return readonly(this.state);
}
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
const currentState = this.state.value;
// 如果菜单已经显示且位置很接近20px范围内则只更新内容避免闪烁
if (currentState.visible) {
const dx = Math.abs(currentState.position.x - clientX);
const dy = Math.abs(currentState.position.y - clientY);
const isSamePosition = dx < 20 && dy < 20;
if (isSamePosition) {
// 只更新items和view保持visible状态和位置
this.state.value = {
...currentState,
items,
view
};
return;
}
}
// 否则正常显示菜单
this.state.value = {
visible: true,
position: { x: clientX, y: clientY },
items,
view
};
}
hide(): void {
if (!this.state.value.visible) {
return;
}
const previousPosition = this.state.value.position;
const view = this.state.value.view;
this.state.value = {
visible: false,
position: previousPosition,
items: [],
view: null
};
if (view) {
view.focus();
}
}
runCommand(item: RenderMenuItem): void {
if (item.type !== "action" || item.disabled) {
return;
}
const { view } = this.state.value;
if (item.command && view) {
item.command(view);
}
this.hide();
}
destroy(): void {
this.state.value = {
visible: false,
position: { x: 0, y: 0 },
items: [],
view: null
};
}
}
export const contextMenuManager = new ContextMenuManager();
export function showContextMenu(
view: EditorView,
clientX: number,
clientY: number,
items: RenderMenuItem[]
): void {
contextMenuManager.show(view, clientX, clientY, items);
}

View File

@@ -0,0 +1,102 @@
import type { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
export interface MenuContext {
view: EditorView;
event: MouseEvent;
hasSelection: boolean;
selectionText: string;
isEditable: boolean;
}
export type MenuSchemaNode =
| {
id: string;
type?: "action";
labelKey: string;
command?: (view: EditorView) => boolean;
shortcutCommand?: KeyBindingCommand;
visible?: (context: MenuContext) => boolean;
enabled?: (context: MenuContext) => boolean;
}
| {
id: string;
type: "separator";
visible?: (context: MenuContext) => boolean;
};
export interface RenderMenuItem {
id: string;
type: "action" | "separator";
label?: string;
shortcut?: string;
disabled?: boolean;
command?: (view: EditorView) => boolean;
}
interface MenuBuildOptions {
translate: (key: string) => string;
formatShortcut: (command?: KeyBindingCommand) => string;
}
const menuRegistry: MenuSchemaNode[] = [];
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
const { state } = view;
const hasSelection = state.selection.ranges.some((range) => !range.empty);
const selectionText = hasSelection
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
: "";
const isEditable = !state.facet(EditorState.readOnly);
return {
view,
event,
hasSelection,
selectionText,
isEditable
};
}
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
menuRegistry.push(...nodes);
}
export function buildRegisteredMenu(
context: MenuContext,
options: MenuBuildOptions
): RenderMenuItem[] {
return menuRegistry
.map((node) => convertNode(node, context, options))
.filter((item): item is RenderMenuItem => Boolean(item));
}
function convertNode(
node: MenuSchemaNode,
context: MenuContext,
options: MenuBuildOptions
): RenderMenuItem | null {
if (node.visible && !node.visible(context)) {
return null;
}
if (node.type === "separator") {
return {
id: node.id,
type: "separator"
};
}
const disabled = node.enabled ? !node.enabled(context) : false;
const shortcut = options.formatShortcut(node.shortcutCommand);
return {
id: node.id,
type: "action",
label: options.translate(node.labelKey),
shortcut: shortcut || undefined,
disabled,
command: node.command
};
}

View File

@@ -1,37 +0,0 @@
import {foldService} from '@codemirror/language';
export const foldingOnIndent = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from); // First line
const lines = state.doc.lines; // Number of lines in the document
const indent = line.text.search(/\S|$/); // Indent level of the first line
let foldStart = from; // Start of the fold
let foldEnd = to; // End of the fold
// Check the next line if it is on a deeper indent level
// If it is, check the next line and so on
// If it is not, go on with the foldEnd
let nextLine = line;
while (nextLine.number < lines) {
nextLine = state.doc.line(nextLine.number + 1); // Next line
const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line
// If the next line is on a deeper indent level, add it to the fold
if (nextIndent > indent) {
foldEnd = nextLine.to; // Set the fold end to the end of the next line
} else {
break; // If the next line is not on a deeper indent level, stop
}
}
// If the fold is only one line, don't fold it
if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) {
return null;
}
// Set the fold start to the end of the first line
// With this, the fold will not include the first line
foldStart = line.to;
// Return a fold that covers the entire indent level
return {from: foldStart, to: foldEnd};
});

View File

@@ -3,15 +3,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";
import { getNoteBlockFromPos } from '../codeblock/state';
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((?:https?|ftp):\/\/[^\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;
@@ -45,106 +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;
/**
* 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'
}).range(link.from, link.to));
// 检查当前位置是否在 HTTP 代码块中
const block = getNoteBlockFromPos(view.state, from);
if (block && block.language.name === 'http') {
// 如果在 HTTP 代码块中,跳过超链接装饰
continue;
}
const linkMark = Decoration.mark({
class: 'cm-hyper-link-text',
attributes: {
'data-url': match[0]
}
});
widgets.push(linkMark.range(from, 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) => {
// 检查当前位置是否在 HTTP 代码块中
const block = getNoteBlockFromPos(view.state, from);
if (block && block.language.name === 'http') {
// 如果在 HTTP 代码块中,跳过超链接装饰
return;
}
const url = match[0];
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
if (matchData && matchData[url]) {
urlStr = matchData[url];
}
const start = to,
end = to;
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
add(from, to, Decoration.mark({
class: 'cm-hyper-link-text cm-hyper-link-underline',
attributes: {
'data-url': urlStr
}
}));
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)
}));
}
}
}
@@ -158,7 +223,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
export const hyperLinkStyle = EditorView.baseTheme({
'.cm-hyper-link-text': {
color: '#0969da',
cursor: 'pointer',
cursor: 'text',
transition: 'color 0.2s ease',
textDecoration: 'underline',
textDecorationColor: '#0969da',
@@ -192,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': {
@@ -216,24 +281,19 @@ export const hyperLinkStyle = EditorView.baseTheme({
});
export const hyperLinkClickHandler = EditorView.domEventHandlers({
click: (event, view) => {
const target = event.target as HTMLElement;
let urlElement = target;
while (urlElement && !urlElement.hasAttribute('data-url')) {
urlElement = urlElement.parentElement as HTMLElement;
if (!urlElement || urlElement === document.body) break;
}
if (urlElement && urlElement.hasAttribute('data-url')) {
const url = urlElement.getAttribute('data-url');
click: (event) => {
const target = event.target as HTMLElement | null;
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
if (iconElement && iconElement.hasAttribute('data-url')) {
const url = iconElement.getAttribute('data-url');
if (url) {
runtime.Browser.OpenURL(url);
event.preventDefault();
return true;
}
}
return false;
}
});
@@ -242,4 +302,4 @@ export const hyperLink: Extension = [
hyperLinkExtension(),
hyperLinkStyle,
hyperLinkClickHandler
];
];

View File

@@ -0,0 +1,19 @@
import { Extension } from '@codemirror/state';
import { image } from './plugins/image';
import { headingSlugField } from './state/heading-slug';
import {html} from './plugins/html';
import { render } from './plugins/render';
import { Theme } from './plugins/theme';
/**
* Markdown extensions.
*/
export const markdownExtensions: Extension = [
headingSlugField,
render(),
Theme,
image(),
html()
];
export default markdownExtensions;

View File

@@ -0,0 +1,58 @@
/**
* 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' });
/**
* Handle Blockquote node.
*/
export function handleBlockquote(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): boolean {
if (ctx.seen.has(nf)) return false;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return false;
const startLine = ctx.view.state.doc.lineAt(nf).number;
const endLine = ctx.view.state.doc.lineAt(nt).number;
for (let i = startLine; i <= endLine; i++) {
if (!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;
}
/**
* Theme for blockquotes.
*/
export const blockquoteTheme = EditorView.baseTheme({
'.cm-blockquote': {
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
color: 'var(--cm-blockquote-color, #666)'
}
});

View File

@@ -0,0 +1,139 @@
/**
* Code block handler and theme.
*/
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' });
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>`;
class CodeBlockInfoWidget extends WidgetType {
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('span');
container.className = 'cm-code-block-info';
if (this.language) {
const lang = document.createElement('span');
lang.className = 'cm-code-block-lang';
lang.textContent = this.language;
container.append(lang);
}
const btn = document.createElement('button');
btn.className = 'cm-code-block-copy-btn';
btn.title = 'Copy';
btn.innerHTML = ICON_COPY;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
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;
}
ignoreEvent() { return true; }
}
/**
* Handle FencedCode / CodeBlock node.
*/
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]);
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 });
}
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 });
}
}
/**
* Theme for code blocks.
*/
export const codeBlockTheme = EditorView.baseTheme({
'.cm-codeblock': {
backgroundColor: 'var(--cm-codeblock-bg)',
fontFamily: 'inherit'
},
'.cm-codeblock-begin': {
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative'
},
'.cm-codeblock-end': {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
},
'.cm-code-block-info': {
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
zIndex: '5',
opacity: '0.5',
transition: 'opacity 0.15s'
},
'.cm-code-block-info:hover': { opacity: '1' },
'.cm-code-block-lang': {
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
textTransform: 'lowercase',
userSelect: 'none'
},
'.cm-code-block-copy-btn': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.15em',
border: 'none',
borderRadius: '2px',
background: 'transparent',
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
cursor: 'pointer',
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' }
});

View File

@@ -0,0 +1,57 @@
/**
* 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';
class EmojiWidget extends WidgetType {
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';
span.textContent = this.emoji;
span.title = `:${this.name}:`;
return span;
}
}
/**
* Handle Emoji node (:emoji:).
*/
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;
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) }) });
}
}
/**
* Theme for emoji.
*/
export const emojiTheme = EditorView.baseTheme({
'.cm-emoji': {
cursor: 'default',
fontSize: 'inherit',
lineHeight: 'inherit'
}
});

View File

@@ -0,0 +1,190 @@
/**
* Footnote handlers and theme.
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
/** 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;
}
class FootnoteRefWidget extends WidgetType {
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}]`;
if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined');
return span;
}
ignoreEvent() { return false; }
}
class InlineFootnoteWidget extends WidgetType {
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}]`;
return span;
}
ignoreEvent() { return false; }
}
class FootnoteDefLabelWidget extends WidgetType {
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}]`;
return span;
}
ignoreEvent() { return false; }
}
/**
* Handle FootnoteDefinition node.
*/
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 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 });
}
}
/**
* Handle FootnoteReference node.
*/
export function handleFootnoteReference(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (labelNode && marks.length >= 2) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
}
}
/**
* Handle InlineFootnote node.
*/
export function handleInlineFootnote(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
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 });
}
}
/**
* Process pending footnote refs after all definitions are collected.
*/
export function processPendingFootnotes(ctx: FootnoteContext): void {
for (const ref of ctx.pendingRefs) {
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
}
for (const inline of ctx.pendingInlines) {
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
}
}
/**
* Theme for footnotes.
*/
export const footnoteTheme = EditorView.baseTheme({
'.cm-footnote-ref': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '1em',
height: '1.2em',
padding: '0 0.25em',
marginLeft: '1px',
fontSize: '0.75em',
fontWeight: '500',
lineHeight: '1',
verticalAlign: 'super',
color: 'var(--cm-footnote-color, #1a73e8)',
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
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))'
},
'.cm-inline-footnote-ref': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '1em',
height: '1.2em',
padding: '0 0.25em',
marginLeft: '1px',
fontSize: '0.75em',
fontWeight: '500',
lineHeight: '1',
verticalAlign: 'super',
color: 'var(--cm-inline-footnote-color, #e67e22)',
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
borderRadius: '3px'
},
'.cm-footnote-def-label': {
color: 'var(--cm-footnote-def-color, #1a73e8)',
fontWeight: '600'
}
});

View File

@@ -0,0 +1,63 @@
/**
* 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' });
/**
* Handle ATXHeading node (# Heading).
*/
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;
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 });
}
}
/**
* Handle SetextHeading node (underline style).
*/
export function handleSetextHeading(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const headerMarks = node.getChildren('HeaderMark');
for (const mark of headerMarks) {
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
}
}
/**
* Theme for headings.
*/
export const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': {
fontSize: '0'
}
});

View File

@@ -0,0 +1,58 @@
/**
* Horizontal rule handler and theme.
*/
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; }
}
const hrWidget = new HorizontalRuleWidget();
/**
* 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 }) });
}
}
/**
* Theme for horizontal rules.
*/
export const horizontalRuleTheme = EditorView.baseTheme({
'.cm-horizontal-rule-widget': {
display: 'inline-block',
width: '100%',
verticalAlign: 'middle'
},
'.cm-horizontal-rule': {
width: '100%',
height: '0',
border: 'none',
borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))',
margin: '0.5em 0'
}
});

View File

@@ -0,0 +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 { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
showTooltip,
Tooltip
} from '@codemirror/view';
import DOMPurify from 'dompurify';
import { LruCache } from '@/common/utils/lruCache';
interface HTMLBlockInfo {
from: number;
to: number;
content: string;
sanitized: string;
}
// 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>`;
/**
* LRU cache for DOMPurify sanitize results.
*/
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;
}
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 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;
}
/**
* 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;
}
/**
* 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: HTMLBlockInfo[] = [];
constructor(view: EditorView) {
this.blocks = extractHTMLBlocks(view);
this.decorations = this.build();
}
update(update: ViewUpdate) {
// Always rebuild on viewport change
if (update.viewportChanged) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
return;
}
// 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
});
/**
* Close tooltip when clicking outside.
*/
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-indicator:hover': {
opacity: '1'
},
// 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'
},
// 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',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}
});
/**
* 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
];

View File

@@ -0,0 +1,331 @@
/**
* 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, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
showTooltip,
Tooltip
} from '@codemirror/view';
interface ImageInfo {
src: string;
from: number;
to: number;
alt: string;
}
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i;
const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/;
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
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) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ name, node, from: f, to: t }) => {
if (name !== 'Image') return;
const urlNode = node.getChild('URL');
if (!urlNode) return;
const src = view.state.sliceDoc(urlNode.from, urlNode.to);
if (!isImageUrl(src)) return;
const text = view.state.sliceDoc(f, t);
const alt = text.match(IMAGE_ALT_RE)?.[1] ?? '';
result.push({ src, from: f, to: t, alt });
}
});
}
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(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[] = [];
constructor(view: EditorView) {
this.images = extractImages(view);
this.decorations = this.build();
}
update(update: ViewUpdate) {
// 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)
}));
}
}
}
private build(): DecorationSet {
const deco: Range<Decoration>[] = [];
for (const img of this.images) {
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
}
return Decoration.set(deco, true);
}
}
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
decorations: (v) => v.decorations
});
/**
* Close tooltip when clicking outside.
*/
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-image-indicator') || target.closest('.cm-image-tooltip')) {
return false;
}
// Close tooltip if one is open
const currentTooltip = view.state.field(imageTooltipState);
if (currentTooltip) {
view.dispatch({
effects: closeImageTooltip.of(null)
});
}
return false;
}
});
const theme = EditorView.baseTheme({
'.cm-image-indicator': {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
verticalAlign: 'middle',
cursor: 'pointer',
opacity: '0.5',
color: 'var(--cm-link-color, #1a73e8)',
transition: 'opacity 0.15s',
'& svg': { width: '14px', height: '14px' }
},
'.cm-image-indicator:hover': { opacity: '1' },
'.cm-image-tooltip': {
position: 'relative',
background: `
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
`,
backgroundColor: '#fff',
backgroundSize: '12px 12px',
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px',
border: '1px solid var(--border-color)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
'& img': {
display: 'block',
maxWidth: '60vw',
maxHeight: '50vh',
opacity: '1',
transition: 'opacity 0.15s ease-out'
}
},
'.cm-image-loading': {
minWidth: '48px',
minHeight: '48px',
'& img': { opacity: '0' }
},
'.cm-image-spinner': {
position: 'absolute',
top: '50%',
left: '50%',
width: '16px',
height: '16px',
marginTop: '-8px',
marginLeft: '-8px',
border: '2px solid #ccc',
borderTopColor: '#666',
borderRadius: '50%',
animation: 'cm-spin 0.5s linear infinite'
},
'.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': {
display: 'none'
},
'@keyframes cm-spin': {
to: { transform: 'rotate(360deg)' }
},
'.cm-image-tooltip-error': {
padding: '16px 24px',
fontSize: '12px',
color: 'red'
}
});
export const image = (): Extension => [
imagePlugin,
imageTooltipState,
clickOutsideHandler,
theme
];

View File

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

View File

@@ -0,0 +1,111 @@
/**
* 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'; }
}
/**
* Handle URL node (within Link).
*/
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;
// 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;
// Get URL for the icon
const url = ctx.view.state.sliceDoc(nf, nt);
// Add underline decoration to link text
if (linkText) {
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
}
// Hide markdown syntax marks
for (const m of marks) {
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
}
// Hide URL
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
// Hide link title if present
if (linkTitle) {
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
}
// Add clickable icon widget after link text (at close bracket position)
if (closeBracket) {
ctx.items.push({
from: closeBracket.from,
to: closeBracket.from,
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
priority: 1
});
}
}
/**
* Theme for markdown links.
*/
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'
}
}
});

View File

@@ -0,0 +1,121 @@
/**
* List handlers and theme.
* Handles: ListMark (bullets), Task (checkboxes)
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const BULLET_RE = /^[-+*]$/;
class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) { super(); }
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-list-bullet';
span.textContent = '•';
return span;
}
}
class TaskCheckboxWidget extends WidgetType {
constructor(readonly checked: boolean, readonly pos: number) { super(); }
eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; }
toDOM(view: EditorView): HTMLElement {
const wrap = document.createElement('span');
wrap.setAttribute('aria-hidden', 'true');
wrap.className = 'cm-task-checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.tabIndex = -1;
checkbox.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
});
wrap.appendChild(checkbox);
return wrap;
}
ignoreEvent() { 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) }) });
}
}
/**
* Handle Task node (checkboxes).
*/
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;
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 });
}
/**
* Theme for lists.
*/
export const listTheme = EditorView.baseTheme({
'.cm-list-bullet': {
color: 'var(--cm-list-bullet-color, inherit)'
},
'.cm-task-checked': {
textDecoration: 'line-through',
opacity: '0.6'
},
'.cm-task-checkbox': {
display: 'inline-block',
verticalAlign: 'baseline'
},
'.cm-task-checkbox input': {
cursor: 'pointer',
margin: '0',
width: '1em',
height: '1em',
position: 'relative',
top: '0.1em'
}
});

View File

@@ -0,0 +1,163 @@
/**
* Math handlers and theme.
* Handles: InlineMath, BlockMath
*/
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';
class InlineMathWidget extends WidgetType {
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';
try {
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
} catch (e) {
span.textContent = this.latex;
span.title = e instanceof Error ? e.message : 'Render error';
}
return span;
}
ignoreEvent() { return false; }
}
class BlockMathWidget extends WidgetType {
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';
container.style.height = `${this.lineCount * this.lineHeight}px`;
const inner = document.createElement('div');
inner.className = 'cm-block-math';
try {
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
} catch (e) {
inner.textContent = this.latex;
inner.title = e instanceof Error ? e.message : 'Render error';
}
container.appendChild(inner);
return container;
}
ignoreEvent() { return false; }
}
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' });
/**
* 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 });
}
}
/**
* Handle BlockMath node ($$...$$).
*/
export function handleBlockMath(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const 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 });
}
}
}
/**
* Theme for math.
*/
export const mathTheme = EditorView.baseTheme({
'.cm-inline-math': {
display: 'inline',
verticalAlign: 'baseline'
},
'.cm-inline-math .katex': {
fontSize: 'inherit'
},
'.cm-block-math-container': {
position: 'absolute',
left: '0',
right: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
zIndex: '1'
},
'.cm-block-math': {
display: 'inline-block',
textAlign: 'center',
pointerEvents: 'auto'
},
'.cm-block-math .katex-display': {
margin: '0'
},
'.cm-block-math .katex': {
fontSize: '1.1em'
},
'.cm-line.cm-block-math-line': {
color: 'transparent !important',
caretColor: 'transparent'
},
'.cm-line.cm-block-math-line span': {
color: 'transparent !important'
},
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
color: 'transparent !important'
},
'.cm-block-math-content-hidden': {
color: 'transparent !important'
},
'.cm-block-math-content-hidden span': {
color: 'transparent !important'
},
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
color: 'transparent !important'
}
});

View File

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

View File

@@ -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, '&lt;').replace(/>/g, '&gt;');
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, '&quot;')}">${renderInlineMarkdown(h)}</th>`).join('');
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '&quot;')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
container.appendChild(wrapper);
return container;
}
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' }
});

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField } from '@codemirror/state';
import { Slugger } from '../util';
import {SyntaxNode} from "@lezer/common";
/**
* A heading slug is a string that is used to identify/reference
* a heading in the document. Heading slugs are URI-compatible and can be used
* in permalinks as heading IDs.
*/
export interface HeadingSlug {
slug: string;
pos: number;
}
/**
* A plugin that stores the calculated slugs of the document headings in the
* editor state. These can be useful when resolving links to headings inside
* the document.
*/
export const headingSlugField = StateField.define<HeadingSlug[]>({
create: (state) => {
const slugs = extractSlugs(state);
return slugs;
},
update: (value, tx) => {
if (tx.docChanged) return extractSlugs(tx.state);
return value;
},
compare: (a, b) =>
a.length === b.length &&
a.every((slug, i) => slug.slug === b[i].slug && slug.pos === b[i].pos)
});
/**
*
* @param state - The current editor state.
* @returns An array of heading slugs.
*/
function extractSlugs(state: EditorState): HeadingSlug[] {
const slugs: HeadingSlug[] = [];
const slugger = new Slugger();
syntaxTree(state).iterate({
enter: ({ name, from, to, node }) => {
// Capture ATXHeading and SetextHeading
if (!name.includes('Heading')) return;
const mark: SyntaxNode | null = node.getChild('HeaderMark');
const headerText = state.sliceDoc(from, to).split('');
headerText.splice(mark!.from - from, mark!.to - mark!.from);
const slug = slugger.slug(headerText.join('').trim());
slugs.push({ slug, pos: from });
}
});
return slugs;
}

View File

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

View File

@@ -0,0 +1,259 @@
/**
* Footnote extension for Lezer Markdown parser.
*
* Parses footnote syntax compatible with MultiMarkdown/PHP Markdown Extra.
*
* Syntax:
* - Footnote reference: [^id] or [^1]
* - Footnote definition: [^id]: content (at line start)
* - Inline footnote: ^[content] (content is inline, no separate definition needed)
*
* Examples:
* - This is text[^1] with a footnote.
* - [^1]: This is the footnote content.
* - This is text^[inline footnote content] with inline footnote.
*/
import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown';
import { CharCode, isFootnoteIdChar } from '../util';
/**
* Parse inline footnote ^[content].
*
* @param cx - Inline context
* @param pos - Start position (at ^)
* @returns Position after element, or -1 if no match
*/
function parseInlineFootnote(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: ^[ + content + ] = at least 4 chars
if (end < pos + 3) return -1;
// Track bracket depth for nested brackets
let bracketDepth = 1;
let hasContent = false;
const contentStart = pos + 2;
for (let i = contentStart; i < end; i++) {
const char = cx.char(i);
// Don't allow newlines
if (char === CharCode.Newline) return -1;
// Track bracket depth
if (char === CharCode.OpenBracket) {
bracketDepth++;
} else if (char === CharCode.CloseBracket) {
bracketDepth--;
if (bracketDepth === 0) {
// Found closing bracket - must have content
if (!hasContent) return -1;
// Create element with marks and content
return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [
cx.elt('InlineFootnoteMark', pos, contentStart),
cx.elt('InlineFootnoteContent', contentStart, i),
cx.elt('InlineFootnoteMark', i, i + 1)
]));
}
} else {
hasContent = true;
}
}
return -1;
}
/**
* Parse footnote reference [^id].
*
* @param cx - Inline context
* @param pos - Start position (at [)
* @returns Position after element, or -1 if no match
*/
function parseFootnoteReference(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: [^ + id + ] = at least 4 chars
if (end < pos + 3) return -1;
let hasValidId = false;
const labelStart = pos + 2;
for (let i = labelStart; i < end; i++) {
const char = cx.char(i);
// Found closing bracket
if (char === CharCode.CloseBracket) {
if (!hasValidId) return -1;
// Create element with marks and label
return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [
cx.elt('FootnoteReferenceMark', pos, labelStart),
cx.elt('FootnoteReferenceLabel', labelStart, i),
cx.elt('FootnoteReferenceMark', i, i + 1)
]));
}
// Don't allow newlines
if (char === CharCode.Newline) return -1;
// Validate id character using O(1) lookup table
if (isFootnoteIdChar(char)) {
hasValidId = true;
} else {
return -1;
}
}
return -1;
}
/**
* Parse footnote definition [^id]: content.
*
* @param cx - Block context
* @param line - Current line
* @returns True if parsed successfully
*/
function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean {
const text = line.text;
const len = text.length;
// Minimum: [^id]: = at least 5 chars
if (len < 5) return false;
// Find ]: pattern - use O(1) lookup for ID chars
let labelEnd = 2;
while (labelEnd < len) {
const char = text.charCodeAt(labelEnd);
if (char === CharCode.CloseBracket) {
// Check for : after ]
if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) {
break;
}
return false;
}
// Use O(1) lookup table
if (!isFootnoteIdChar(char)) return false;
labelEnd++;
}
// Validate ]: was found
if (labelEnd >= len ||
text.charCodeAt(labelEnd) !== CharCode.CloseBracket ||
text.charCodeAt(labelEnd + 1) !== CharCode.Colon) {
return false;
}
// Calculate positions (all at once to avoid repeated arithmetic)
const start = cx.lineStart;
const openMarkEnd = start + 2;
const labelEndPos = start + labelEnd;
const closeMarkEnd = start + labelEnd + 2;
// Skip optional space after :
let contentOffset = labelEnd + 2;
if (contentOffset < len) {
const spaceChar = text.charCodeAt(contentOffset);
if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) {
contentOffset++;
}
}
// Build children array
const children = [
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos),
cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd)
];
// Add content if present
if (contentOffset < len) {
children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len));
}
// Create and add block element
cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children));
cx.nextLine();
return true;
}
/**
* Footnote extension for Lezer Markdown.
*
* Defines nodes:
* - FootnoteReference: Inline reference [^id]
* - FootnoteReferenceMark: The [^ and ] delimiters
* - FootnoteReferenceLabel: The id part
* - FootnoteDefinition: Block definition [^id]: content
* - FootnoteDefinitionMark: The [^, ]: delimiters
* - FootnoteDefinitionLabel: The id part in definition
* - FootnoteDefinitionContent: The content part
* - InlineFootnote: Inline footnote ^[content]
* - InlineFootnoteMark: The ^[ and ] delimiters
* - InlineFootnoteContent: The content part
*/
export const Footnote: MarkdownConfig = {
defineNodes: [
// Inline reference nodes
{ name: 'FootnoteReference' },
{ name: 'FootnoteReferenceMark' },
{ name: 'FootnoteReferenceLabel' },
// Block definition nodes
{ name: 'FootnoteDefinition', block: true },
{ name: 'FootnoteDefinitionMark' },
{ name: 'FootnoteDefinitionLabel' },
{ name: 'FootnoteDefinitionContent' },
// Inline footnote nodes
{ name: 'InlineFootnote' },
{ name: 'InlineFootnoteMark' },
{ name: 'InlineFootnoteContent' },
],
parseInline: [
{
name: 'InlineFootnote',
parse(cx, next, pos) {
// Fast path: must start with ^[
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.OpenBracket) {
return -1;
}
return parseInlineFootnote(cx, pos);
},
before: 'Superscript',
},
{
name: 'FootnoteReference',
parse(cx, next, pos) {
// Fast path: must start with [^
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.Caret) {
return -1;
}
return parseFootnoteReference(cx, pos);
},
before: 'Link',
},
],
parseBlock: [
{
name: 'FootnoteDefinition',
parse(cx: BlockContext, line: Line): boolean {
// Fast path: must start with [^
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
line.text.charCodeAt(1) !== CharCode.Caret) {
return false;
}
return parseFootnoteDefinition(cx, line);
},
before: 'LinkReference',
},
],
};
export default Footnote;

View File

@@ -0,0 +1,38 @@
/**
* Highlight extension for Lezer Markdown parser.
*
* Parses ==highlight== syntax similar to Obsidian/Mark style.
*
* Syntax: ==text== → renders as highlighted text
*
* Example:
* - This is ==important== text → This is <mark>important</mark> text
*/
import { MarkdownConfig } from '@lezer/markdown';
import { CharCode, createPairedDelimiterParser } from '../util';
/**
* Highlight extension for Lezer Markdown.
* Defines:
* - Highlight: The container node for highlighted content
* - HighlightMark: The == delimiter marks
*/
export const Highlight: MarkdownConfig = {
defineNodes: [
{ name: 'Highlight' },
{ name: 'HighlightMark' }
],
parseInline: [
createPairedDelimiterParser({
name: 'Highlight',
nodeName: 'Highlight',
markName: 'HighlightMark',
delimChar: CharCode.Equal,
isDouble: true,
after: 'Emphasis'
})
]
};
export default Highlight;

View File

@@ -0,0 +1,41 @@
/**
* Insert extension for Lezer Markdown parser.
*
* Parses ++insert++ syntax for inserted/underlined text.
*
* Syntax: ++text++ → renders as inserted text (underline)
*
* Example:
* - This is ++inserted++ text → This is <ins>inserted</ins> text
*/
import { MarkdownConfig } from '@lezer/markdown';
import { CharCode, createPairedDelimiterParser } from '../util';
/**
* Insert extension for Lezer Markdown.
*
* Uses optimized factory function for O(n) single-pass parsing.
*
* Defines:
* - Insert: The container node for inserted content
* - InsertMark: The ++ delimiter marks
*/
export const Insert: MarkdownConfig = {
defineNodes: [
{ name: 'Insert' },
{ name: 'InsertMark' }
],
parseInline: [
createPairedDelimiterParser({
name: 'Insert',
nodeName: 'Insert',
markName: 'InsertMark',
delimChar: CharCode.Plus,
isDouble: true,
after: 'Emphasis'
})
]
};
export default Insert;

View File

@@ -0,0 +1,146 @@
/**
* Math extension for Lezer Markdown parser.
*
* Parses LaTeX math syntax:
* - Inline math: $E=mc^2$ → renders as inline formula
* - Block math: $$...$$ → renders as block formula (can be multi-line)
*/
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
import { CharCode } from '../util';
/**
* Parse block math ($$...$$).
* Allows multi-line content and handles escaped $.
*
* @param cx - Inline context
* @param pos - Start position (at first $)
* @returns Position after element, or -1 if no match
*/
function parseBlockMath(cx: InlineContext, pos: number): number {
const end = cx.end;
// Don't match $$$ or more
if (cx.char(pos + 2) === CharCode.Dollar) return -1;
// Minimum: $$ + content + $$ = at least 5 chars
const minEnd = pos + 4;
if (end < minEnd) return -1;
// Search for closing $$
const searchEnd = end - 1;
for (let i = pos + 2; i < searchEnd; i++) {
const char = cx.char(i);
// Skip escaped $ (backslash followed by any char)
if (char === CharCode.Backslash) {
i++; // Skip next char
continue;
}
// Found potential closing $$
if (char === CharCode.Dollar) {
const nextChar = cx.char(i + 1);
if (nextChar !== CharCode.Dollar) continue;
// Don't match $$$
if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue;
// Ensure content exists
if (i === pos + 2) return -1;
// Create element with marks
return cx.addElement(cx.elt('BlockMath', pos, i + 2, [
cx.elt('BlockMathMark', pos, pos + 2),
cx.elt('BlockMathMark', i, i + 2)
]));
}
}
return -1;
}
/**
* Parse inline math ($...$).
* Single line only, handles escaped $.
*
* @param cx - Inline context
* @param pos - Start position (at $)
* @returns Position after element, or -1 if no match
*/
function parseInlineMath(cx: InlineContext, pos: number): number {
const end = cx.end;
// Don't match if preceded by backslash (escaped)
if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1;
// Minimum: $ + content + $ = at least 3 chars
if (end < pos + 2) return -1;
// Search for closing $
for (let i = pos + 1; i < end; i++) {
const char = cx.char(i);
// Newline not allowed in inline math
if (char === CharCode.Newline) return -1;
// Skip escaped $
if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) {
i++; // Skip next char
continue;
}
// Found potential closing $
if (char === CharCode.Dollar) {
// Don't match $$
if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue;
// Ensure content exists
if (i === pos + 1) return -1;
// Create element with marks
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [
cx.elt('InlineMathMark', pos, pos + 1),
cx.elt('InlineMathMark', i, i + 1)
]));
}
}
return -1;
}
/**
* Math extension for Lezer Markdown.
*
* Defines:
* - InlineMath: Inline math formula $...$
* - InlineMathMark: The $ delimiter marks for inline
* - BlockMath: Block math formula $$...$$
* - BlockMathMark: The $$ delimiter marks for block
*/
export const Math: MarkdownConfig = {
defineNodes: [
{ name: 'InlineMath' },
{ name: 'InlineMathMark' },
{ name: 'BlockMath' },
{ name: 'BlockMathMark' }
],
parseInline: [
{
name: 'Math',
parse(cx, next, pos) {
// Fast path: must start with $
if (next !== CharCode.Dollar) return -1;
// Check for $$ (block math) vs $ (inline math)
const isBlock = cx.char(pos + 1) === CharCode.Dollar;
return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos);
},
// Parse after emphasis to avoid conflicts
after: 'Emphasis'
}
]
};
export default Math;

View File

@@ -0,0 +1,202 @@
import { Decoration } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { InlineContext, InlineParser } from '@lezer/markdown';
/**
* ASCII character codes for common delimiters.
*/
export const enum CharCode {
Space = 32,
Tab = 9,
Newline = 10,
Backslash = 92,
Dollar = 36, // $
Plus = 43, // +
Equal = 61, // =
OpenBracket = 91, // [
CloseBracket = 93, // ]
Caret = 94, // ^
Colon = 58, // :
Hyphen = 45, // -
Underscore = 95, // _
}
/**
* Pre-computed lookup table for footnote ID characters.
* Valid characters: 0-9, A-Z, a-z, _, -
* Uses Uint8Array for memory efficiency and O(1) lookup.
*/
const FOOTNOTE_ID_CHARS = new Uint8Array(128);
// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45)
for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9
for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z
for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z
FOOTNOTE_ID_CHARS[95] = 1; // _
FOOTNOTE_ID_CHARS[45] = 1; // -
/**
* O(1) check if a character is valid for footnote ID.
* @param code - ASCII character code
* @returns True if valid footnote ID character
*/
export function isFootnoteIdChar(code: number): boolean {
return code < 128 && FOOTNOTE_ID_CHARS[code] === 1;
}
/**
* Configuration for paired delimiter parser factory.
*/
export interface PairedDelimiterConfig {
/** Parser name */
name: string;
/** Node name for the container element */
nodeName: string;
/** Node name for the delimiter marks */
markName: string;
/** First delimiter character code */
delimChar: number;
/** Whether delimiter is doubled (e.g., == vs =) */
isDouble: true;
/** Whether to allow newlines in content */
allowNewlines?: boolean;
/** Parse order - after which parser */
after?: string;
/** Parse order - before which parser */
before?: string;
}
/**
* Factory function to create a paired delimiter inline parser.
* Optimized with:
* - Fast path early return
* - Minimal function calls in loop
* - Pre-computed delimiter length
*
* @param config - Parser configuration
* @returns InlineParser for MarkdownConfig
*/
export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser {
const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config;
const delimLen = 2; // Always double delimiter for these parsers
return {
name,
parse(cx: InlineContext, next: number, pos: number): number {
// Fast path: check first character
if (next !== delimChar) return -1;
// Check second delimiter character
if (cx.char(pos + 1) !== delimChar) return -1;
// Don't match triple delimiter (e.g., ===, +++)
if (cx.char(pos + 2) === delimChar) return -1;
// Calculate search bounds
const searchEnd = cx.end - 1;
const contentStart = pos + delimLen;
// Look for closing delimiter
for (let i = contentStart; i < searchEnd; i++) {
const char = cx.char(i);
// Check for newline (unless allowed)
if (!allowNewlines && char === CharCode.Newline) return -1;
// Found potential closing delimiter
if (char === delimChar && cx.char(i + 1) === delimChar) {
// Don't match triple delimiter
if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue;
// Create element with marks
return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [
cx.elt(markName, pos, contentStart),
cx.elt(markName, i, i + delimLen)
]));
}
}
return -1;
},
...(after && { after }),
...(before && { before })
};
}
/**
* Tuple representation of a range [from, to].
*/
export type RangeTuple = [number, number];
/**
* Check if two ranges overlap (touch or intersect).
* Based on the visual diagram on https://stackoverflow.com/a/25369187
*
* @param range1 - First range
* @param range2 - Second range
* @returns True if the ranges overlap
*/
export function checkRangeOverlap(
range1: RangeTuple,
range2: RangeTuple
): boolean {
return range1[0] <= range2[1] && range2[0] <= range1[1];
}
/**
* Check if any of the editor cursors is in the given range.
*
* @param state - Editor state
* @param range - Range to check
* @returns True if the cursor is in the range
*/
export function isCursorInRange(
state: EditorState,
range: RangeTuple
): boolean {
return state.selection.ranges.some((selection) =>
checkRangeOverlap(range, [selection.from, selection.to])
);
}
/**
* Decoration to simply hide anything (replace with nothing).
*/
export const invisibleDecoration = Decoration.replace({});
/**
* Class for generating unique slugs from heading contents.
*/
export class Slugger {
/** Occurrences for each slug. */
private occurrences: Map<string, number> = new Map();
/**
* Generate a slug from the given content.
*
* @param text - Content to generate the slug from
* @returns The generated slug
*/
public slug(text: string): string {
let slug = text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '');
const count = this.occurrences.get(slug) || 0;
if (count > 0) {
slug += '-' + count;
}
this.occurrences.set(slug, count + 1);
return slug;
}
/**
* Reset the slugger state.
*/
public reset(): void {
this.occurrences.clear();
}
}

View File

@@ -1,62 +0,0 @@
/**
* Markdown 预览扩展主入口
*/
import { EditorView } from "@codemirror/view";
import { useThemeStore } from "@/stores/themeStore";
import { usePanelStore } from "@/stores/panelStore";
import { useDocumentStore } from "@/stores/documentStore";
import { getActiveNoteBlock } from "../codeblock/state";
import { createMarkdownPreviewTheme } from "./styles";
import { previewPanelState, previewPanelPlugin, togglePreview, closePreviewWithAnimation } from "./state";
/**
* 切换预览面板的命令
*/
export function toggleMarkdownPreview(view: EditorView): boolean {
const panelStore = usePanelStore();
const documentStore = useDocumentStore();
const currentState = view.state.field(previewPanelState, false);
const activeBlock = getActiveNoteBlock(view.state as any);
// 如果当前没有激活的 Markdown 块,不执行操作
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
return false;
}
// 获取当前文档ID
const currentDocumentId = documentStore.currentDocumentId;
if (currentDocumentId === null) {
return false;
}
// 如果预览面板已打开(无论预览的是不是当前块),关闭预览
if (panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing) {
// 使用带动画的关闭函数
closePreviewWithAnimation(view);
} else {
// 否则,打开当前块的预览
view.dispatch({
effects: togglePreview.of({
documentId: currentDocumentId,
blockFrom: activeBlock.content.from,
blockTo: activeBlock.content.to
})
});
// 注意store 状态由 ViewPlugin 在面板创建成功后更新
}
return true;
}
/**
* 导出 Markdown 预览扩展
*/
export function markdownPreviewExtension() {
const themeStore = useThemeStore();
const colors = themeStore.currentColors;
const theme = colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
return [previewPanelState, previewPanelPlugin, theme];
}

View File

@@ -1,117 +0,0 @@
/**
* Markdown 渲染器配置和自定义插件
*/
import MarkdownIt from 'markdown-it';
import {tasklist} from "@mdit/plugin-tasklist";
import {katex} from "@mdit/plugin-katex";
import markPlugin from "@/common/markdown-it/plugins/markdown-it-mark";
import hljs from 'highlight.js';
import 'highlight.js/styles/default.css';
import {full as emoji} from '@/common/markdown-it/plugins/markdown-it-emojis/'
import footnote_plugin from "@/common/markdown-it/plugins/markdown-it-footnote"
import sup_plugin from "@/common/markdown-it/plugins/markdown-it-sup"
import ins_plugin from "@/common/markdown-it/plugins/markdown-it-ins"
import deflist_plugin from "@/common/markdown-it/plugins/markdown-it-deflist"
import abbr_plugin from "@/common/markdown-it/plugins/markdown-it-abbr"
import sub_plugin from "@/common/markdown-it/plugins/markdown-it-sub"
import {MermaidIt} from "@/common/markdown-it/plugins/markdown-it-mermaid"
import {useThemeStore} from '@/stores/themeStore'
/**
* 自定义链接插件:使用 data-href 替代 href配合事件委托实现自定义跳转
*/
export function customLinkPlugin(md: MarkdownIt) {
// 保存默认的 link_open 渲染器
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// 重写 link_open 渲染器
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
// 获取 href 属性
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
// 添加 data-href 属性保存原始链接
token.attrPush(['data-href', href]);
// 添加 class 用于样式
const classIndex = token.attrIndex('class');
if (classIndex < 0) {
token.attrPush(['class', 'markdown-link']);
} else {
token.attrs![classIndex][1] += ' markdown-link';
}
// 移除 href 属性,防止默认跳转
token.attrs!.splice(hrefIndex, 1);
}
return defaultRender(tokens, idx, options, env, self);
};
}
/**
* 创建 Markdown-It 实例
*/
export function createMarkdownRenderer(): MarkdownIt {
const themeStore = useThemeStore();
const mermaidTheme = themeStore.isDarkMode ? "dark" : "default";
return new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
langPrefix: "language-",
highlight: (code, lang) => {
// 对于大代码块(>1000行跳过高亮以提升性能
if (code.length > 50000) {
return `<pre><code>${code}</code></pre>`;
}
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, {language: lang, ignoreIllegals: true}).value;
} catch (error) {
console.warn(`Failed to highlight code block with language: ${lang}`, error);
return code;
}
}
// 对于中等大小的代码块(>5000字符跳过自动检测
if (code.length > 5000) {
return code;
}
// 小代码块才使用自动检测
try {
return hljs.highlightAuto(code).value;
} catch (error) {
console.warn('Failed to auto-highlight code block', error);
return code;
}
}
})
.use(tasklist, {
disabled: false,
})
.use(customLinkPlugin)
.use(markPlugin)
.use(emoji)
.use(footnote_plugin)
.use(sup_plugin)
.use(ins_plugin)
.use(deflist_plugin)
.use(abbr_plugin)
.use(sub_plugin)
.use(katex)
.use(MermaidIt, {
theme: mermaidTheme
});
}

View File

@@ -1,367 +0,0 @@
/**
* Markdown 预览面板 UI 组件
*/
import {EditorView, Panel, ViewUpdate} from "@codemirror/view";
import MarkdownIt from 'markdown-it';
import * as runtime from "@wailsio/runtime";
import {previewPanelState} from "./state";
import {createMarkdownRenderer} from "./markdownRenderer";
import {updateMermaidTheme} from "@/common/markdown-it/plugins/markdown-it-mermaid";
import {useThemeStore} from "@/stores/themeStore";
import {usePanelStore} from "@/stores/panelStore";
import {watch} from "vue";
import {createDebounce} from "@/common/utils/debounce";
import {morphHTML} from "@/common/utils/domDiff";
/**
* Markdown 预览面板类
*/
export class MarkdownPreviewPanel {
private md: MarkdownIt;
private readonly dom: HTMLDivElement;
private readonly resizeHandle: HTMLDivElement;
private readonly content: HTMLDivElement;
private view: EditorView;
private themeUnwatch?: () => void;
private lastRenderedContent: string = "";
private readonly debouncedUpdate: ReturnType<typeof createDebounce>;
private isDestroyed: boolean = false; // 标记面板是否已销毁
constructor(view: EditorView) {
this.view = view;
this.md = createMarkdownRenderer();
// 创建防抖更新函数
this.debouncedUpdate = createDebounce(() => {
this.updateContentInternal();
}, { delay: 500 });
// 监听主题变化
const themeStore = useThemeStore();
this.themeUnwatch = watch(() => themeStore.isDarkMode, (isDark) => {
const newTheme = isDark ? "dark" : "default";
updateMermaidTheme(newTheme);
this.lastRenderedContent = ""; // 清空缓存,强制重新渲染
});
// 创建 DOM 结构
this.dom = document.createElement("div");
this.dom.className = "cm-markdown-preview-panel";
this.resizeHandle = document.createElement("div");
this.resizeHandle.className = "cm-preview-resize-handle";
this.content = document.createElement("div");
this.content.className = "cm-preview-content";
this.dom.appendChild(this.resizeHandle);
this.dom.appendChild(this.content);
// 设置默认高度为编辑器高度的一半
const defaultHeight = Math.floor(this.view.dom.clientHeight / 2);
this.dom.style.height = `${defaultHeight}px`;
// 初始化拖动功能
this.initResize();
// 初始化链接点击处理
this.initLinkHandler();
// 初始渲染
this.updateContentInternal();
}
/**
* 初始化链接点击处理(事件委托)
*/
private initLinkHandler(): void {
this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 查找最近的 <a> 标签
let linkElement = target;
while (linkElement && linkElement !== this.content) {
if (linkElement.tagName === 'A') {
const anchor = linkElement as HTMLAnchorElement;
const href = anchor.getAttribute('href');
// 处理脚注内部锚点链接
if (href && href.startsWith('#')) {
e.preventDefault();
// 在预览面板内查找目标元素
const targetId = href.substring(1);
// 使用 getElementById 而不是 querySelector因为 ID 可能包含特殊字符(如冒号)
const targetElement = document.getElementById(targetId);
if (targetElement && this.content.contains(targetElement)) {
// 平滑滚动到目标元素
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return;
}
// 处理带 data-href 的外部链接
if (anchor.hasAttribute('data-href')) {
e.preventDefault();
const url = anchor.getAttribute('data-href');
if (url && this.isValidUrl(url)) {
runtime.Browser.OpenURL(url);
}
return;
}
// 处理其他链接
if (href && !href.startsWith('#')) {
e.preventDefault();
// 只有有效的 URLhttp/https/mailto/file 等)才用浏览器打开
if (this.isValidUrl(href)) {
runtime.Browser.OpenURL(href);
} else {
// 相对路径或无效链接,显示提示
console.warn('Invalid or relative link in preview:', href);
}
return;
}
}
linkElement = linkElement.parentElement as HTMLElement;
}
});
}
/**
* 检查是否是有效的 URL包含协议
*/
private isValidUrl(url: string): boolean {
try {
// 检查是否包含协议
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
const parsedUrl = new URL(url);
// 允许的协议列表
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
return allowedProtocols.includes(parsedUrl.protocol);
}
return false;
} catch {
return false;
}
}
/**
* 初始化拖动调整高度功能
*/
private initResize(): void {
let startY = 0;
let startHeight = 0;
const onMouseMove = (e: MouseEvent) => {
const delta = startY - e.clientY;
const maxHeight = this.getMaxHeight();
const newHeight = Math.max(100, Math.min(maxHeight, startHeight + delta));
this.dom.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
this.resizeHandle.classList.remove("dragging");
// 恢复 body 样式
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
this.resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
startY = e.clientY;
startHeight = this.dom.offsetHeight;
this.resizeHandle.classList.add("dragging");
// 设置 body 样式,防止拖动时光标闪烁
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
/**
* 动态计算最大高度(编辑器高度)
*/
private getMaxHeight(): number {
return this.view.dom.clientHeight;
}
/**
* 内部更新预览内容(带缓存 + DOM Diff 优化)
*/
private updateContentInternal(): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const state = this.view.state;
const currentPreviewState = state.field(previewPanelState, false);
if (!currentPreviewState) {
return;
}
const blockContent = state.doc.sliceString(
currentPreviewState.blockFrom,
currentPreviewState.blockTo
);
if (!blockContent || blockContent.trim().length === 0) {
return;
}
// 缓存检查:如果内容没变,不重新渲染
if (blockContent === this.lastRenderedContent) {
return;
}
// 对于大内容,使用异步渲染避免阻塞主线程
if (blockContent.length > 1000) {
this.renderLargeContentAsync(blockContent);
} else {
// 小内容使用 DOM Diff 优化渲染
this.renderWithDiff(blockContent);
}
} catch (error) {
console.warn("Error updating preview content:", error);
}
}
/**
* 使用 DOM Diff 渲染内容(保留未变化的节点)
*/
private renderWithDiff(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const newHtml = this.md.render(content);
// 如果是首次渲染或内容为空,直接设置 innerHTML
if (!this.lastRenderedContent || this.content.children.length === 0) {
this.content.innerHTML = newHtml;
} else {
// 使用 DOM Diff 增量更新
morphHTML(this.content, newHtml);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering with diff:", error);
// 降级到直接设置 innerHTML
if (!this.isDestroyed) {
this.content.innerHTML = this.md.render(content);
this.lastRenderedContent = content;
}
}
}
/**
* 异步渲染大内容(使用 DOM Diff 优化)
*/
private renderLargeContentAsync(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
// 如果是首次渲染,显示加载状态
if (!this.lastRenderedContent) {
this.content.innerHTML = '<div class="markdown-loading">Rendering...</div>';
}
// 使用 requestIdleCallback 在浏览器空闲时渲染
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
callback(() => {
// 再次检查是否已销毁(异步回调时可能已经关闭)
if (this.isDestroyed) {
return;
}
try {
const html = this.md.render(content);
// 如果是首次渲染或之前内容为空,直接设置
if (!this.lastRenderedContent || this.content.children.length === 0) {
// 使用 DocumentFragment 减少 DOM 操作
const fragment = document.createRange().createContextualFragment(html);
this.content.innerHTML = '';
this.content.appendChild(fragment);
} else {
// 使用 DOM Diff 增量更新(保留滚动位置和未变化的节点)
morphHTML(this.content, html);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering large content:", error);
if (!this.isDestroyed) {
this.content.innerHTML = '<div class="markdown-error">Render failed</div>';
}
}
});
}
/**
* 响应编辑器更新
*/
public update(update: ViewUpdate): void {
if (update.docChanged) {
// 文档改变时使用防抖更新
this.debouncedUpdate.debouncedFn();
} else if (update.selectionSet) {
// 光标移动时不触发更新
// 如果需要根据光标位置更新,可以在这里处理
}
}
/**
* 清理资源
*/
public destroy(): void {
// 标记为已销毁,防止异步回调继续执行
this.isDestroyed = true;
// 清理防抖
if (this.debouncedUpdate) {
this.debouncedUpdate.cancel();
}
// 清空缓存
this.lastRenderedContent = "";
}
/**
* 获取 CodeMirror Panel 对象
*/
public getPanel(): Panel {
return {
top: false,
dom: this.dom,
update: (update: ViewUpdate) => this.update(update),
destroy: () => this.destroy()
};
}
}
/**
* 创建预览面板
*/
export function createPreviewPanel(view: EditorView): Panel {
const panel = new MarkdownPreviewPanel(view);
return panel.getPanel();
}

View File

@@ -1,142 +0,0 @@
/**
* Markdown 预览面板的 CodeMirror 状态管理
*/
import { EditorView, showPanel, ViewUpdate, ViewPlugin } from "@codemirror/view";
import { StateEffect, StateField } from "@codemirror/state";
import { getActiveNoteBlock } from "../codeblock/state";
import { usePanelStore } from "@/stores/panelStore";
import { createPreviewPanel } from "./panel";
import type { PreviewState } from "./types";
/**
* 定义切换预览面板的 Effect
*/
export const togglePreview = StateEffect.define<PreviewState | null>();
/**
* 关闭面板(带动画)
*/
export function closePreviewWithAnimation(view: EditorView): void {
const panelStore = usePanelStore();
// 标记开始关闭
panelStore.startClosingMarkdownPreview();
const panelElement = view.dom.querySelector('.cm-panels.cm-panels-bottom') as HTMLElement;
if (panelElement) {
panelElement.style.animation = 'panelSlideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
// 等待动画完成后再关闭面板
setTimeout(() => {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}, 280);
} else {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}
}
/**
* 定义预览面板的状态字段
*/
export const previewPanelState = StateField.define<PreviewState | null>({
create: () => null,
update(value, tr) {
const panelStore = usePanelStore();
for (let e of tr.effects) {
if (e.is(togglePreview)) {
value = e.value;
}
}
// 如果有预览状态,智能管理预览生命周期
if (value && !value.closing) {
const activeBlock = getActiveNoteBlock(tr.state as any);
// 关键修复:检查预览状态是否属于当前文档
// 如果 panelStore 中没有当前文档的预览状态(说明切换了文档),
// 则不执行关闭逻辑,保持其他文档的预览状态
if (!panelStore.markdownPreview.isOpen) {
// 当前文档没有预览,不处理
return value;
}
// 场景1离开 Markdown 块或无激活块 → 关闭预览
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景2切换到其他块起始位置变化→ 关闭预览
else if (activeBlock.content.from !== value.blockFrom) {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景3还在同一个块内编辑只有结束位置变化→ 更新范围,实时预览
else if (activeBlock.content.to !== value.blockTo) {
// 更新 panelStore 中的预览范围
panelStore.updatePreviewRange(value.blockFrom, activeBlock.content.to);
return {
documentId: value.documentId,
blockFrom: value.blockFrom,
blockTo: activeBlock.content.to,
closing: false
};
}
}
return value;
},
provide: f => showPanel.from(f, state => state ? createPreviewPanel : null)
});
/**
* 创建监听插件
*/
export const previewPanelPlugin = ViewPlugin.fromClass(class {
private lastState: PreviewState | null | undefined = null;
private panelStore = usePanelStore();
constructor(private view: EditorView) {
this.lastState = view.state.field(previewPanelState, false);
this.panelStore.setEditorView(view);
}
update(update: ViewUpdate) {
const currentState = update.state.field(previewPanelState, false);
// 检测到面板打开(从 null 变为有值,且不是 closing
if (currentState && !currentState.closing && !this.lastState) {
// 验证面板 DOM 是否真正创建成功
requestAnimationFrame(() => {
const panelElement = this.view.dom.querySelector('.cm-markdown-preview-panel');
if (panelElement) {
// 面板创建成功,更新 store 状态
this.panelStore.openMarkdownPreview(currentState.blockFrom, currentState.blockTo);
}
});
}
// 检测到状态变为 closing
if (currentState?.closing && !this.lastState?.closing) {
// 触发关闭动画
closePreviewWithAnimation(this.view);
}
this.lastState = currentState;
}
destroy() {
// 不调用 reset(),因为那会清空所有文档的预览状态
// 只清理编辑器视图引用
this.panelStore.setEditorView(null);
}
});

View File

@@ -1,356 +0,0 @@
import { EditorView } from "@codemirror/view";
import type { ThemeColors } from "@/views/editor/theme/types";
/**
* 创建 Markdown 预览面板的主题样式
*/
export function createMarkdownPreviewTheme(colors: ThemeColors) {
// GitHub 官方颜色变量
const isDark = colors.dark;
// GitHub Light 主题颜色
const lightColors = {
fg: {
default: "#1F2328",
muted: "#656d76",
subtle: "#6e7781"
},
border: {
default: "#d0d7de",
muted: "#d8dee4"
},
canvas: {
default: "#ffffff",
subtle: "#f6f8fa"
},
accent: {
fg: "#0969da",
emphasis: "#0969da"
}
};
// GitHub Dark 主题颜色
const darkColors = {
fg: {
default: "#e6edf3",
muted: "#7d8590",
subtle: "#6e7681"
},
border: {
default: "#30363d",
muted: "#21262d"
},
canvas: {
default: "#0d1117",
subtle: "#161b22"
},
accent: {
fg: "#2f81f7",
emphasis: "#2f81f7"
}
};
const ghColors = isDark ? darkColors : lightColors;
return EditorView.theme({
// 面板容器
".cm-markdown-preview-panel": {
position: "relative",
display: "flex",
flexDirection: "column",
overflow: "hidden"
},
// 拖动调整大小的手柄
".cm-preview-resize-handle": {
width: "100%",
height: "3px",
backgroundColor: colors.borderColor,
cursor: "ns-resize",
position: "relative",
flexShrink: 0,
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: colors.selection
},
"&.dragging": {
backgroundColor: colors.selection
}
},
// 内容区域
".cm-preview-content": {
flex: 1,
padding: "45px",
overflow: "auto",
fontSize: "16px",
lineHeight: "1.5",
color: ghColors.fg.default,
wordWrap: "break-word",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'",
boxSizing: "border-box",
// Loading state
"& .markdown-loading, & .markdown-error": {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "200px",
fontSize: "14px",
color: ghColors.fg.muted
},
"& .markdown-error": {
color: "#f85149"
},
// ========== 标题样式 ==========
"& h1, & h2, & h3, & h4, & h5, & h6": {
marginTop: "24px",
marginBottom: "16px",
fontWeight: "600",
lineHeight: "1.25",
color: ghColors.fg.default
},
"& h1": {
fontSize: "2em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h2": {
fontSize: "1.5em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h3": {
fontSize: "1.25em"
},
"& h4": {
fontSize: "1em"
},
"& h5": {
fontSize: "0.875em"
},
"& h6": {
fontSize: "0.85em",
color: ghColors.fg.muted
},
// ========== 段落和文本 ==========
"& p": {
marginTop: "0",
marginBottom: "16px"
},
"& strong": {
fontWeight: "600"
},
"& em": {
fontStyle: "italic"
},
"& del": {
textDecoration: "line-through",
opacity: "0.7"
},
// ========== 列表 ==========
"& ul, & ol": {
paddingLeft: "2em",
marginTop: "0",
marginBottom: "16px"
},
"& ul ul, & ul ol, & ol ol, & ol ul": {
marginTop: "0",
marginBottom: "0"
},
"& li": {
wordWrap: "break-all"
},
"& li > p": {
marginTop: "16px"
},
"& li + li": {
marginTop: "0.25em"
},
// 任务列表
"& .task-list-item": {
listStyleType: "none",
position: "relative",
paddingLeft: "1.5em"
},
"& .task-list-item + .task-list-item": {
marginTop: "3px"
},
"& .task-list-item input[type='checkbox']": {
font: "inherit",
overflow: "visible",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
boxSizing: "border-box",
padding: "0",
margin: "0 0.2em 0.25em -1.6em",
verticalAlign: "middle",
cursor: "pointer"
},
// ========== 代码块 ==========
"& code, & tt": {
fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace",
fontSize: "85%",
padding: "0.2em 0.4em",
margin: "0",
backgroundColor: isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(27, 31, 35, 0.05)",
borderRadius: "3px"
},
"& pre": {
position: "relative",
backgroundColor: isDark ? "#161b22" : "#f6f8fa",
padding: "40px 16px 16px 16px",
borderRadius: "6px",
overflow: "auto",
margin: "16px 0",
fontSize: "85%",
lineHeight: "1.45",
wordWrap: "normal",
// macOS 窗口样式 - 使用伪元素创建顶部栏
"&::before": {
content: '""',
position: "absolute",
top: "0",
left: "0",
right: "0",
height: "28px",
backgroundColor: isDark ? "#1c1c1e" : "#e8e8e8",
borderBottom: `1px solid ${ghColors.border.default}`,
borderRadius: "6px 6px 0 0"
},
// macOS 三个控制按钮
"&::after": {
content: '""',
position: "absolute",
top: "10px",
left: "12px",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: isDark ? "#ec6a5f" : "#ff5f57",
boxShadow: `
18px 0 0 0 ${isDark ? "#f4bf4f" : "#febc2e"},
36px 0 0 0 ${isDark ? "#61c554" : "#28c840"}
`
}
},
"& pre code, & pre tt": {
display: "inline",
maxWidth: "auto",
padding: "0",
margin: "0",
overflow: "visible",
lineHeight: "inherit",
wordWrap: "normal",
backgroundColor: "transparent",
border: "0",
fontSize: "100%",
color: ghColors.fg.default,
wordBreak: "normal",
whiteSpace: "pre"
},
// ========== 引用块 ==========
"& blockquote": {
margin: "16px 0",
padding: "0 1em",
color: isDark ? "#7d8590" : "#6a737d",
borderLeft: isDark ? "0.25em solid #3b434b" : "0.25em solid #dfe2e5"
},
"& blockquote > :first-child": {
marginTop: "0"
},
"& blockquote > :last-child": {
marginBottom: "0"
},
// ========== 分割线 ==========
"& hr": {
height: "0.25em",
padding: "0",
margin: "24px 0",
backgroundColor: isDark ? "#21262d" : "#e1e4e8",
border: "0",
overflow: "hidden",
boxSizing: "content-box"
},
// ========== 表格 ==========
"& table": {
borderSpacing: "0",
borderCollapse: "collapse",
display: "block",
width: "100%",
overflow: "auto",
marginTop: "0",
marginBottom: "16px"
},
"& table tr": {
backgroundColor: isDark ? "#0d1117" : "#ffffff",
borderTop: isDark ? "1px solid #21262d" : "1px solid #c6cbd1"
},
"& table th, & table td": {
padding: "6px 13px",
border: isDark ? "1px solid #30363d" : "1px solid #dfe2e5"
},
"& table th": {
fontWeight: "600"
},
// ========== 链接 ==========
"& a, & .markdown-link": {
color: isDark ? "#58a6ff" : "#0366d6",
textDecoration: "none",
cursor: "pointer",
"&:hover": {
textDecoration: "underline"
}
},
// ========== 图片 ==========
"& img": {
maxWidth: "100%",
height: "auto",
borderRadius: "4px",
margin: "16px 0"
},
// ========== 其他元素 ==========
"& kbd": {
display: "inline-block",
padding: "3px 5px",
fontSize: "11px",
lineHeight: "10px",
color: ghColors.fg.default,
verticalAlign: "middle",
backgroundColor: ghColors.canvas.subtle,
border: `solid 1px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderBottom: `solid 2px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderRadius: "6px",
boxShadow: "inset 0 -1px 0 rgba(175, 184, 193, 0.2)"
},
// 首个子元素去除上边距
"& > *:first-child": {
marginTop: "0 !important"
},
// 最后一个子元素去除下边距
"& > *:last-child": {
marginBottom: "0 !important"
}
}
}, { dark: colors.dark });
}

View File

@@ -1,12 +0,0 @@
/**
* Markdown 预览面板相关类型定义
*/
// 预览面板状态
export interface PreviewState {
documentId: number; // 预览所属的文档ID
blockFrom: number;
blockTo: number;
closing?: boolean; // 标记面板正在关闭
}

View File

@@ -0,0 +1,498 @@
import { EditorView } from '@codemirror/view';
import { EditorState, ChangeSet } from '@codemirror/state';
import { syntaxTree, highlightingFor } from '@codemirror/language';
import { Highlighter, highlightTree } from '@lezer/highlight';
import { Scale } from './config';
import { LinesState, getLinesSnapshot } from './linesState';
import {
ToWorkerMessage,
ToMainMessage,
BlockRequest,
Highlight,
LineSpan,
FontInfo,
} from './worker/protocol';
import crelt from 'crelt';
const BLOCK_LINES = 50;
const MAX_BLOCKS = 20;
interface Block {
index: number;
startLine: number;
endLine: number;
bitmap: ImageBitmap | null;
dirty: boolean;
rendering: boolean;
requestId: number;
lastUsed: number; // LRU 时间戳
}
export class BlockManager {
private worker: Worker | null = null;
private blocks = new Map<number, Block>();
private fontInfoMap = new Map<string, FontInfo>();
private fontDirty = true;
private fontVersion = 0;
private measureCache: { charWidth: number; lineHeight: number; version: number } | null = null;
private displayText: 'blocks' | 'characters' = 'characters';
private themeClasses: Set<string>;
private requestId = 0;
private onBlockReady: (() => void) | null = null;
// 批量处理块完成事件
private pendingBlockReadyHandle: ReturnType<typeof setTimeout> | null = null;
private renderingCount = 0;
constructor(private view: EditorView) {
this.themeClasses = new Set(Array.from(view.dom.classList));
this.initWorker();
}
private initWorker(): void {
this.worker = new Worker(
new URL('./worker/block.worker.ts', import.meta.url),
{ type: 'module' }
);
this.worker.onmessage = (e: MessageEvent<ToMainMessage>) => {
this.handleWorkerMessage(e.data);
};
this.worker.onerror = (e) => {
console.error('[BlockManager] Worker error:', e);
};
this.worker.postMessage({ type: 'init' } as ToWorkerMessage);
}
private handleWorkerMessage(msg: ToMainMessage): void {
switch (msg.type) {
case 'ready':
break;
case 'blockComplete': {
const block = this.blocks.get(msg.blockIndex);
if (block && block.requestId === msg.blockId) {
block.bitmap = msg.bitmap;
block.dirty = false;
block.rendering = false;
this.renderingCount = Math.max(0, this.renderingCount - 1);
this.scheduleBlockReady();
}
break;
}
case 'error':
console.error('[BlockManager] Worker error:', msg.message);
break;
}
}
// 批量触发块就绪回调
// 策略:等 30ms让多个块完成事件合并
private scheduleBlockReady(): void {
if (this.pendingBlockReadyHandle !== null) {
clearTimeout(this.pendingBlockReadyHandle);
}
this.pendingBlockReadyHandle = setTimeout(() => {
this.pendingBlockReadyHandle = null;
if (this.renderingCount === 0) {
this.onBlockReady?.();
}
}, 30);
}
setOnBlockReady(callback: () => void): void {
this.onBlockReady = callback;
}
setDisplayText(mode: 'blocks' | 'characters'): void {
if (this.displayText !== mode) {
this.displayText = mode;
this.markAllDirty();
}
}
checkThemeChange(): boolean {
const nowClasses = Array.from(this.view.dom.classList);
const now = new Set(nowClasses);
const prev = this.themeClasses;
this.themeClasses = now;
if (!prev) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
const prevSet = new Set(prev);
const nowSet = new Set(now);
prevSet.delete('cm-focused');
nowSet.delete('cm-focused');
if (prevSet.size !== nowSet.size) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
for (const cls of prevSet) {
if (!nowSet.has(cls)) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
}
return false;
}
markAllDirty(): void {
for (const block of this.blocks.values()) {
block.dirty = true;
}
}
handleDocChange(state: EditorState, changes: ChangeSet, oldLineCount: number): void {
const totalLines = state.doc.lines;
const maxIndex = Math.ceil(totalLines / BLOCK_LINES) - 1;
// 找出变化影响的块
const affectedBlocks = new Set<number>();
// 正确检测行数变化:比较新旧文档总行数
const hasLineCountChange = totalLines !== oldLineCount;
changes.iterChanges((fromA, toA, fromB, toB) => {
// 找出新文档中受影响的行范围
const startLine = state.doc.lineAt(fromB).number;
const endLine = state.doc.lineAt(Math.min(toB, state.doc.length)).number;
const startBlock = Math.floor((startLine - 1) / BLOCK_LINES);
const endBlock = Math.floor((endLine - 1) / BLOCK_LINES);
for (let i = startBlock; i <= endBlock; i++) {
affectedBlocks.add(i);
}
});
// 如果行数变化,后续所有块都需要标记为 dirty
let markRest = false;
for (const [index, block] of this.blocks) {
if (index > maxIndex) {
block.bitmap?.close();
this.blocks.delete(index);
} else if (affectedBlocks.has(index)) {
block.dirty = true;
if (hasLineCountChange) {
markRest = true; // 从这个块开始,后续块都需要更新
}
} else if (markRest) {
block.dirty = true;
}
}
}
/**
* 计算可见范围(提取公共计算逻辑)
*/
private getVisibleRange(
canvasHeight: number,
lineHeight: number,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): {
valid: boolean;
totalLines: number;
scaledPTop: number;
canvasTop: number;
startBlock: number;
endBlock: number;
} | null {
const totalLines = this.view.state.field(LinesState).length;
if (totalLines === 0 || canvasHeight <= 0) {
return null;
}
const { top: pTop, bottom: pBottom } = this.view.documentPadding;
const scaledPTop = pTop / Scale.SizeRatio;
const scaledPBottom = pBottom / Scale.SizeRatio;
const totalHeight = scaledPTop + scaledPBottom + totalLines * lineHeight;
const { scrollTop, clientHeight, scrollHeight } = scrollInfo;
const scrollPercent = Math.max(0, Math.min(1, scrollTop / (scrollHeight - clientHeight))) || 0;
const canvasTop = Math.max(0, scrollPercent * (totalHeight - canvasHeight));
const visibleStart = Math.max(1, Math.floor((canvasTop - scaledPTop) / lineHeight) + 1);
const visibleEnd = Math.min(totalLines, Math.ceil((canvasTop + canvasHeight - scaledPTop) / lineHeight));
if (visibleEnd < visibleStart) {
return null;
}
return {
valid: true,
totalLines,
scaledPTop,
canvasTop,
startBlock: Math.floor((visibleStart - 1) / BLOCK_LINES),
endBlock: Math.floor((visibleEnd - 1) / BLOCK_LINES),
};
}
render(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): void {
if (this.fontDirty) {
this.refreshFontCache();
}
const { charWidth, lineHeight } = this.measure(ctx);
const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo);
if (!range) return;
const { totalLines, scaledPTop, canvasTop, startBlock, endBlock } = range;
const state = this.view.state;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = startBlock; i <= endBlock; i++) {
const block = this.getOrCreateBlock(i, state, totalLines);
if (!block) continue;
if (block.dirty && !block.rendering) {
this.requestBlockRender(block, state, charWidth, lineHeight);
}
if (block.bitmap) {
const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop;
ctx.drawImage(block.bitmap, 0, blockY);
}
}
this.fontDirty = false;
this.evictOldBlocks();
}
/**
* 只绘制缓存的块,不请求新渲染(用于 overlay-only 更新)
*/
drawCachedBlocks(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): void {
const { lineHeight } = this.measure(ctx);
const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo);
if (!range) return;
const { scaledPTop, canvasTop, startBlock, endBlock } = range;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = startBlock; i <= endBlock; i++) {
const block = this.blocks.get(i);
if (block?.bitmap) {
const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop;
ctx.drawImage(block.bitmap, 0, blockY);
}
}
}
private getOrCreateBlock(index: number, state: EditorState, totalLines: number): Block | null {
const startLine = index * BLOCK_LINES + 1;
if (startLine > totalLines) return null;
const endLine = Math.min((index + 1) * BLOCK_LINES, totalLines);
const now = performance.now();
let block = this.blocks.get(index);
if (!block) {
block = {
index,
startLine,
endLine,
bitmap: null,
dirty: true,
rendering: false,
requestId: 0,
lastUsed: now,
};
this.blocks.set(index, block);
} else {
block.startLine = startLine;
block.endLine = endLine;
block.lastUsed = now; // 更新 LRU 时间戳
}
return block;
}
private requestBlockRender(
block: Block,
state: EditorState,
charWidth: number,
lineHeight: number
): void {
if (!this.worker) return;
block.rendering = true;
block.requestId = ++this.requestId;
this.renderingCount++;
const { startLine, endLine } = block;
const linesSnapshot = getLinesSnapshot(state);
const tree = syntaxTree(state);
// Collect highlights
const highlights: Highlight[] = [];
if (tree.length > 0 && startLine <= state.doc.lines) {
const highlighter: Highlighter = {
style: (tags) => highlightingFor(state, tags),
};
const startPos = state.doc.line(startLine).from;
const endPos = state.doc.line(Math.min(endLine, state.doc.lines)).to;
highlightTree(tree, highlighter, (from, to, tags) => {
highlights.push({ from, to, tags });
}, startPos, endPos);
}
// Extract relevant lines
const startIdx = startLine - 1;
const endIdx = Math.min(endLine, linesSnapshot.length);
const lines: LineSpan[][] = linesSnapshot.slice(startIdx, endIdx).map(line =>
line.map(span => ({ from: span.from, to: span.to, folded: span.folded }))
);
// Get text slice
let textOffset = 0;
let textEnd = 0;
if (lines.length > 0 && lines[0].length > 0) {
textOffset = lines[0][0].from;
const lastLine = lines[lines.length - 1];
if (lastLine.length > 0) {
textEnd = lastLine[lastLine.length - 1].to;
}
}
const textSlice = state.doc.sliceString(textOffset, textEnd);
// Build font info map
const fontInfoMap: Record<string, FontInfo> = {};
for (const hl of highlights) {
if (!fontInfoMap[hl.tags]) {
const info = this.getFontInfo(hl.tags);
fontInfoMap[hl.tags] = info;
}
}
fontInfoMap[''] = this.getFontInfo('');
const blockLines = endLine - startLine + 1;
const request: BlockRequest = {
type: 'renderBlock',
blockId: block.requestId,
blockIndex: block.index,
startLine,
endLine,
width: Math.ceil(Scale.MaxWidth * Scale.PixelMultiplier),
height: Math.ceil(blockLines * lineHeight),
highlights,
lines,
textSlice,
textOffset,
fontInfoMap,
defaultFont: fontInfoMap[''],
displayText: this.displayText,
charWidth,
lineHeight,
gutterOffset: 0,
};
this.worker.postMessage(request);
}
private evictOldBlocks(): void {
if (this.blocks.size <= MAX_BLOCKS) return;
// LRU 驱逐:按 lastUsed 升序排序,驱逐最久未使用的块
const sorted = Array.from(this.blocks.entries())
.filter(([, b]) => !b.rendering)
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
const toRemove = sorted.slice(0, this.blocks.size - MAX_BLOCKS);
for (const [index, block] of toRemove) {
block.bitmap?.close();
this.blocks.delete(index);
}
}
private refreshFontCache(): void {
this.fontInfoMap.clear();
this.measureCache = null;
// 注意fontDirty 在成功渲染块后才设为 false
this.fontVersion++;
this.markAllDirty();
}
measure(ctx: CanvasRenderingContext2D): { charWidth: number; lineHeight: number } {
const info = this.getFontInfo('');
ctx.textBaseline = 'ideographic';
ctx.fillStyle = info.color;
ctx.font = info.font;
if (this.measureCache?.version === this.fontVersion) {
return { charWidth: this.measureCache.charWidth, lineHeight: this.measureCache.lineHeight };
}
const charWidth = ctx.measureText('_').width;
this.measureCache = { charWidth, lineHeight: info.lineHeight, version: this.fontVersion };
return { charWidth, lineHeight: info.lineHeight };
}
private getFontInfo(tags: string): FontInfo {
const cached = this.fontInfoMap.get(tags);
if (cached) return cached;
const mockToken = crelt('span', { class: tags });
const mockLine = crelt('div', { class: 'cm-line', style: 'display: none' }, mockToken);
this.view.contentDOM.appendChild(mockLine);
const style = window.getComputedStyle(mockToken);
// 获取字体大小(用于渲染字符)
const fontSize = parseFloat(style.fontSize) || this.view.defaultLineHeight;
const scaledFontSize = Math.max(1, fontSize / Scale.SizeRatio);
// 获取行高(用于行间距)
const rawLineHeight = parseFloat(style.lineHeight);
const resolvedLineHeight = Number.isFinite(rawLineHeight) && rawLineHeight > 0 ? rawLineHeight : fontSize;
const lineHeight = Math.max(1, resolvedLineHeight / Scale.SizeRatio);
const result: FontInfo = {
color: style.color,
font: `${style.fontStyle} ${style.fontWeight} ${scaledFontSize}px ${style.fontFamily}`,
lineHeight,
};
this.view.contentDOM.removeChild(mockLine);
this.fontInfoMap.set(tags, result);
return result;
}
destroy(): void {
if (this.pendingBlockReadyHandle !== null) {
clearTimeout(this.pendingBlockReadyHandle);
}
for (const block of this.blocks.values()) {
block.bitmap?.close();
}
this.blocks.clear();
this.worker?.postMessage({ type: 'destroy' } as ToWorkerMessage);
this.worker?.terminate();
this.worker = null;
}
}

View File

@@ -8,12 +8,21 @@ import {
import { LineBasedState } from "./linebasedstate";
import { DrawContext } from "./types";
import { Lines, LinesState, foldsChanged } from "./linesState";
import { Config } from "./config";
import { LinesState, foldsChanged } from "./linesState";
import { Config, Scale } from "./config";
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
type Severity = Diagnostic["severity"];
type DiagnosticRange = { from: number; to: number };
type LineDiagnostics = {
severity: Severity;
ranges: Array<DiagnosticRange>;
};
const MIN_PIXEL_WIDTH = 1 / Scale.PixelMultiplier;
const snapToDevice = (value: number) =>
Math.round(value * Scale.PixelMultiplier) / Scale.PixelMultiplier;
export class DiagnosticState extends LineBasedState<Severity> {
export class DiagnosticState extends LineBasedState<LineDiagnostics> {
private count: number | undefined = undefined;
public constructor(view: EditorView) {
@@ -63,70 +72,74 @@ export class DiagnosticState extends LineBasedState<Severity> {
this.count = diagnosticCount(update.state);
forEachDiagnostic(update.state, (diagnostic, from, to) => {
// Find the start and end lines for the diagnostic
const lineStart = this.findLine(from, lines);
const lineEnd = this.findLine(to, lines);
const lineStart = lineNumberAt(lines, from);
const lineEnd = lineNumberAt(lines, to);
if (lineStart <= 0 || lineEnd <= 0) {
return;
}
// Populate each line in the range with the highest severity diagnostic
let severity = diagnostic.severity;
for (let i = lineStart; i <= lineEnd; i++) {
const previous = this.get(i);
if (previous) {
severity = [severity, previous]
.sort(this.sort.bind(this))
.slice(0, 1)[0];
for (let lineNumber = lineStart; lineNumber <= lineEnd; lineNumber++) {
const spans = lines[lineNumber - 1];
if (!spans || spans.length === 0) {
continue;
}
this.set(i, severity);
const length = lineLength(spans);
const startOffset =
lineNumber === lineStart
? offsetWithinLine(from, spans)
: 0;
const endOffset =
lineNumber === lineEnd ? offsetWithinLine(to, spans) : length;
const fromOffset = Math.max(0, Math.min(length, startOffset));
let toOffset = Math.max(fromOffset, Math.min(length, endOffset));
if (toOffset === fromOffset) {
toOffset = Math.min(length, fromOffset + 1);
}
this.pushRange(lineNumber, diagnostic.severity, {
from: fromOffset,
to: toOffset,
});
}
});
this.mergeRanges();
}
public drawLine(ctx: DrawContext, lineNumber: number) {
const { context, lineHeight, offsetX, offsetY } = ctx;
const severity = this.get(lineNumber);
if (!severity) {
const diagnostics = this.get(lineNumber);
if (!diagnostics) {
return;
}
// Draw the full line width rectangle in the background
context.globalAlpha = 0.65;
context.beginPath();
context.rect(
offsetX,
offsetY /* TODO Scaling causes anti-aliasing in rectangles */,
context.canvas.width - offsetX,
lineHeight
);
context.fillStyle = this.color(severity);
context.fill();
const { context, lineHeight, charWidth, offsetX, offsetY } = ctx;
const color = this.color(diagnostics.severity);
const snappedY = snapToDevice(offsetY);
const snappedHeight =
Math.max(MIN_PIXEL_WIDTH, snapToDevice(offsetY + lineHeight) - snappedY) ||
MIN_PIXEL_WIDTH;
// Draw diagnostic range rectangle in the foreground
// TODO: We need to update the state to have specific ranges
// context.globalAlpha = 1;
// context.beginPath();
// context.rect(offsetX, offsetY, textWidth, lineHeight);
// context.fillStyle = this.color(severity);
// context.fill();
}
context.fillStyle = color;
for (const range of diagnostics.ranges) {
const startX = offsetX + range.from * charWidth;
const width = Math.max(
MIN_PIXEL_WIDTH,
(range.to - range.from) * charWidth
);
const snappedX = snapToDevice(startX);
const snappedWidth =
Math.max(MIN_PIXEL_WIDTH, snapToDevice(startX + width) - snappedX) ||
MIN_PIXEL_WIDTH;
/**
* Given a position and a set of line ranges, return
* the line number the position falls within
*/
private findLine(pos: number, lines: Lines) {
const index = lines.findIndex((spans) => {
const start = spans.slice(0, 1)[0];
const end = spans.slice(-1)[0];
if (!start || !end) {
return false;
}
return start.from <= pos && pos <= end.to;
});
// Line numbers begin at 1
return index + 1;
context.globalAlpha = 0.65;
context.beginPath();
context.rect(snappedX, snappedY, snappedWidth, snappedHeight);
context.fill();
}
context.globalAlpha = 1;
}
/**
@@ -141,12 +154,6 @@ export class DiagnosticState extends LineBasedState<Severity> {
: "#999";
}
/** Sorts severity from most to least severe */
private sort(a: Severity, b: Severity) {
return this.score(b) - this.score(a);
}
/** Assigns a score to severity, with most severe being the highest */
private score(s: Severity) {
switch (s) {
case "error": {
@@ -160,6 +167,47 @@ export class DiagnosticState extends LineBasedState<Severity> {
}
}
}
private pushRange(
lineNumber: number,
severity: Severity,
range: DiagnosticRange
) {
let entry = this.get(lineNumber);
if (!entry) {
entry = { severity, ranges: [range] };
this.set(lineNumber, entry);
return;
}
if (this.score(severity) > this.score(entry.severity)) {
entry.severity = severity;
}
entry.ranges.push(range);
}
private mergeRanges() {
for (const entry of this.map.values()) {
if (entry.ranges.length <= 1) {
continue;
}
entry.ranges.sort((a, b) => a.from - b.from);
const merged: Array<DiagnosticRange> = [];
for (const range of entry.ranges) {
const last = merged[merged.length - 1];
if (last && range.from <= last.to) {
last.to = Math.max(last.to, range.to);
} else {
merged.push({ ...range });
}
}
entry.ranges = merged;
}
}
}
export function diagnostics(view: EditorView): DiagnosticState {

View File

@@ -1,27 +1,37 @@
import { Facet } from "@codemirror/state";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Overlay } from "./overlay";
import { Config, Options, Scale } from "./config";
import { DiagnosticState, diagnostics } from "./diagnostics";
import { SelectionState, selections } from "./selections";
import { TextState, text } from "./text";
import { LinesState } from "./linesState";
import crelt from "crelt";
import { GUTTER_WIDTH, drawLineGutter } from "./gutters";
/**
* Minimap Extension Entry
* Uses block rendering for visible area only
*/
import { Facet } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { Overlay } from './overlay';
import { Config, Options, Scale } from './config';
import { DiagnosticState, diagnostics } from './diagnostics';
import { SelectionState, selections } from './selections';
import { LinesState, foldsChanged } from './linesState';
import { BlockManager } from './blockManager';
import crelt from 'crelt';
import { GUTTER_WIDTH, drawLineGutter } from './gutters';
import { createDebounce } from '@/common/utils/debounce';
const Theme = EditorView.theme({
"&": {
height: "100%",
overflowY: "auto",
'&': {
height: '100%',
overflowY: 'auto',
},
"& .cm-minimap-gutter": {
'& .cm-minimap-gutter': {
borderRight: 0,
flexShrink: 0,
left: "unset",
position: "sticky",
left: 'unset',
position: 'sticky',
right: 0,
top: 0,
},
// 初始化时隐藏,避免宽度未设置时闪烁
'& .cm-minimap-initializing': {
opacity: 0,
},
'& .cm-minimap-autohide': {
opacity: 0.0,
transition: 'opacity 0.3s',
@@ -29,62 +39,130 @@ const Theme = EditorView.theme({
'& .cm-minimap-autohide:hover': {
opacity: 1.0,
},
"& .cm-minimap-inner": {
height: "100%",
position: "absolute",
'& .cm-minimap-inner': {
height: '100%',
position: 'absolute',
right: 0,
top: 0,
overflowY: "hidden",
"& canvas": {
display: "block",
overflowY: 'hidden',
'& canvas': {
display: 'block',
willChange: 'transform, opacity',
},
},
"& .cm-minimap-box-shadow": {
boxShadow: "12px 0px 20px 5px #6c6c6c",
'& .cm-minimap-box-shadow': {
boxShadow: '12px 0px 20px 5px #6c6c6c',
},
});
const WIDTH_RATIO = 6;
type RenderReason = 'scroll' | 'data';
// 渲染类型blocks=块内容变化需要重渲染, overlays=只需重绘选区等覆盖层
type RenderType = 'blocks' | 'overlays';
const minimapClass = ViewPlugin.fromClass(
class {
private dom: HTMLElement | undefined;
private inner: HTMLElement | undefined;
private canvas: HTMLCanvasElement | undefined;
private renderHandle: number | ReturnType<typeof setTimeout> | null = null;
private cancelRender: (() => void) | null = null;
private pendingScrollTop: number | null = null;
private lastRenderedScrollTop: number = -1;
private pendingRenderReason: RenderReason | null = null;
private pendingRenderType: RenderType | null = null;
public text: TextState;
// 块管理器Worker 渲染)
private blockManager: BlockManager;
public selection: SelectionState;
public diagnostic: DiagnosticState;
// 等待滚动位置稳定
private initialRenderDelay: ReturnType<typeof setTimeout> | null = null;
private isInitialized = false;
private hasRenderedOnce = false; // 是否已首次渲染
private lastScrollTop = -1;
private scrollStableCount = 0;
// 块渲染防抖500ms
private debouncedBlockRender: ReturnType<typeof createDebounce>['debouncedFn'];
private cancelDebounce: () => void;
public constructor(private view: EditorView) {
this.text = text(view);
this.blockManager = new BlockManager(view);
this.selection = selections(view);
this.diagnostic = diagnostics(view);
// 创建防抖的块渲染函数
const { debouncedFn, cancel } = createDebounce(() => {
this.requestRender('data', 'blocks');
}, { delay: 1000 });
this.debouncedBlockRender = debouncedFn;
this.cancelDebounce = cancel;
// 当块渲染完成时,请求重新渲染(只渲染 overlays
this.blockManager.setOnBlockReady(() => {
if (this.isInitialized) {
this.requestRender('data', 'overlays');
}
});
if (view.state.facet(showMinimapFacet)) {
this.create(view);
this.waitForScrollStable();
}
}
// 等待滚动位置稳定后再渲染
private waitForScrollStable(): void {
const check = () => {
const scrollTop = this.view.scrollDOM.scrollTop;
if (scrollTop === this.lastScrollTop) {
this.scrollStableCount++;
// 连续 3 次检测位置不变,认为稳定
if (this.scrollStableCount >= 3) {
this.isInitialized = true;
this.initialRenderDelay = null;
this.requestRender('data', 'blocks');
return;
}
} else {
this.scrollStableCount = 0;
this.lastScrollTop = scrollTop;
}
// 每 20ms 检测一次,最多等待 200ms
if (this.scrollStableCount < 10) {
this.initialRenderDelay = setTimeout(check, 20);
} else {
this.isInitialized = true;
this.initialRenderDelay = null;
this.requestRender('data', 'blocks');
}
};
this.initialRenderDelay = setTimeout(check, 20);
}
private create(view: EditorView) {
const config = view.state.facet(showMinimapFacet);
if (!config) {
throw Error("Expected nonnull");
throw Error('Expected nonnull');
}
this.inner = crelt("div", { class: "cm-minimap-inner" });
this.canvas = crelt("canvas") as HTMLCanvasElement;
this.inner = crelt('div', { class: 'cm-minimap-inner' });
this.canvas = crelt('canvas') as HTMLCanvasElement;
this.dom = config.create(view).dom;
this.dom.classList.add("cm-gutters");
this.dom.classList.add("cm-minimap-gutter");
this.dom.classList.add('cm-gutters');
this.dom.classList.add('cm-minimap-gutter');
this.dom.classList.add('cm-minimap-initializing'); // 初始隐藏
this.inner.appendChild(this.canvas);
this.dom.appendChild(this.inner);
// For now let's keep this same behavior. We might want to change
// this in the future and have the extension figure out how to mount.
// Or expose some more generic right gutter api and use that
this.view.scrollDOM.insertBefore(
this.dom,
this.view.contentDOM.nextSibling
@@ -97,35 +175,23 @@ const minimapClass = ViewPlugin.fromClass(
}
}
// 阻止小地图上的右键菜单
this.dom.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// 阻止小地图内部元素和画布上的右键菜单
this.inner.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
if (config.autohide) {
this.dom.classList.add('cm-minimap-autohide');
}
// 设置显示模式
this.blockManager.setDisplayText(view.state.facet(Config).displayText);
}
private remove() {
this.cancelRenderRequest();
if (this.dom) {
this.dom.remove();
}
this.dom = undefined;
this.inner = undefined;
this.canvas = undefined;
this.hasRenderedOnce = false; // 重置首次渲染标记
}
update(update: ViewUpdate) {
@@ -142,6 +208,9 @@ const minimapClass = ViewPlugin.fromClass(
}
if (now) {
let needBlockRender = false;
let needOverlayRender = false;
if (prev && this.dom && prev.autohide !== now.autohide) {
if (now.autohide) {
this.dom.classList.add('cm-minimap-autohide');
@@ -150,10 +219,45 @@ const minimapClass = ViewPlugin.fromClass(
}
}
this.text.update(update);
// Check theme change
if (this.blockManager.checkThemeChange()) {
needBlockRender = true;
}
// Check config change
const prevConfig = update.startState.facet(Config);
const nowConfig = update.state.facet(Config);
if (prevConfig.displayText !== nowConfig.displayText) {
this.blockManager.setDisplayText(nowConfig.displayText);
needBlockRender = true;
}
// Check doc change
if (update.docChanged) {
const oldLineCount = update.startState.doc.lines;
this.blockManager.handleDocChange(update.state, update.changes, oldLineCount);
needBlockRender = true;
}
// Check fold change
if (foldsChanged(update.transactions)) {
this.blockManager.markAllDirty();
needBlockRender = true;
}
// Update selection and diagnostics
this.selection.update(update);
this.diagnostic.update(update);
this.render();
if (update.selectionSet) {
needOverlayRender = true;
}
// 根据变化类型决定渲染方式
if (needBlockRender) {
this.debouncedBlockRender();
} else if (needOverlayRender) {
this.requestRender('data', 'overlays');
}
}
}
@@ -167,85 +271,182 @@ const minimapClass = ViewPlugin.fromClass(
}
render() {
// If we don't have elements to draw to exit early
if (!this.dom || !this.canvas || !this.inner) {
return;
}
this.text.beforeDraw();
const effectiveScrollTop = this.pendingScrollTop ?? this.view.scrollDOM.scrollTop;
const renderType = this.pendingRenderType ?? 'blocks';
this.pendingScrollTop = null;
this.pendingRenderReason = null;
this.pendingRenderType = null;
this.lastRenderedScrollTop = effectiveScrollTop;
this.updateBoxShadow();
this.dom.style.width = this.getWidth() + "px";
this.canvas.style.maxWidth = this.getWidth() + "px";
this.canvas.width = this.getWidth() * Scale.PixelMultiplier;
// Set canvas size
const width = this.getWidth();
this.dom.style.width = width + 'px';
this.canvas.style.maxWidth = width + 'px';
this.canvas.width = width * Scale.PixelMultiplier;
const domHeight = this.view.dom.getBoundingClientRect().height;
this.inner.style.minHeight = domHeight + "px";
this.inner.style.minHeight = domHeight + 'px';
this.canvas.height = domHeight * Scale.PixelMultiplier;
this.canvas.style.height = domHeight + "px";
this.canvas.style.height = domHeight + 'px';
const context = this.canvas.getContext("2d");
const context = this.canvas.getContext('2d');
if (!context) {
return;
}
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Get scroll info
const scrollInfo = {
scrollTop: effectiveScrollTop,
clientHeight: this.view.scrollDOM.clientHeight,
scrollHeight: this.view.scrollDOM.scrollHeight,
};
/* We need to get the correct font dimensions before this to measure characters */
const { charWidth, lineHeight } = this.text.measure(context);
// 渲染块
if (renderType === 'blocks') {
this.blockManager.render(this.canvas, context, scrollInfo);
} else {
this.blockManager.drawCachedBlocks(this.canvas, context, scrollInfo);
}
let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex(
// Render overlays (gutters, selections, diagnostics)
const gutters = this.view.state.facet(Config).gutters;
this.renderOverlays(context, effectiveScrollTop, gutters);
// 首次渲染完成后显示 minimap
if (!this.hasRenderedOnce && this.dom) {
this.hasRenderedOnce = true;
this.dom.classList.remove('cm-minimap-initializing');
}
}
/**
* 渲染覆盖层gutters、选区、诊断
*/
private renderOverlays(
context: CanvasRenderingContext2D,
scrollTop: number,
gutters: Required<Options>['gutters']
) {
const { charWidth, lineHeight } = this.blockManager.measure(context);
const { startIndex, endIndex, offsetY: initialOffsetY } = this.canvasStartAndEndIndex(
context,
lineHeight
lineHeight,
scrollTop
);
const gutters = this.view.state.facet(Config).gutters;
const lines = this.view.state.field(LinesState);
let offsetY = initialOffsetY;
for (let i = startIndex; i < endIndex; i++) {
const lines = this.view.state.field(LinesState);
if (i >= lines.length) break;
const drawContext = {
offsetX: 0,
offsetY,
context,
lineHeight,
charWidth,
};
let offsetX = 0;
const lineNumber = i + 1;
// 渲染 gutters
if (gutters.length) {
/* Small leading buffer */
drawContext.offsetX += 2;
offsetX += 2;
for (const gutter of gutters) {
drawLineGutter(gutter, drawContext, i + 1);
drawContext.offsetX += GUTTER_WIDTH;
drawLineGutter(gutter, { offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
offsetX += GUTTER_WIDTH;
}
/* Small trailing buffer */
drawContext.offsetX += 2;
offsetX += 2;
}
this.text.drawLine(drawContext, i + 1);
this.selection.drawLine(drawContext, i + 1);
this.diagnostic.drawLine(drawContext, i + 1);
// 渲染选区
this.selection.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
// 渲染诊断
if (this.diagnostic.has(lineNumber)) {
this.diagnostic.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
}
offsetY += lineHeight;
}
}
context.restore();
requestRender(reason: RenderReason = 'data', type: RenderType = 'blocks') {
if (!this.isInitialized) {
return;
}
if (reason === 'scroll') {
const scrollTop = this.view.scrollDOM.scrollTop;
if (this.lastRenderedScrollTop === scrollTop && !this.pendingRenderReason) {
return;
}
if (
this.pendingRenderReason === 'scroll' &&
this.pendingScrollTop === scrollTop
) {
return;
}
this.pendingScrollTop = scrollTop;
} else {
this.pendingScrollTop = null;
}
if (reason === 'data' || this.pendingRenderReason === null) {
this.pendingRenderReason = reason;
}
// 合并渲染类型blocks > overlays
if (this.pendingRenderType === null || type === 'blocks') {
this.pendingRenderType = type;
}
if (this.renderHandle !== null) {
return;
}
if (typeof requestAnimationFrame === 'function') {
const handle = requestAnimationFrame(() => {
this.renderHandle = null;
this.cancelRender = null;
this.render();
});
this.renderHandle = handle;
this.cancelRender = () => cancelAnimationFrame(handle);
return;
}
const handle = setTimeout(() => {
this.renderHandle = null;
this.cancelRender = null;
this.render();
}, 16);
this.renderHandle = handle;
this.cancelRender = () => clearTimeout(handle);
}
cancelRenderRequest() {
if (!this.cancelRender) {
return;
}
this.cancelRender();
this.renderHandle = null;
this.cancelRender = null;
this.pendingScrollTop = null;
this.pendingRenderReason = null;
}
private canvasStartAndEndIndex(
context: CanvasRenderingContext2D,
lineHeight: number
lineHeight: number,
scrollTopOverride?: number
) {
let { top: pTop, bottom: pBottom } = this.view.documentPadding;
(pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio);
const canvasHeight = context.canvas.height;
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
const { clientHeight, scrollHeight } = this.view.scrollDOM;
const scrollTop = scrollTopOverride ?? this.view.scrollDOM.scrollTop;
let scrollPercent = scrollTop / (scrollHeight - clientHeight);
if (isNaN(scrollPercent)) {
scrollPercent = 0;
@@ -278,20 +479,26 @@ const minimapClass = ViewPlugin.fromClass(
const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM;
if (clientWidth + scrollLeft < scrollWidth) {
this.canvas.classList.add("cm-minimap-box-shadow");
this.canvas.classList.add('cm-minimap-box-shadow');
} else {
this.canvas.classList.remove("cm-minimap-box-shadow");
this.canvas.classList.remove('cm-minimap-box-shadow');
}
}
destroy() {
if (this.initialRenderDelay) {
clearTimeout(this.initialRenderDelay);
this.initialRenderDelay = null;
}
this.cancelDebounce();
this.blockManager.destroy();
this.remove();
}
},
{
eventHandlers: {
scroll() {
requestAnimationFrame(() => this.render());
this.requestRender('scroll', 'blocks');
},
},
provide: (plugin) => {
@@ -307,8 +514,7 @@ const minimapClass = ViewPlugin.fromClass(
}
);
// 使用type定义
export type MinimapConfig = Omit<Options, "enabled"> & {
export type MinimapConfig = Omit<Options, 'enabled'> & {
/**
* A function that creates the element that contains the minimap
*/
@@ -318,43 +524,35 @@ export type MinimapConfig = Omit<Options, "enabled"> & {
/**
* Facet used to show a minimap in the right gutter of the editor using the
* provided configuration.
*
* If you return `null`, a minimap will not be shown.
*/
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
combine: (c) => c.find((o) => o !== null) ?? null,
enables: (f) => {
return [
[
Config.compute([f], (s) => s.facet(f)),
Theme,
LinesState,
minimapClass, // TODO, codemirror-ify this one better
Overlay,
],
];
},
});
/**
* 创建默认的minimap DOM元素
* 创建默认的 minimap DOM 元素
*/
const defaultCreateFn = (view: EditorView) => {
const defaultCreateFn = (_view: EditorView) => {
const dom = document.createElement('div');
return { dom };
};
/**
* 添加minimap到编辑器
* @param options Minimap配置项
* @returns
* 添加 minimap 到编辑器
* @param options Minimap 配置项
*/
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
return showMinimapFacet.of({
const config: MinimapConfig = {
create: defaultCreateFn,
...options
});
}
...options,
};
// 保持原始接口兼容性
export { showMinimapFacet as showMinimap };
return [
showMinimapFacet.of(config),
Config.compute([showMinimapFacet], (s) => s.facet(showMinimapFacet)),
Theme,
LinesState,
minimapClass,
Overlay,
];
}

View File

@@ -0,0 +1,85 @@
import { Lines } from "./linesState";
const DEFAULT_LINE_NUMBER = 0;
function lineBoundary(spans: Lines[number]) {
if (!spans || spans.length === 0) {
return { start: 0, end: 0 };
}
const start = spans[0].from;
const end = spans[spans.length - 1].to;
return { start, end };
}
export function lineNumberAt(lines: Lines, position: number): number {
if (!lines.length) {
return DEFAULT_LINE_NUMBER;
}
const first = lineBoundary(lines[0]);
const last = lineBoundary(lines[lines.length - 1]);
let target = position;
if (target < first.start) {
target = first.start;
} else if (target > last.end) {
target = last.end;
}
let low = 0;
let high = lines.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const spans = lines[mid];
const { start, end } = lineBoundary(spans);
if (target < start) {
high = mid - 1;
continue;
}
if (target > end) {
low = mid + 1;
continue;
}
return mid + 1;
}
return Math.max(1, Math.min(lines.length, low + 1));
}
export function lineLength(spans: Lines[number]) {
if (!spans) {
return 1;
}
let length = 0;
for (const span of spans) {
length += span.folded ? 1 : Math.max(0, span.to - span.from);
}
return Math.max(1, length);
}
export function offsetWithinLine(pos: number, spans: Lines[number]) {
if (!spans) {
return 0;
}
let offset = 0;
for (const span of spans) {
const spanLength = span.folded ? 1 : Math.max(0, span.to - span.from);
if (!span.folded && pos < span.to) {
return offset + Math.max(0, pos - span.from);
}
if (span.folded && pos <= span.to) {
return offset;
}
offset += spanLength;
}
return offset;
}

View File

@@ -14,6 +14,10 @@ export abstract class LineBasedState<TValue> {
return this.map.get(lineNumber);
}
public has(lineNumber: number): boolean {
return this.map.has(lineNumber);
}
protected set(lineNumber: number, value: TValue) {
this.map.set(lineNumber, value);
}

View File

@@ -77,6 +77,12 @@ function computeLinesState(state: EditorState): Lines {
const LinesState = StateField.define<Lines>({
create: (state) => computeLinesState(state),
update: (current, tr) => {
const prevEnabled = tr.startState.facet(Config).enabled;
const nextEnabled = tr.state.facet(Config).enabled;
if (prevEnabled !== nextEnabled) {
return computeLinesState(tr.state);
}
if (foldsChanged([tr]) || tr.docChanged) {
return computeLinesState(tr.state);
}
@@ -93,3 +99,11 @@ function foldsChanged(transactions: readonly Transaction[]) {
}
export { foldsChanged, LinesState };
export function getLinesSnapshot(state: EditorState): Lines {
const lines = state.field(LinesState);
if (lines.length || !state.facet(Config).enabled) {
return lines;
}
return computeLinesState(state);
}

View File

@@ -51,6 +51,10 @@ const OverlayView = ViewPlugin.fromClass(
private _isDragging: boolean = false;
private _dragStartY: number | undefined;
private abortController: AbortController | undefined;
private readonly _boundMouseDown = (event: MouseEvent) => this.onMouseDown(event);
private readonly _boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
private readonly _boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
public constructor(private view: EditorView) {
if (view.state.facet(Config).enabled) {
@@ -59,14 +63,19 @@ const OverlayView = ViewPlugin.fromClass(
}
private create(view: EditorView) {
this.remove();
this.abortController = new AbortController();
const signal = this.abortController.signal;
this.container = crelt("div", { class: "cm-minimap-overlay-container" });
this.dom = crelt("div", { class: "cm-minimap-overlay" });
this.container.appendChild(this.dom);
// Attach event listeners for overlay
this.container.addEventListener("mousedown", this.onMouseDown.bind(this));
window.addEventListener("mouseup", this.onMouseUp.bind(this));
window.addEventListener("mousemove", this.onMouseMove.bind(this));
this.container.addEventListener("mousedown", this._boundMouseDown, { signal });
window.addEventListener("mouseup", this._boundMouseUp, { signal });
window.addEventListener("mousemove", this._boundMouseMove, { signal });
// Attach the overlay elements to the minimap
const inner = view.dom.querySelector(".cm-minimap-inner");
@@ -81,11 +90,15 @@ const OverlayView = ViewPlugin.fromClass(
}
private remove() {
if (this.abortController) {
this.abortController.abort();
this.abortController = undefined;
}
if (this.container) {
this.container.removeEventListener("mousedown", this.onMouseDown);
window.removeEventListener("mouseup", this.onMouseUp);
window.removeEventListener("mousemove", this.onMouseMove);
this.container.remove();
this.container = undefined;
this.dom = undefined;
}
}

View File

@@ -1,15 +1,27 @@
import { LineBasedState } from "./linebasedstate";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { LinesState, foldsChanged } from "./linesState";
import { EditorState } from "@codemirror/state";
import { Lines, foldsChanged, getLinesSnapshot } from "./linesState";
import { DrawContext } from "./types";
import { Config } from "./config";
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
type Selection = { from: number; to: number; extends: boolean };
type DrawInfo = { backgroundColor: string };
type RangeInfo = {
from: number;
to: number;
lineFrom: number;
lineTo: number;
};
const MAX_CACHED_LINES = 800;
export class SelectionState extends LineBasedState<Array<Selection>> {
private _drawInfo: DrawInfo | undefined;
private _themeClasses: string;
private _rangeInfo: Array<RangeInfo> = [];
private _linesSnapshot: Lines = [];
public constructor(view: EditorView) {
super(view);
@@ -52,95 +64,7 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
return;
}
this.map.clear();
/* If class list has changed, clear and recalculate the selection style */
if (this._themeClasses !== this.view.dom.classList.value) {
this._drawInfo = undefined;
this._themeClasses = this.view.dom.classList.value;
}
const { ranges } = update.state.selection;
let selectionIndex = 0;
for (const [index, line] of update.state.field(LinesState).entries()) {
const selections: Array<Selection> = [];
let offset = 0;
for (const span of line) {
do {
// We've already processed all selections
if (selectionIndex >= ranges.length) {
continue;
}
// The next selection begins after this span
if (span.to < ranges[selectionIndex].from) {
continue;
}
// Ignore 0-length selections
if (ranges[selectionIndex].from === ranges[selectionIndex].to) {
selectionIndex++;
continue;
}
// Build the selection for the current span
const range = ranges[selectionIndex];
const selection = {
from: offset + Math.max(span.from, range.from) - span.from,
to: offset + Math.min(span.to, range.to) - span.from,
extends: range.to > span.to,
};
const lastSelection = selections.slice(-1)[0];
if (lastSelection && lastSelection.to === selection.from) {
// The selection in this span may just be a continuation of the
// selection in the previous span
// Adjust `to` depending on if we're in a folded span
let { to } = selection;
if (span.folded && selection.extends) {
to = selection.from + 1;
} else if (span.folded && !selection.extends) {
to = lastSelection.to;
}
selections[selections.length - 1] = {
...lastSelection,
to,
extends: selection.extends,
};
} else if (!span.folded) {
// It's a new selection; if we're not in a folded span we
// should push it onto the stack
selections.push(selection);
}
// If the selection doesn't end in this span, break out of the loop
if (selection.extends) {
break;
}
// Otherwise, move to the next selection
selectionIndex++;
} while (
selectionIndex < ranges.length &&
span.to >= ranges[selectionIndex].from
);
offset += span.folded ? 1 : span.to - span.from;
}
// If we don't have any selections on this line, we don't need to store anything
if (selections.length === 0) {
continue;
}
// Lines are indexed beginning at 1 instead of 0
const lineNumber = index + 1;
this.map.set(lineNumber, selections);
}
this.rebuild(update.state);
}
public drawLine(ctx: DrawContext, lineNumber: number) {
@@ -151,7 +75,7 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
offsetX: startOffsetX,
offsetY,
} = ctx;
const selections = this.get(lineNumber);
const selections = this.ensureSelections(lineNumber);
if (!selections) {
return;
}
@@ -199,6 +123,123 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
return result;
}
private appendSelection(target: Array<Selection>, entry: Selection) {
const last = target[target.length - 1];
if (last && entry.from <= last.to) {
target[target.length - 1] = {
from: Math.min(last.from, entry.from),
to: Math.max(last.to, entry.to),
extends: entry.extends || last.extends,
};
return;
}
target.push(entry);
}
private rebuild(state: EditorState) {
if (this._themeClasses !== this.view.dom.classList.value) {
this._drawInfo = undefined;
this._themeClasses = this.view.dom.classList.value;
}
this._linesSnapshot = getLinesSnapshot(state);
this._rangeInfo = this.buildRangeInfo(state, this._linesSnapshot);
this.map.clear();
}
private buildRangeInfo(state: EditorState, lines: Lines) {
const info: Array<RangeInfo> = [];
for (const range of state.selection.ranges) {
if (range.empty) {
continue;
}
const startLine = lineNumberAt(lines, range.from);
const endLine = lineNumberAt(lines, Math.max(range.from, range.to - 1));
if (startLine <= 0 || endLine <= 0) {
continue;
}
info.push({
from: range.from,
to: range.to,
lineFrom: startLine,
lineTo: endLine,
});
}
return info;
}
private ensureSelections(lineNumber: number) {
const cached = this.get(lineNumber);
if (cached) {
return cached;
}
const computed = this.buildSelectionsForLine(lineNumber);
if (!computed || computed.length === 0) {
return undefined;
}
if (this.map.has(lineNumber)) {
this.map.delete(lineNumber);
}
this.map.set(lineNumber, computed);
while (this.map.size > MAX_CACHED_LINES) {
const oldest = this.map.keys().next();
if (oldest.done) {
break;
}
this.map.delete(oldest.value);
}
return computed;
}
private buildSelectionsForLine(lineNumber: number) {
const spans = this._linesSnapshot[lineNumber - 1];
if (!spans || spans.length === 0) {
return undefined;
}
const relevant = this._rangeInfo.filter(
(info) => lineNumber >= info.lineFrom && lineNumber <= info.lineTo
);
if (!relevant.length) {
return undefined;
}
const selections: Array<Selection> = [];
for (const range of relevant) {
const length = lineLength(spans);
const fromOffset =
lineNumber === range.lineFrom
? offsetWithinLine(range.from, spans)
: 0;
let toOffset =
lineNumber === range.lineTo
? offsetWithinLine(range.to, spans)
: length;
if (toOffset === fromOffset) {
toOffset = Math.min(length, fromOffset + 1);
}
const lastSpan = spans[spans.length - 1];
const spanEnd = lastSpan ? lastSpan.to : range.to;
const extendsLine =
lineNumber < range.lineTo ||
(lineNumber === range.lineTo && range.to > spanEnd);
this.appendSelection(selections, {
from: fromOffset,
to: toOffset,
extends: extendsLine,
});
}
return selections;
}
}
export function selections(view: EditorView): SelectionState {

View File

@@ -1,415 +0,0 @@
import { LineBasedState } from "./linebasedstate";
import { Highlighter, highlightTree } from "@lezer/highlight";
import { ChangedRange, Tree, TreeFragment } from "@lezer/common";
import { highlightingFor, language } from "@codemirror/language";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { DrawContext } from "./types";
import { Config, Options, Scale } from "./config";
import { LinesState, foldsChanged } from "./linesState";
import crelt from "crelt";
import { ChangeSet, EditorState } from "@codemirror/state";
type TagSpan = { text: string; tags: string };
type FontInfo = { color: string; font: string; lineHeight: number };
export class TextState extends LineBasedState<Array<TagSpan>> {
private _previousTree: Tree | undefined;
private _displayText: Required<Options>["displayText"] | undefined;
private _fontInfoMap: Map<string, FontInfo> = new Map();
private _themeClasses: Set<string> | undefined;
private _highlightingCallbackId: number | NodeJS.Timeout | undefined;
public constructor(view: EditorView) {
super(view);
this._themeClasses = new Set(Array.from(view.dom.classList));
if (view.state.facet(Config).enabled) {
this.updateImpl(view.state);
}
}
private shouldUpdate(update: ViewUpdate) {
// If the doc changed
if (update.docChanged) {
return true;
}
// If configuration settings changed
if (update.state.facet(Config) !== update.startState.facet(Config)) {
return true;
}
// If the theme changed
if (this.themeChanged()) {
return true;
}
// If the folds changed
if (foldsChanged(update.transactions)) {
return true;
}
return false;
}
public update(update: ViewUpdate) {
if (!this.shouldUpdate(update)) {
return;
}
if (this._highlightingCallbackId) {
typeof window.requestIdleCallback !== "undefined"
? cancelIdleCallback(this._highlightingCallbackId as number)
: clearTimeout(this._highlightingCallbackId);
}
this.updateImpl(update.state, update.changes);
}
private updateImpl(state: EditorState, changes?: ChangeSet) {
this.map.clear();
/* Store display text setting for rendering */
this._displayText = state.facet(Config).displayText;
/* If class list has changed, clear and recalculate the font info map */
if (this.themeChanged()) {
this._fontInfoMap.clear();
}
/* Incrementally parse the tree based on previous tree + changes */
let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
if (this._previousTree && changes) {
const previousFragments = TreeFragment.addTree(this._previousTree);
const changedRanges: Array<ChangedRange> = [];
changes.iterChangedRanges((fromA, toA, fromB, toB) =>
changedRanges.push({ fromA, toA, fromB, toB })
);
treeFragments = TreeFragment.applyChanges(
previousFragments,
changedRanges
);
}
/* Parse the document into a lezer tree */
const docToString = state.doc.toString();
const parser = state.facet(language)?.parser;
const tree = parser ? parser.parse(docToString, treeFragments) : undefined;
this._previousTree = tree;
/* Highlight the document, and store the text and tags for each line */
const highlighter: Highlighter = {
style: (tags) => highlightingFor(state, tags),
};
let highlights: Array<{ from: number; to: number; tags: string }> = [];
if (tree) {
/**
* The viewport renders a few extra lines above and below the editor view. To approximate
* the lines visible in the minimap, we multiply the lines in the viewport by the scale multipliers.
*
* Based on the current scroll position, the minimap may show a larger portion of lines above or
* below the lines currently in the editor view. On a long document, when the scroll position is
* near the top of the document, the minimap will show a small number of lines above the lines
* in the editor view, and a large number of lines below the lines in the editor view.
*
* To approximate this ratio, we can use the viewport scroll percentage
*
* ┌─────────────────────┐
* │ │
* │ Extra viewport │
* │ buffer │
* ├─────────────────────┼───────┐
* │ │Minimap│
* │ │Gutter │
* │ ├───────┤
* │ Editor View │Scaled │
* │ │View │
* │ │Overlay│
* │ ├───────┤
* │ │ │
* │ │ │
* ├─────────────────────┼───────┘
* │ │
* │ Extra viewport │
* │ buffer │
* └─────────────────────┘
*
**/
const vpLineTop = state.doc.lineAt(this.view.viewport.from).number;
const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number;
const vpLineCount = vpLineBottom - vpLineTop;
const vpScroll = vpLineTop / (state.doc.lines - vpLineCount);
const { SizeRatio, PixelMultiplier } = Scale;
const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier;
const mmLineRatio = vpScroll * mmLineCount;
const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio));
const mmLineBottom = Math.min(
vpLineBottom + Math.floor(mmLineCount - mmLineRatio),
state.doc.lines
);
// Highlight the in-view lines synchronously
highlightTree(
tree,
highlighter,
(from, to, tags) => {
highlights.push({ from, to, tags });
},
state.doc.line(mmLineTop).from,
state.doc.line(mmLineBottom).to
);
}
// Update the map
this.updateMapImpl(state, highlights);
// Highlight the entire tree in an idle callback
highlights = [];
const highlightingCallback = () => {
if (tree) {
highlightTree(tree, highlighter, (from, to, tags) => {
highlights.push({ from, to, tags });
});
this.updateMapImpl(state, highlights);
this._highlightingCallbackId = undefined;
}
};
this._highlightingCallbackId =
typeof window.requestIdleCallback !== "undefined"
? requestIdleCallback(highlightingCallback)
: setTimeout(highlightingCallback);
}
private updateMapImpl(
state: EditorState,
highlights: Array<{ from: number; to: number; tags: string }>
) {
this.map.clear();
const docToString = state.doc.toString();
const highlightsIterator = highlights.values();
let highlightPtr = highlightsIterator.next();
for (const [index, line] of state.field(LinesState).entries()) {
const spans: Array<TagSpan> = [];
for (const span of line) {
// Skip if it's a 0-length span
if (span.from === span.to) {
continue;
}
// Append a placeholder for a folded span
if (span.folded) {
spans.push({ text: "…", tags: "" });
continue;
}
let position = span.from;
while (!highlightPtr.done && highlightPtr.value.from < span.to) {
const { from, to, tags } = highlightPtr.value;
// Iterate until our highlight is over the current span
if (to < position) {
highlightPtr = highlightsIterator.next();
continue;
}
// Append unstyled text before the highlight begins
if (from > position) {
spans.push({ text: docToString.slice(position, from), tags: "" });
}
// A highlight may start before and extend beyond the current span
const start = Math.max(from, span.from);
const end = Math.min(to, span.to);
// Append the highlighted text
spans.push({ text: docToString.slice(start, end), tags });
position = end;
// If the highlight continues beyond this span, break from this loop
if (to > end) {
break;
}
// Otherwise, move to the next highlight
highlightPtr = highlightsIterator.next();
}
// If there are remaining spans that did not get highlighted, append them unstyled
if (position !== span.to) {
spans.push({
text: docToString.slice(position, span.to),
tags: "",
});
}
}
// Lines are indexed beginning at 1 instead of 0
const lineNumber = index + 1;
this.map.set(lineNumber, spans);
}
}
public measure(context: CanvasRenderingContext2D): {
charWidth: number;
lineHeight: number;
} {
const { color, font, lineHeight } = this.getFontInfo("");
context.textBaseline = "ideographic";
context.fillStyle = color;
context.font = font;
return {
charWidth: context.measureText("_").width,
lineHeight: lineHeight,
};
}
public beforeDraw() {
this._fontInfoMap.clear(); // Confirm this worked for theme changes or get rid of it because it's slow
}
public drawLine(ctx: DrawContext, lineNumber: number) {
const line = this.get(lineNumber);
if (!line) {
return;
}
let { context, charWidth, lineHeight, offsetX, offsetY } = ctx;
let prevInfo: FontInfo | undefined;
context.textBaseline = "ideographic";
for (const span of line) {
const info = this.getFontInfo(span.tags);
if (!prevInfo || prevInfo.color !== info.color) {
context.fillStyle = info.color;
}
if (!prevInfo || prevInfo.font !== info.font) {
context.font = info.font;
}
prevInfo = info;
lineHeight = Math.max(lineHeight, info.lineHeight);
switch (this._displayText) {
case "characters": {
// TODO: `fillText` takes up the majority of profiling time in `render`
// Try speeding it up with `drawImage`
// https://stackoverflow.com/questions/8237030/html5-canvas-faster-filltext-vs-drawimage/8237081
context.fillText(span.text, offsetX, offsetY + lineHeight);
offsetX += span.text.length * charWidth;
break;
}
case "blocks": {
const nonWhitespace = /\S+/g;
let start: RegExpExecArray | null;
while ((start = nonWhitespace.exec(span.text)) !== null) {
const startX = offsetX + start.index * charWidth;
let width = (nonWhitespace.lastIndex - start.index) * charWidth;
// Reached the edge of the minimap
if (startX > context.canvas.width) {
break;
}
// Limit width to edge of minimap
if (startX + width > context.canvas.width) {
width = context.canvas.width - startX;
}
// Scaled 2px buffer between lines
const yBuffer = 2 / Scale.SizeRatio;
const height = lineHeight - yBuffer;
context.fillStyle = info.color;
context.globalAlpha = 0.65; // Make the blocks a bit faded
context.beginPath();
context.rect(startX, offsetY, width, height);
context.fill();
}
offsetX += span.text.length * charWidth;
break;
}
}
}
}
private getFontInfo(tags: string): FontInfo {
const cached = this._fontInfoMap.get(tags);
if (cached) {
return cached;
}
// Create a mock token wrapped in a cm-line
const mockToken = crelt("span", { class: tags });
const mockLine = crelt(
"div",
{ class: "cm-line", style: "display: none" },
mockToken
);
this.view.contentDOM.appendChild(mockLine);
// Get style information and store it
const style = window.getComputedStyle(mockToken);
const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio;
const result = {
color: style.color,
font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`,
lineHeight,
};
this._fontInfoMap.set(tags, result);
// Clean up and return
this.view.contentDOM.removeChild(mockLine);
return result;
}
private themeChanged(): boolean {
const previous = this._themeClasses;
const now = new Set<string>(Array.from(this.view.dom.classList));
this._themeClasses = now;
if (!previous) {
return true;
}
// Ignore certain classes being added/removed
previous.delete("cm-focused");
now.delete("cm-focused");
if (previous.size !== now.size) {
return true;
}
let containsAll = true;
previous.forEach((theme) => {
if (!now.has(theme)) {
containsAll = false;
}
});
return !containsAll;
}
}
export function text(view: EditorView): TextState {
return new TextState(view);
}

View File

@@ -0,0 +1,265 @@
import {
ToWorkerMessage,
ToMainMessage,
BlockRequest,
TagSpan,
FontInfo,
} from './protocol';
function post(msg: ToMainMessage, transfer?: Transferable[]): void {
self.postMessage(msg, { transfer });
}
/**
* 二分查找:找到第一个 to > pos 的高亮索引
*/
function findHighlightStart(highlights: BlockRequest['highlights'], pos: number): number {
let left = 0;
let right = highlights.length;
while (left < right) {
const mid = (left + right) >>> 1;
if (highlights[mid].to <= pos) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
function buildLineSpans(
request: BlockRequest
): Map<number, TagSpan[]> {
const { highlights, lines, textSlice, textOffset, startLine, endLine } = request;
const result = new Map<number, TagSpan[]>();
const slice = (from: number, to: number): string => {
return textSlice.slice(from - textOffset, to - textOffset);
};
// 使用二分查找找到起始高亮索引
const firstSpanPos = lines[0]?.[0]?.from ?? 0;
let hlIndex = findHighlightStart(highlights, firstSpanPos);
for (let i = 0; i < lines.length && i < endLine - startLine + 1; i++) {
const lineNumber = startLine + i;
const line = lines[i];
if (!line) continue;
const spans: TagSpan[] = [];
for (const span of line) {
if (span.from === span.to) continue;
if (span.folded) {
spans.push({ text: '…', tags: '' });
continue;
}
let pos = span.from;
// 从当前索引继续查找
while (hlIndex < highlights.length && highlights[hlIndex].from < span.to) {
const hl = highlights[hlIndex];
if (hl.to <= pos) {
hlIndex++;
continue;
}
if (hl.from > pos) {
spans.push({ text: slice(pos, Math.min(hl.from, span.to)), tags: '' });
pos = Math.min(hl.from, span.to);
}
if (hl.from < span.to) {
const start = Math.max(hl.from, span.from);
const end = Math.min(hl.to, span.to);
if (start < end) {
spans.push({ text: slice(start, end), tags: hl.tags });
pos = end;
}
}
if (hl.to <= span.to) {
hlIndex++;
} else {
break;
}
}
if (pos < span.to) {
spans.push({ text: slice(pos, span.to), tags: '' });
}
}
result.set(lineNumber, spans);
}
return result;
}
function renderBlock(request: BlockRequest): void {
const {
blockId,
blockIndex,
startLine,
endLine,
width,
height,
fontInfoMap,
defaultFont,
displayText,
charWidth,
lineHeight,
gutterOffset,
} = request;
// Create OffscreenCanvas for this block
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) {
post({ type: 'error', message: 'Failed to get 2d context' });
return;
}
// Build line data
const lineSpans = buildLineSpans(request);
// Render each line
ctx.textBaseline = 'ideographic';
let offsetY = 0;
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
const spans = lineSpans.get(lineNum);
if (spans && spans.length > 0) {
drawLine(ctx, spans, fontInfoMap, defaultFont, displayText, gutterOffset, offsetY, charWidth, lineHeight, width);
}
offsetY += lineHeight;
}
// Convert to ImageBitmap and send back
const bitmap = canvas.transferToImageBitmap();
post({ type: 'blockComplete', blockId, blockIndex, bitmap }, [bitmap]);
}
function drawLine(
ctx: OffscreenCanvasRenderingContext2D,
spans: TagSpan[],
fontInfoMap: Record<string, FontInfo>,
defaultFont: FontInfo,
displayText: 'blocks' | 'characters',
offsetX: number,
offsetY: number,
charWidth: number,
lineHeight: number,
availableWidth: number
): void {
let cursorX = offsetX;
let prevFont: FontInfo | null = null;
for (const span of spans) {
const info = fontInfoMap[span.tags] || defaultFont;
if (!prevFont || prevFont.color !== info.color) {
ctx.fillStyle = info.color;
}
if (!prevFont || prevFont.font !== info.font) {
ctx.font = info.font;
}
prevFont = info;
if (displayText === 'characters') {
cursorX = drawCharacters(ctx, span.text, cursorX, offsetY, charWidth, lineHeight);
} else {
cursorX = drawTextBlocks(ctx, span.text, cursorX, offsetY, charWidth, lineHeight, availableWidth - offsetX);
}
}
}
function drawCharacters(
ctx: OffscreenCanvasRenderingContext2D,
text: string,
x: number,
y: number,
charWidth: number,
lineHeight: number
): number {
const yPos = y + lineHeight;
let batchStart = -1;
let batchX = x;
for (let i = 0; i <= text.length; i++) {
const char = text[i];
const isWhitespace = !char || char === ' ' || char === '\t';
if (isWhitespace) {
// 输出之前积累的批次
if (batchStart !== -1) {
ctx.fillText(text.slice(batchStart, i), batchX, yPos);
batchStart = -1;
}
if (char) x += charWidth;
} else {
// 开始新批次
if (batchStart === -1) {
batchStart = i;
batchX = x;
}
x += charWidth;
}
}
return x;
}
function drawTextBlocks(
ctx: OffscreenCanvasRenderingContext2D,
text: string,
x: number,
y: number,
charWidth: number,
lineHeight: number,
availableWidth: number
): number {
const nonWhitespace = /\S+/g;
let match: RegExpExecArray | null;
while ((match = nonWhitespace.exec(text)) !== null) {
const startX = x + match.index * charWidth;
let width = (nonWhitespace.lastIndex - match.index) * charWidth;
if (startX > availableWidth) break;
if (startX + width > availableWidth) {
width = availableWidth - startX;
}
if (width > 0) {
ctx.fillRect(startX, y, width, lineHeight);
}
}
return x + text.length * charWidth;
}
function handleMessage(msg: ToWorkerMessage): void {
switch (msg.type) {
case 'init':
post({ type: 'ready' });
break;
case 'renderBlock':
renderBlock(msg);
break;
case 'destroy':
break;
}
}
self.onmessage = (e: MessageEvent<ToWorkerMessage>) => {
try {
handleMessage(e.data);
} catch (err) {
post({ type: 'error', message: String(err) });
}
};

View File

@@ -0,0 +1,74 @@
/**
* Worker Communication Protocol
*/
export interface TagSpan {
text: string;
tags: string;
}
export interface Highlight {
from: number;
to: number;
tags: string;
}
export interface LineSpan {
from: number;
to: number;
folded: boolean;
}
export interface FontInfo {
color: string;
font: string;
lineHeight: number;
}
export interface BlockRequest {
type: 'renderBlock';
blockId: number;
blockIndex: number;
startLine: number;
endLine: number;
width: number;
height: number;
highlights: Highlight[];
lines: LineSpan[][];
textSlice: string;
textOffset: number;
fontInfoMap: Record<string, FontInfo>;
defaultFont: FontInfo;
displayText: 'blocks' | 'characters';
charWidth: number;
lineHeight: number;
gutterOffset: number;
}
export interface InitRequest {
type: 'init';
}
export interface DestroyRequest {
type: 'destroy';
}
export type ToWorkerMessage = BlockRequest | InitRequest | DestroyRequest;
export interface BlockComplete {
type: 'blockComplete';
blockId: number;
blockIndex: number;
bitmap: ImageBitmap;
}
export interface WorkerReady {
type: 'ready';
}
export interface WorkerError {
type: 'error';
message: string;
}
export type ToMainMessage = BlockComplete | WorkerReady | WorkerError;

View File

@@ -0,0 +1,107 @@
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
import { Range } from '@codemirror/state';
// 彩虹颜色数组
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.buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
private buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
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 (OPEN_BRACKETS.has(char)) {
stack.push({ char, from: pos });
} else if (CLOSE_BRACKETS.has(char)) {
const open = stack.pop();
if (open && open.char !== BRACKET_PAIRS[char]) {
stack.push(open); // 不匹配,放回
}
}
}
// 阶段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));
}
}
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
decorations: (v) => v.decorations,
});
export default function rainbowBrackets() {
return [
rainbowBracketsPlugin,
EditorView.baseTheme({
// 为每种颜色定义CSS样式
'.cm-rainbow-bracket-red': { color: '#FF6B6B' },
'.cm-rainbow-bracket-orange': { color: '#FF9E6B' },
'.cm-rainbow-bracket-yellow': { color: '#FFD166' },
'.cm-rainbow-bracket-green': { color: '#06D6A0' },
'.cm-rainbow-bracket-blue': { color: '#118AB2' },
'.cm-rainbow-bracket-indigo': { color: '#6B5B95' },
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
}),
];
}

View File

@@ -1,86 +0,0 @@
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'
];
}
class RainbowBracketsView {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.getBracketDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.getBracketDecorations(update.view);
}
}
private getBracketDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const decorations: Range<Decoration>[] = [];
const stack: { type: string; from: number }[] = [];
const colors = generateColors();
// 遍历文档内容
for (let pos = 0; 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 === '}') {
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)
);
}
}
}
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
}
private getMatchingBracket(closingBracket: string): string | null {
switch (closingBracket) {
case ')': return '(';
case ']': return '[';
case '}': return '{';
default: return null;
}
}
}
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
decorations: (v) => v.decorations,
});
export default function rainbowBracketsExtension() {
return [
rainbowBracketsPlugin,
EditorView.baseTheme({
// 为每种颜色定义CSS样式
'.cm-rainbow-bracket-red': { color: '#FF6B6B' },
'.cm-rainbow-bracket-orange': { color: '#FF9E6B' },
'.cm-rainbow-bracket-yellow': { color: '#FFD166' },
'.cm-rainbow-bracket-green': { color: '#06D6A0' },
'.cm-rainbow-bracket-blue': { color: '#118AB2' },
'.cm-rainbow-bracket-indigo': { color: '#6B5B95' },
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
}),
];
}

Some files were not shown because too many files have changed in this diff Show More