Compare commits
22 Commits
docs
...
71ca541f78
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ca541f78 | |||
| 91f4f4afac | |||
| fc5639d7bd | |||
|
|
6668c11846 | ||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e | |||
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f |
@@ -1170,7 +1170,7 @@ export class Theme {
|
|||||||
this["type"] = ("" as ThemeType);
|
this["type"] = ("" as ThemeType);
|
||||||
}
|
}
|
||||||
if (!("colors" in $$source)) {
|
if (!("colors" in $$source)) {
|
||||||
this["colors"] = (new ThemeColorConfig());
|
this["colors"] = ({} as ThemeColorConfig);
|
||||||
}
|
}
|
||||||
if (!("isDefault" in $$source)) {
|
if (!("isDefault" in $$source)) {
|
||||||
this["isDefault"] = false;
|
this["isDefault"] = false;
|
||||||
@@ -1199,303 +1199,9 @@ export class Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
|
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
|
||||||
*/
|
*/
|
||||||
export class ThemeColorConfig {
|
export type ThemeColorConfig = { [_: string]: any };
|
||||||
/**
|
|
||||||
* 主题基本信息
|
|
||||||
* 主题名称
|
|
||||||
*/
|
|
||||||
"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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeType 主题类型枚举
|
* ThemeType 主题类型枚举
|
||||||
@@ -1636,6 +1342,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
|||||||
});
|
});
|
||||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||||
const $$createType8 = HotkeyCombo.createFrom;
|
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 $$createType10 = GithubConfig.createFrom;
|
||||||
const $$createType11 = GiteaConfig.createFrom;
|
const $$createType11 = GiteaConfig.createFrom;
|
||||||
|
|||||||
@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
|
|||||||
import * as models$0 from "../models/models.js";
|
import * as models$0 from "../models/models.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GetAllThemes 获取所有主题
|
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
|
||||||
*/
|
*/
|
||||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
let $resultPromise = $Call.ByID(1938954770, name) 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;
|
|
||||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||||
return $$createType1($result);
|
return $$createType1($result);
|
||||||
}) as any;
|
}) 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 } {
|
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
let $resultPromise = $Call.ByID(1806334457, name) as any;
|
||||||
return $resultPromise;
|
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 } {
|
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
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 } {
|
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
|
||||||
return $resultPromise;
|
return $resultPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = models$0.Theme.createFrom;
|
const $$createType0 = models$0.Theme.createFrom;
|
||||||
const $$createType1 = $Create.Nullable($$createType0);
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
const $$createType2 = $Create.Array($$createType1);
|
|
||||||
|
|||||||
3230
frontend/package-lock.json
generated
3230
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
|||||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.19.1",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.0",
|
"@codemirror/commands": "^6.10.0",
|
||||||
"@codemirror/lang-angular": "^0.1.4",
|
"@codemirror/lang-angular": "^0.1.4",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
@@ -50,59 +50,56 @@
|
|||||||
"@codemirror/lint": "^6.9.2",
|
"@codemirror/lint": "^6.9.2",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.6",
|
"@codemirror/view": "^6.38.8",
|
||||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@lezer/lr": "^1.4.3",
|
"@lezer/lr": "^1.4.4",
|
||||||
"@mdit/plugin-katex": "^0.23.2",
|
|
||||||
"@mdit/plugin-tasklist": "^0.22.2",
|
|
||||||
"@prettier/plugin-xml": "^3.4.2",
|
"@prettier/plugin-xml": "^3.4.2",
|
||||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^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": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
"colors-named-hex": "^1.0.2",
|
"colors-named-hex": "^1.0.2",
|
||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
"markdown-it": "^14.1.0",
|
"marked": "^17.0.1",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"npm": "^11.6.2",
|
|
||||||
"php-parser": "^3.2.5",
|
"php-parser": "^3.2.5",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.7.2",
|
||||||
"sass": "^1.94.0",
|
"sass": "^1.94.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.25",
|
||||||
"vue-i18n": "^11.1.12",
|
"vue-i18n": "^11.2.2",
|
||||||
"vue-pick-colors": "^1.8.0",
|
"vue-pick-colors": "^1.8.0",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@lezer/generator": "^1.8.0",
|
"@lezer/generator": "^1.8.0",
|
||||||
"@types/node": "^24.9.2",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@wailsio/runtime": "latest",
|
"@wailsio/runtime": "latest",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-vue": "^10.5.1",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"happy-dom": "^20.0.10",
|
"happy-dom": "^20.0.11",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.48.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"vite-plugin-node-polyfills": "^0.24.0",
|
"vite-plugin-node-polyfills": "^0.24.0",
|
||||||
"vitepress": "^2.0.0-alpha.12",
|
"vitepress": "^2.0.0-alpha.12",
|
||||||
"vitest": "^4.0.8",
|
"vitest": "^4.0.14",
|
||||||
"vue-eslint-parser": "^10.2.0",
|
"vue-eslint-parser": "^10.2.0",
|
||||||
"vue-tsc": "^3.1.3"
|
"vue-tsc": "^3.1.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@latest"
|
"vite": "npm:rolldown-vite@latest"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/* 导入所有CSS文件 */
|
/* 导入所有CSS文件 */
|
||||||
@import 'normalize.css';
|
@import 'normalize.css';
|
||||||
@import 'variables.css';
|
|
||||||
@import 'scrollbar.css';
|
|
||||||
@import "harmony_fonts.css";
|
@import "harmony_fonts.css";
|
||||||
@import 'hack_fonts.css';
|
@import 'hack_fonts.css';
|
||||||
@import 'opensans_fonts.css';
|
@import 'opensans_fonts.css';
|
||||||
@import "monocraft_fonts.css";
|
@import "monocraft_fonts.css";
|
||||||
|
@import 'variables.css';
|
||||||
|
@import 'scrollbar.css';
|
||||||
|
@import 'styles.css';
|
||||||
3
frontend/src/assets/styles/styles.css
Normal file
3
frontend/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
@@ -1,255 +1,209 @@
|
|||||||
:root {
|
:root {
|
||||||
/* 编辑器区域 */
|
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 监听系统深色主题 */
|
/* 默认/暗色主题 */
|
||||||
@media (prefers-color-scheme: dark) {
|
:root,
|
||||||
:root[data-theme="auto"] {
|
:root[data-theme="dark"],
|
||||||
--toolbar-bg: var(--dark-toolbar-bg);
|
:root[data-theme="auto"] {
|
||||||
--toolbar-border: var(--dark-toolbar-border);
|
color-scheme: dark;
|
||||||
--toolbar-text: var(--dark-toolbar-text);
|
|
||||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
--text-primary: #ffffff;
|
||||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
|
||||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
--toolbar-bg: #2d2d2d;
|
||||||
--tab-active-line: var(--dark-tab-active-line);
|
--toolbar-border: #404040;
|
||||||
--bg-secondary: var(--dark-bg-secondary);
|
--toolbar-text: #ffffff;
|
||||||
--text-secondary: var(--dark-text-secondary);
|
--toolbar-text-secondary: #cccccc;
|
||||||
--text-muted: var(--dark-text-muted);
|
--toolbar-button-hover: #404040;
|
||||||
--border-color: var(--dark-border-color);
|
--toolbar-separator: #404040;
|
||||||
--settings-bg: var(--dark-settings-bg);
|
|
||||||
--settings-card-bg: var(--dark-settings-card-bg);
|
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||||
--settings-text: var(--dark-settings-text);
|
--bg-secondary: #0e1217;
|
||||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
--bg-primary: #1a1a1a;
|
||||||
--settings-border: var(--dark-settings-border);
|
--bg-hover: #2a2a2a;
|
||||||
--settings-input-bg: var(--dark-settings-input-bg);
|
|
||||||
--settings-input-border: var(--dark-settings-input-border);
|
--text-secondary: #a0aec0;
|
||||||
--settings-hover: var(--dark-settings-hover);
|
--text-muted: #666666;
|
||||||
--scrollbar-track: var(--dark-scrollbar-track);
|
--text-danger: #ff6b6b;
|
||||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
|
||||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
--border-color: #2d3748;
|
||||||
--selection-bg: var(--dark-selection-bg);
|
|
||||||
--selection-text: var(--dark-selection-text);
|
--settings-bg: #2a2a2a;
|
||||||
--text-danger: var(--dark-danger-color);
|
--settings-card-bg: #333333;
|
||||||
--bg-primary: var(--dark-bg-primary);
|
--settings-text: #ffffff;
|
||||||
--bg-hover: var(--dark-bg-hover);
|
--settings-text-secondary: #cccccc;
|
||||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
--settings-border: #444444;
|
||||||
--voidraft-loading-color: var(--dark-loading-color);
|
--settings-input-bg: #3a3a3a;
|
||||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
--settings-input-border: #555555;
|
||||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
--settings-hover: #404040;
|
||||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
|
||||||
}
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 监听系统浅色主题 */
|
/* 亮色主题 */
|
||||||
|
: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: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跟随系统的浅色偏好 */
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root[data-theme="auto"] {
|
:root[data-theme="auto"] {
|
||||||
--toolbar-bg: var(--light-toolbar-bg);
|
color-scheme: light;
|
||||||
--toolbar-border: var(--light-toolbar-border);
|
|
||||||
--toolbar-text: var(--light-toolbar-text);
|
--text-primary: #000000;
|
||||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
|
||||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
--toolbar-bg: #f8f9fa;
|
||||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
--toolbar-border: #e9ecef;
|
||||||
--tab-active-line: var(--light-tab-active-line);
|
--toolbar-text: #212529;
|
||||||
--bg-secondary: var(--light-bg-secondary);
|
--toolbar-text-secondary: #495057;
|
||||||
--text-secondary: var(--light-text-secondary);
|
--toolbar-button-hover: #e9ecef;
|
||||||
--text-muted: var(--light-text-muted);
|
--toolbar-separator: #e9ecef;
|
||||||
--border-color: var(--light-border-color);
|
|
||||||
--settings-bg: var(--light-settings-bg);
|
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||||
--settings-card-bg: var(--light-settings-card-bg);
|
--bg-secondary: #f7fef7;
|
||||||
--settings-text: var(--light-settings-text);
|
--bg-primary: #ffffff;
|
||||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
--bg-hover: #f1f3f4;
|
||||||
--settings-border: var(--light-settings-border);
|
|
||||||
--settings-input-bg: var(--light-settings-input-bg);
|
--text-secondary: #374151;
|
||||||
--settings-input-border: var(--light-settings-input-border);
|
--text-muted: #6b7280;
|
||||||
--settings-hover: var(--light-settings-hover);
|
--text-danger: #dc3545;
|
||||||
--scrollbar-track: var(--light-scrollbar-track);
|
|
||||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
--border-color: #e5e7eb;
|
||||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
|
||||||
--selection-bg: var(--light-selection-bg);
|
--settings-bg: #ffffff;
|
||||||
--selection-text: var(--light-selection-text);
|
--settings-card-bg: #f8f9fa;
|
||||||
--text-danger: var(--light-danger-color);
|
--settings-text: #212529;
|
||||||
--bg-primary: var(--light-bg-primary);
|
--settings-text-secondary: #6c757d;
|
||||||
--bg-hover: var(--light-bg-hover);
|
--settings-border: #dee2e6;
|
||||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
--settings-input-bg: #ffffff;
|
||||||
--voidraft-loading-color: var(--light-loading-color);
|
--settings-input-border: #ced4da;
|
||||||
--voidraft-loading-glow: var(--light-loading-glow);
|
--settings-hover: #e9ecef;
|
||||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
|
||||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
--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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手动选择浅色主题 */
|
|
||||||
: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);
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AppearanceConfig,
|
AuthMethod,
|
||||||
EditingConfig,
|
|
||||||
GeneralConfig,
|
|
||||||
LanguageType,
|
LanguageType,
|
||||||
SystemThemeType,
|
SystemThemeType,
|
||||||
TabType,
|
TabType,
|
||||||
UpdatesConfig,
|
UpdateSourceType
|
||||||
UpdateSourceType,
|
|
||||||
GitBackupConfig,
|
|
||||||
AuthMethod
|
|
||||||
} from '@/../bindings/voidraft/internal/models/models';
|
} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {FONT_OPTIONS} from './fonts';
|
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 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',
|
alwaysOnTop: 'general.alwaysOnTop',
|
||||||
dataPath: 'general.dataPath',
|
dataPath: 'general.dataPath',
|
||||||
enableSystemTray: 'general.enableSystemTray',
|
enableSystemTray: 'general.enableSystemTray',
|
||||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
|||||||
enableWindowSnap: 'general.enableWindowSnap',
|
enableWindowSnap: 'general.enableWindowSnap',
|
||||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||||
enableTabs: 'general.enableTabs',
|
enableTabs: 'general.enableTabs',
|
||||||
} as const;
|
// editing
|
||||||
|
|
||||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
|
||||||
fontSize: 'editing.fontSize',
|
fontSize: 'editing.fontSize',
|
||||||
fontFamily: 'editing.fontFamily',
|
fontFamily: 'editing.fontFamily',
|
||||||
fontWeight: 'editing.fontWeight',
|
fontWeight: 'editing.fontWeight',
|
||||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
|||||||
enableTabIndent: 'editing.enableTabIndent',
|
enableTabIndent: 'editing.enableTabIndent',
|
||||||
tabSize: 'editing.tabSize',
|
tabSize: 'editing.tabSize',
|
||||||
tabType: 'editing.tabType',
|
tabType: 'editing.tabType',
|
||||||
autoSaveDelay: 'editing.autoSaveDelay'
|
autoSaveDelay: 'editing.autoSaveDelay',
|
||||||
} as const;
|
// appearance
|
||||||
|
|
||||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
|
||||||
language: 'appearance.language',
|
language: 'appearance.language',
|
||||||
systemTheme: 'appearance.systemTheme',
|
systemTheme: 'appearance.systemTheme',
|
||||||
currentTheme: 'appearance.currentTheme'
|
currentTheme: 'appearance.currentTheme',
|
||||||
} as const;
|
// updates
|
||||||
|
|
||||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
|
||||||
version: 'updates.version',
|
version: 'updates.version',
|
||||||
autoUpdate: 'updates.autoUpdate',
|
autoUpdate: 'updates.autoUpdate',
|
||||||
primarySource: 'updates.primarySource',
|
primarySource: 'updates.primarySource',
|
||||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
|||||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||||
updateTimeout: 'updates.updateTimeout',
|
updateTimeout: 'updates.updateTimeout',
|
||||||
github: 'updates.github',
|
github: 'updates.github',
|
||||||
gitea: 'updates.gitea'
|
gitea: 'updates.gitea',
|
||||||
} as const;
|
// backup
|
||||||
|
|
||||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
|
||||||
enabled: 'backup.enabled',
|
enabled: 'backup.enabled',
|
||||||
repo_url: 'backup.repo_url',
|
repo_url: 'backup.repo_url',
|
||||||
auth_method: 'backup.auth_method',
|
auth_method: 'backup.auth_method',
|
||||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
|||||||
auto_backup: 'backup.auto_backup',
|
auto_backup: 'backup.auto_backup',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||||
|
|
||||||
// 配置限制
|
// 配置限制
|
||||||
export const CONFIG_LIMITS = {
|
export const CONFIG_LIMITS = {
|
||||||
fontSize: {min: 12, max: 28, default: 13},
|
fontSize: {min: 12, max: 28, default: 13},
|
||||||
|
|||||||
1945
frontend/src/common/constant/emojies.ts
Normal file
1945
frontend/src/common/constant/emojies.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
* 翻译图标SVG
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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'] });
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Token } from 'markdown-it';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emoji 渲染函数
|
|
||||||
*/
|
|
||||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
|
||||||
return tokens[idx].content;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
font-family: var(--voidraft-font-mono),serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-word {
|
.loading-word {
|
||||||
@@ -175,4 +175,4 @@ onBeforeUnmount(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
.menu-item:hover & {
|
.menu-item:hover & {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
|||||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const editorStore = readonly(useEditorStore());
|
const editorStore = useEditorStore();
|
||||||
|
|
||||||
// 组件状态
|
// 组件状态
|
||||||
const showLanguageMenu = shallowRef(false);
|
const showLanguageMenu = shallowRef(false);
|
||||||
|
|||||||
@@ -13,20 +13,16 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
|||||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
|
||||||
import {usePanelStore} from '@/stores/panelStore';
|
|
||||||
|
|
||||||
const editorStore = readonly(useEditorStore());
|
const editorStore = useEditorStore();
|
||||||
const configStore = readonly(useConfigStore());
|
const configStore = useConfigStore();
|
||||||
const updateStore = readonly(useUpdateStore());
|
const updateStore = useUpdateStore();
|
||||||
const windowStore = readonly(useWindowStore());
|
const windowStore = useWindowStore();
|
||||||
const systemStore = readonly(useSystemStore());
|
const systemStore = useSystemStore();
|
||||||
const panelStore = readonly(usePanelStore());
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const canFormatCurrentBlock = ref(false);
|
const canFormatCurrentBlock = ref(false);
|
||||||
const canPreviewMarkdown = ref(false);
|
|
||||||
const isLoaded = shallowRef(false);
|
const isLoaded = shallowRef(false);
|
||||||
|
|
||||||
const { documentStats } = toRefs(editorStore);
|
const { documentStats } = toRefs(editorStore);
|
||||||
@@ -37,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
|||||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 当前文档的预览是否打开
|
|
||||||
const isCurrentBlockPreviewing = computed(() => {
|
|
||||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 切换窗口置顶状态
|
// 切换窗口置顶状态
|
||||||
const toggleAlwaysOnTop = async () => {
|
const toggleAlwaysOnTop = async () => {
|
||||||
@@ -69,22 +61,12 @@ const formatCurrentBlock = () => {
|
|||||||
formatBlockContent(editorStore.editorView);
|
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 updateButtonStates = () => {
|
||||||
const view: any = editorStore.editorView;
|
const view: any = editorStore.editorView;
|
||||||
if (!view) {
|
if (!view) {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +77,6 @@ const updateButtonStates = () => {
|
|||||||
// 提前返回,减少不必要的计算
|
// 提前返回,减少不必要的计算
|
||||||
if (!activeBlock) {
|
if (!activeBlock) {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +84,9 @@ const updateButtonStates = () => {
|
|||||||
const language = getLanguage(languageName as any);
|
const language = getLanguage(languageName as any);
|
||||||
|
|
||||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error checking block capabilities:', error);
|
console.warn('Error checking block capabilities:', error);
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,7 +140,6 @@ watch(
|
|||||||
cleanupListeners = setupEditorListeners(newView);
|
cleanupListeners = setupEditorListeners(newView);
|
||||||
} else {
|
} else {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -255,21 +233,6 @@ const statsData = computed(() => ({
|
|||||||
<!-- 块语言选择器 -->
|
<!-- 块语言选择器 -->
|
||||||
<BlockLanguageSelector/>
|
<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
|
<div
|
||||||
v-if="canFormatCurrentBlock"
|
v-if="canFormatCurrentBlock"
|
||||||
|
|||||||
@@ -161,53 +161,6 @@ export default {
|
|||||||
customThemeColors: 'Custom Theme Colors',
|
customThemeColors: 'Custom Theme Colors',
|
||||||
resetToDefault: 'Reset to Default',
|
resetToDefault: 'Reset to Default',
|
||||||
colorValue: 'Color Value',
|
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',
|
lineHeight: 'Line Height',
|
||||||
tabSettings: 'Tab Settings',
|
tabSettings: 'Tab Settings',
|
||||||
tabSize: 'Tab Size',
|
tabSize: 'Tab Size',
|
||||||
@@ -341,4 +294,4 @@ export default {
|
|||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
clickToClean: 'Click to clean memory'
|
clickToClean: 'Click to clean memory'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,54 +202,6 @@ export default {
|
|||||||
customThemeColors: '自定义主题颜色',
|
customThemeColors: '自定义主题颜色',
|
||||||
resetToDefault: '重置为默认',
|
resetToDefault: '重置为默认',
|
||||||
colorValue: '颜色值',
|
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: '预览:',
|
hotkeyPreview: '预览:',
|
||||||
none: '无',
|
none: '无',
|
||||||
backup: {
|
backup: {
|
||||||
@@ -344,4 +296,4 @@ export default {
|
|||||||
memory: '内存',
|
memory: '内存',
|
||||||
clickToClean: '点击清理内存'
|
clickToClean: '点击清理内存'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
|||||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {
|
import {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AppearanceConfig,
|
|
||||||
AuthMethod,
|
AuthMethod,
|
||||||
EditingConfig,
|
EditingConfig,
|
||||||
GeneralConfig,
|
|
||||||
GitBackupConfig,
|
|
||||||
LanguageType,
|
LanguageType,
|
||||||
SystemThemeType,
|
SystemThemeType,
|
||||||
TabType,
|
TabType
|
||||||
UpdatesConfig
|
|
||||||
} from '@/../bindings/voidraft/internal/models/models';
|
} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||||
import {
|
import {
|
||||||
APPEARANCE_CONFIG_KEY_MAP,
|
CONFIG_KEY_MAP,
|
||||||
BACKUP_CONFIG_KEY_MAP,
|
|
||||||
CONFIG_LIMITS,
|
CONFIG_LIMITS,
|
||||||
|
ConfigKey,
|
||||||
|
ConfigSection,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
EDITING_CONFIG_KEY_MAP,
|
NumberConfigKey
|
||||||
GENERAL_CONFIG_KEY_MAP,
|
|
||||||
NumberConfigKey,
|
|
||||||
UPDATES_CONFIG_KEY_MAP
|
|
||||||
} from '@/common/constant/config';
|
} from '@/common/constant/config';
|
||||||
import * as runtime from '@wailsio/runtime';
|
import * as runtime from '@wailsio/runtime';
|
||||||
|
|
||||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
// Font options (no longer localized)
|
// Font options (no longer localized)
|
||||||
const fontOptions = computed(() => FONT_OPTIONS);
|
const fontOptions = computed(() => FONT_OPTIONS);
|
||||||
|
|
||||||
// 计算属性 - 使用工厂函数简化
|
// 计算属性
|
||||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||||
const limits = Object.fromEntries(
|
const limits = Object.fromEntries(
|
||||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
) 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) {
|
if (!state.configLoaded && !state.isLoading) {
|
||||||
await initConfig();
|
await initConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
if (!backendKey) {
|
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);
|
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> => {
|
// 只更新本地状态,不保存到后端
|
||||||
// 确保配置已加载
|
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
await initConfig();
|
const section = backendKey.split('.')[0] as ConfigSection;
|
||||||
}
|
(state.config[section] as any)[key] = value;
|
||||||
|
|
||||||
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 updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
// 保存指定配置到后端
|
||||||
// 确保配置已加载
|
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
await initConfig();
|
const section = backendKey.split('.')[0] as ConfigSection;
|
||||||
}
|
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||||
reset: async () => await updateEditingConfig(key, limit.default)
|
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) =>
|
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[]) =>
|
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||||
async () => {
|
async () => {
|
||||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||||
const nextIndex = (currentIndex + 1) % values.length;
|
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> => {
|
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||||
await updateAppearanceConfig('language', language);
|
await updateConfig('language', language);
|
||||||
|
|
||||||
// 同步更新前端语言
|
|
||||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||||
locale.value = frontendLocale as any;
|
locale.value = frontendLocale as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 系统主题设置方法
|
// 系统主题设置方法
|
||||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
await updateConfig('systemTheme', systemTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 当前主题设置方法
|
// 当前主题设置方法
|
||||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
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 = {
|
const togglers = {
|
||||||
tabIndent: createEditingToggler('enableTabIndent'),
|
tabIndent: createEditingToggler('enableTabIndent'),
|
||||||
alwaysOnTop: async () => {
|
alwaysOnTop: async () => {
|
||||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||||
// 立即应用窗口置顶状态
|
|
||||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||||
},
|
},
|
||||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
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 {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
config: computed(() => state.config),
|
config: computed(() => state.config),
|
||||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
decreaseFontSize: adjusters.fontSize.decrease,
|
decreaseFontSize: adjusters.fontSize.decrease,
|
||||||
resetFontSize: adjusters.fontSize.reset,
|
resetFontSize: adjusters.fontSize.reset,
|
||||||
setFontSize: adjusters.fontSize.set,
|
setFontSize: adjusters.fontSize.set,
|
||||||
|
// 字体大小操作
|
||||||
|
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||||
|
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||||
|
saveFontSize: () => saveConfig('fontSize'),
|
||||||
|
|
||||||
// Tab操作
|
// Tab操作
|
||||||
toggleTabIndent: togglers.tabIndent,
|
toggleTabIndent: togglers.tabIndent,
|
||||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||||
...adjusters.tabSize,
|
...adjusters.tabSize,
|
||||||
increaseTabSize: adjusters.tabSize.increase,
|
increaseTabSize: adjusters.tabSize.increase,
|
||||||
decreaseTabSize: adjusters.tabSize.decrease,
|
decreaseTabSize: adjusters.tabSize.decrease,
|
||||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
// 窗口操作
|
// 窗口操作
|
||||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||||
|
|
||||||
// 字体操作
|
// 字体操作
|
||||||
setFontFamily: setters.fontFamily,
|
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||||
setFontWeight: setters.fontWeight,
|
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),
|
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||||
|
|
||||||
// 系统托盘配置相关方法
|
// 系统托盘配置相关方法
|
||||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||||
|
|
||||||
// 开机启动配置相关方法
|
// 开机启动配置相关方法
|
||||||
setStartAtLogin: async (value: boolean) => {
|
setStartAtLogin: async (value: boolean) => {
|
||||||
// 先更新配置文件
|
await updateConfig('startAtLogin', value);
|
||||||
await updateGeneralConfig('startAtLogin', value);
|
|
||||||
// 再调用系统设置API
|
|
||||||
await StartupService.SetEnabled(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) => {
|
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||||
await updateBackupConfig('enabled', value);
|
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||||
},
|
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||||
setAutoBackup: async (value: boolean) => {
|
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||||
await updateBackupConfig('auto_backup', value);
|
setUsername: (value: string) => updateConfig('username', value),
|
||||||
},
|
setPassword: (value: string) => updateConfig('password', value),
|
||||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
setToken: (value: string) => updateConfig('token', value),
|
||||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
setBackupInterval: (value: number) => updateConfig('backup_interval', 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),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
|
|||||||
import {EditorState, Extension} from '@codemirror/state';
|
import {EditorState, Extension} from '@codemirror/state';
|
||||||
import {useConfigStore} from './configStore';
|
import {useConfigStore} from './configStore';
|
||||||
import {useDocumentStore} from './documentStore';
|
import {useDocumentStore} from './documentStore';
|
||||||
import {usePanelStore} from './panelStore';
|
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {ensureSyntaxTree} from "@codemirror/language";
|
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 {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||||
|
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||||
import {
|
import {
|
||||||
createDynamicExtensions,
|
createDynamicExtensions,
|
||||||
@@ -29,8 +29,8 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
|||||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
import markdownExtensions from "@/views/editor/extensions/markdown";
|
||||||
|
|
||||||
export interface DocumentStats {
|
export interface DocumentStats {
|
||||||
lines: number;
|
lines: number;
|
||||||
@@ -242,6 +242,13 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
fontWeight: configStore.config.editing.fontWeight
|
fontWeight: configStore.config.editing.fontWeight
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wheelZoomExtension = createWheelZoomExtension({
|
||||||
|
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||||
|
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||||
|
onSave: () => configStore.saveFontSize(),
|
||||||
|
saveDelay: 500
|
||||||
|
});
|
||||||
|
|
||||||
// 统计扩展
|
// 统计扩展
|
||||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||||
|
|
||||||
@@ -256,8 +263,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
const httpExtension = createHttpClientExtension();
|
const httpExtension = createHttpClientExtension();
|
||||||
|
|
||||||
// Markdown预览扩展
|
|
||||||
const previewExtension = markdownPreviewExtension();
|
|
||||||
|
|
||||||
// 再次检查操作有效性
|
// 再次检查操作有效性
|
||||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||||
@@ -287,12 +292,13 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
themeExtension,
|
themeExtension,
|
||||||
...tabExtensions,
|
...tabExtensions,
|
||||||
fontExtension,
|
fontExtension,
|
||||||
|
wheelZoomExtension,
|
||||||
statsExtension,
|
statsExtension,
|
||||||
contentChangeExtension,
|
contentChangeExtension,
|
||||||
codeBlockExtension,
|
codeBlockExtension,
|
||||||
...dynamicExtensions,
|
...dynamicExtensions,
|
||||||
...httpExtension,
|
...httpExtension,
|
||||||
previewExtension
|
markdownExtensions
|
||||||
];
|
];
|
||||||
|
|
||||||
// 创建编辑器状态
|
// 创建编辑器状态
|
||||||
@@ -635,6 +641,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 应用Tab设置
|
// 应用Tab设置
|
||||||
const applyTabSettings = () => {
|
const applyTabSettings = () => {
|
||||||
editorCache.values().forEach(instance => {
|
editorCache.values().forEach(instance => {
|
||||||
@@ -687,10 +694,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
instance.view.destroy();
|
instance.view.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
|
||||||
const panelStore = usePanelStore();
|
|
||||||
panelStore.reset();
|
|
||||||
|
|
||||||
currentEditor.value = null;
|
currentEditor.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,12 +710,15 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 更新前端编辑器扩展 - 应用于所有实例
|
// 更新前端编辑器扩展 - 应用于所有实例
|
||||||
const manager = getExtensionManager();
|
const manager = getExtensionManager();
|
||||||
if (manager) {
|
if (manager) {
|
||||||
// 使用立即更新模式,跳过防抖
|
// 直接更新前端扩展至所有视图
|
||||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
manager.updateExtension(id, enabled, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载扩展配置
|
// 重新加载扩展配置
|
||||||
await extensionStore.loadExtensions();
|
await extensionStore.loadExtensions();
|
||||||
|
if (manager) {
|
||||||
|
manager.initExtensions(extensionStore.extensions);
|
||||||
|
}
|
||||||
|
|
||||||
await applyKeymapSettings();
|
await applyKeymapSettings();
|
||||||
};
|
};
|
||||||
@@ -781,4 +787,4 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
editorView: currentEditor,
|
editorView: currentEditor,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,195 +1,159 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
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 { ThemeService } from '@/../bindings/voidraft/internal/services';
|
||||||
import { useConfigStore } from './configStore';
|
import { useConfigStore } from './configStore';
|
||||||
import { useEditorStore } from './editorStore';
|
import { useEditorStore } from './editorStore';
|
||||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
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', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
// 所有主题列表
|
|
||||||
const allThemes = ref<Theme[]>([]);
|
|
||||||
|
|
||||||
// 当前主题的颜色配置
|
|
||||||
const currentColors = ref<ThemeColors | null>(null);
|
const currentColors = ref<ThemeColors | null>(null);
|
||||||
|
|
||||||
// 计算属性:当前系统主题模式
|
const currentTheme = computed(
|
||||||
const currentTheme = computed(() =>
|
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算属性:当前是否为深色模式
|
const isDarkMode = computed(
|
||||||
const isDarkMode = computed(() =>
|
() =>
|
||||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算属性:根据类型获取主题列表
|
const availableThemes = computed<ThemeOption[]>(() =>
|
||||||
const darkThemes = computed(() =>
|
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 应用主题到 DOM
|
|
||||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||||
const themeMap = {
|
const themeMap = {
|
||||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||||
[SystemThemeType.SystemThemeLight]: 'light'
|
[SystemThemeType.SystemThemeLight]: 'light',
|
||||||
};
|
};
|
||||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从数据库加载所有主题
|
const loadThemeColors = async (themeName?: string) => {
|
||||||
const loadAllThemes = async () => {
|
const targetName = resolveThemeName(
|
||||||
try {
|
themeName || configStore.config?.appearance?.currentTheme
|
||||||
const themes = await ThemeService.GetAllThemes();
|
);
|
||||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
currentColors.value = await fetchThemeColors(targetName);
|
||||||
return allThemes.value;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load themes from database:', error);
|
|
||||||
allThemes.value = [];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化主题颜色
|
|
||||||
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 initializeTheme = async () => {
|
||||||
const theme = currentTheme.value;
|
applyThemeToDOM(currentTheme.value);
|
||||||
applyThemeToDOM(theme);
|
await loadThemeColors();
|
||||||
await initializeThemeColors();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置系统主题模式(深色/浅色/自动)
|
|
||||||
const setTheme = async (theme: SystemThemeType) => {
|
const setTheme = async (theme: SystemThemeType) => {
|
||||||
await configStore.setSystemTheme(theme);
|
await configStore.setSystemTheme(theme);
|
||||||
applyThemeToDOM(theme);
|
applyThemeToDOM(theme);
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换到指定的预设主题
|
|
||||||
const switchToTheme = async (themeName: string) => {
|
const switchToTheme = async (themeName: string) => {
|
||||||
const theme = allThemes.value.find(t => t.name === themeName);
|
if (!themePresetMap[themeName]) {
|
||||||
if (!theme) {
|
|
||||||
console.error('Theme not found:', themeName);
|
console.error('Theme not found:', themeName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接设置当前主题颜色
|
await loadThemeColors(themeName);
|
||||||
currentColors.value = theme.colors as ThemeColors;
|
|
||||||
|
|
||||||
// 持久化到配置
|
|
||||||
await configStore.setCurrentTheme(themeName);
|
await configStore.setCurrentTheme(themeName);
|
||||||
|
|
||||||
// 刷新编辑器
|
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新当前主题的颜色配置
|
|
||||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||||
if (!currentColors.value) return;
|
if (!currentColors.value) return;
|
||||||
Object.assign(currentColors.value, colors);
|
Object.assign(currentColors.value, colors);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存当前主题颜色到数据库
|
|
||||||
const saveCurrentTheme = async () => {
|
const saveCurrentTheme = async () => {
|
||||||
if (!currentColors.value) {
|
if (!currentColors.value) {
|
||||||
throw new Error('No theme selected');
|
throw new Error('No theme selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||||
if (!theme) {
|
currentColors.value.themeName = themeName;
|
||||||
throw new Error('Theme not found');
|
|
||||||
}
|
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
|
||||||
|
|
||||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
await loadThemeColors(themeName);
|
||||||
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置当前主题为预设配置
|
|
||||||
const resetCurrentTheme = async () => {
|
const resetCurrentTheme = async () => {
|
||||||
if (!currentColors.value) {
|
if (!currentColors.value) {
|
||||||
throw new Error('No theme selected');
|
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);
|
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||||
|
await ThemeService.ResetTheme(themeName);
|
||||||
if (updatedTheme) {
|
|
||||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
await loadThemeColors(themeName);
|
||||||
}
|
|
||||||
|
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新编辑器主题
|
|
||||||
const refreshEditorTheme = () => {
|
const refreshEditorTheme = () => {
|
||||||
applyThemeToDOM(currentTheme.value);
|
applyThemeToDOM(currentTheme.value);
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
editorStore?.applyThemeSettings();
|
editorStore?.applyThemeSettings();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
|
||||||
allThemes,
|
|
||||||
darkThemes,
|
|
||||||
lightThemes,
|
|
||||||
availableThemes,
|
availableThemes,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
currentColors,
|
currentColors,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
|
|
||||||
// 方法
|
|
||||||
setTheme,
|
setTheme,
|
||||||
switchToTheme,
|
switchToTheme,
|
||||||
initializeTheme,
|
initializeTheme,
|
||||||
loadAllThemes,
|
|
||||||
updateCurrentColors,
|
updateCurrentColors,
|
||||||
saveCurrentTheme,
|
saveCurrentTheme,
|
||||||
resetCurrentTheme,
|
resetCurrentTheme,
|
||||||
refreshEditorTheme,
|
refreshEditorTheme,
|
||||||
applyThemeToDOM,
|
applyThemeToDOM,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import {defineStore} from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
import {ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
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', () => {
|
export const useTranslationStore = defineStore('translation', () => {
|
||||||
// 基础状态
|
// 基础状态
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
|||||||
import {useEditorStore} from '@/stores/editorStore';
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
import {useDocumentStore} from '@/stores/documentStore';
|
import {useDocumentStore} from '@/stores/documentStore';
|
||||||
import {useConfigStore} from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
|
||||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
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 LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||||
import {useTabStore} from "@/stores/tabStore";
|
import {useTabStore} from '@/stores/tabStore';
|
||||||
|
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||||
|
import {contextMenuManager} from './contextMenu/manager';
|
||||||
|
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||||
|
import {translatorManager} from './extensions/translator/manager';
|
||||||
|
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
|
|||||||
|
|
||||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||||
|
|
||||||
// 创建滚轮缩放处理器
|
|
||||||
const wheelHandler = createWheelZoomHandler(
|
|
||||||
configStore.increaseFontSize,
|
|
||||||
configStore.decreaseFontSize
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!editorElement.value) return;
|
if (!editorElement.value) return;
|
||||||
|
|
||||||
// 从URL查询参数中获取documentId
|
|
||||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||||
|
|
||||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
|
||||||
await documentStore.initialize(urlDocumentId);
|
await documentStore.initialize(urlDocumentId);
|
||||||
|
|
||||||
// 设置编辑器容器
|
|
||||||
editorStore.setEditorContainer(editorElement.value);
|
editorStore.setEditorContainer(editorElement.value);
|
||||||
|
|
||||||
await tabStore.initializeTab();
|
await tabStore.initializeTab();
|
||||||
|
|
||||||
// 添加滚轮事件监听
|
|
||||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// 移除滚轮事件监听
|
contextMenuManager.destroy();
|
||||||
if (editorElement.value) {
|
translatorManager.destroy();
|
||||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
|
||||||
}
|
|
||||||
editorStore.clearAllEditors();
|
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<div ref="editorElement" class="editor"></div>
|
<!-- 加载动画 -->
|
||||||
<Toolbar/>
|
|
||||||
<transition name="loading-fade">
|
<transition name="loading-fade">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||||
</transition>
|
</transition>
|
||||||
|
<!-- 编辑器区域 -->
|
||||||
|
<div ref="editorElement" class="editor"></div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<Toolbar/>
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<ContextMenu :portal-target="editorElement"/>
|
||||||
|
<!-- 翻译器弹窗 -->
|
||||||
|
<TranslatorDialog :portal-target="editorElement"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载动画过渡效果
|
|
||||||
.loading-fade-enter-active,
|
.loading-fade-enter-active,
|
||||||
.loading-fade-leave-active {
|
.loading-fade-leave-active {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
@@ -98,4 +94,4 @@ onBeforeUnmount(() => {
|
|||||||
.loading-fade-leave-to {
|
.loading-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
|
||||||
import { useEditorStore } from '@/stores/editorStore';
|
import type {Text} from '@codemirror/state';
|
||||||
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内容变化监听插件 - 集成文档和编辑器管理
|
|
||||||
*/
|
*/
|
||||||
export function createContentChangePlugin() {
|
export function createContentChangePlugin() {
|
||||||
return ViewPlugin.fromClass(
|
return ViewPlugin.fromClass(
|
||||||
class ContentChangePlugin {
|
class ContentChangePlugin {
|
||||||
private editorStore = useEditorStore();
|
private readonly editorStore = useEditorStore();
|
||||||
private lastContent = '';
|
private lastDoc: Text;
|
||||||
|
private rafId: number | null = null;
|
||||||
|
private pendingNotification = false;
|
||||||
|
|
||||||
constructor(private view: EditorView) {
|
constructor(private view: EditorView) {
|
||||||
this.lastContent = view.state.doc.toString();
|
this.lastDoc = view.state.doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newContent = this.view.state.doc.toString();
|
this.lastDoc = update.state.doc;
|
||||||
if (newContent === this.lastContent) return;
|
this.scheduleNotification();
|
||||||
|
|
||||||
this.lastContent = newContent;
|
|
||||||
|
|
||||||
this.editorStore.onContentChange();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
// 处理滚轮缩放字体的事件处理函数
|
import {EditorView} from '@codemirror/view';
|
||||||
export const createWheelZoomHandler = (
|
import type {Extension} from '@codemirror/state';
|
||||||
increaseFontSize: () => void,
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
decreaseFontSize: () => void
|
|
||||||
) => {
|
type FontAdjuster = () => void;
|
||||||
return (event: WheelEvent) => {
|
type SaveCallback = () => Promise<void> | void;
|
||||||
// 检查是否按住了Ctrl键
|
|
||||||
if (event.ctrlKey) {
|
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();
|
event.preventDefault();
|
||||||
|
|
||||||
// 根据滚轮方向增大或减小字体
|
// 立即更新字体大小
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
// 向上滚动,增大字体
|
|
||||||
increaseFontSize();
|
increaseFontSize();
|
||||||
} else {
|
} else if (event.deltaY > 0) {
|
||||||
// 向下滚动,减小字体
|
|
||||||
decreaseFontSize();
|
decreaseFontSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防抖保存
|
||||||
|
if (debouncedSave) {
|
||||||
|
debouncedSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal 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>
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,174 +1,141 @@
|
|||||||
/**
|
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';
|
||||||
|
import { showContextMenu } from './manager';
|
||||||
|
import {
|
||||||
|
buildRegisteredMenu,
|
||||||
|
createMenuContext,
|
||||||
|
registerMenuNodes
|
||||||
|
} from './menuSchema';
|
||||||
|
import type { MenuSchemaNode } from './menuSchema';
|
||||||
|
|
||||||
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 {
|
function t(key: string): string {
|
||||||
return i18n.global.t(key);
|
return i18n.global.t(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取快捷键显示文本
|
function formatKeyBinding(keyBinding: string): string {
|
||||||
* @param command 命令ID
|
const systemStore = useSystemStore();
|
||||||
* @returns 快捷键显示文本
|
const isMac = systemStore.isMacOS;
|
||||||
*/
|
|
||||||
function getShortcutText(command: KeyBindingCommand): string {
|
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 {
|
try {
|
||||||
const keybindingStore = useKeybindingStore();
|
const keybindingStore = useKeybindingStore();
|
||||||
|
const binding = keybindingStore.keyBindings.find(
|
||||||
// 如果找到该命令的快捷键配置
|
(kb) => kb.command === command && kb.enabled
|
||||||
const binding = keybindingStore.keyBindings.find(kb =>
|
|
||||||
kb.command === command && kb.enabled
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (binding && binding.key) {
|
if (binding?.key) {
|
||||||
// 格式化快捷键显示
|
const formatted = formatKeyBinding(binding.key);
|
||||||
return formatKeyBinding(binding.key);
|
shortcutCache.set(command, formatted);
|
||||||
|
return formatted;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("An error occurred while getting the shortcut:", error);
|
console.warn("An error occurred while getting the shortcut:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortcutCache.set(command, "");
|
||||||
return "";
|
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 getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||||
/**
|
|
||||||
* 创建编辑菜单项
|
|
||||||
*/
|
|
||||||
function createEditItems(): MenuItem[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockCopy"),
|
id: "copy",
|
||||||
|
labelKey: "keybindings.commands.blockCopy",
|
||||||
command: copyCommand,
|
command: copyCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
|
||||||
|
enabled: (context) => context.hasSelection
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockCut"),
|
id: "cut",
|
||||||
|
labelKey: "keybindings.commands.blockCut",
|
||||||
command: cutCommand,
|
command: cutCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
shortcutCommand: KeyBindingCommand.BlockCutCommand,
|
||||||
|
visible: (context) => context.isEditable,
|
||||||
|
enabled: (context) => context.hasSelection && context.isEditable
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockPaste"),
|
id: "paste",
|
||||||
|
labelKey: "keybindings.commands.blockPaste",
|
||||||
command: pasteCommand,
|
command: pasteCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||||
}
|
visible: (context) => context.isEditable
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建历史操作菜单项
|
|
||||||
*/
|
|
||||||
function createHistoryItems(): MenuItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.historyUndo"),
|
|
||||||
command: undo,
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.historyRedo"),
|
id: "undo",
|
||||||
|
labelKey: "keybindings.commands.historyUndo",
|
||||||
|
command: undo,
|
||||||
|
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
|
||||||
|
visible: (context) => context.isEditable
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redo",
|
||||||
|
labelKey: "keybindings.commands.historyRedo",
|
||||||
command: redo,
|
command: redo,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||||
|
visible: (context) => context.isEditable
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let builtinMenuRegistered = false;
|
||||||
|
|
||||||
/**
|
function ensureBuiltinMenuRegistered(): void {
|
||||||
* 创建主菜单项
|
if (builtinMenuRegistered) return;
|
||||||
*/
|
registerMenuNodes(getBuiltinMenuNodes());
|
||||||
function createMainMenuItems(): MenuItem[] {
|
builtinMenuRegistered = true;
|
||||||
// 基本编辑操作放在主菜单
|
|
||||||
const basicItems = createEditItems();
|
|
||||||
|
|
||||||
// 历史操作放在主菜单
|
|
||||||
const historyItems = createHistoryItems();
|
|
||||||
|
|
||||||
// 构建主菜单
|
|
||||||
return [
|
|
||||||
...basicItems,
|
|
||||||
...historyItems
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建编辑器上下文菜单
|
|
||||||
*/
|
|
||||||
export function createEditorContextMenu(): Extension {
|
export function createEditorContextMenu(): Extension {
|
||||||
// 为编辑器添加右键事件处理
|
ensureBuiltinMenuRegistered();
|
||||||
|
|
||||||
return EditorView.domEventHandlers({
|
return EditorView.domEventHandlers({
|
||||||
contextmenu: (event, view) => {
|
contextmenu: (event, view) => {
|
||||||
// 阻止默认右键菜单
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 获取菜单项
|
const context = createMenuContext(view, event as MouseEvent);
|
||||||
const menuItems = createMainMenuItems();
|
const menuItems = buildRegisteredMenu(context, {
|
||||||
|
translate: t,
|
||||||
// 显示上下文菜单
|
formatShortcut: getShortcutText
|
||||||
|
});
|
||||||
|
|
||||||
|
if (menuItems.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default createEditorContextMenu;
|
||||||
* 默认导出
|
|
||||||
*/
|
|
||||||
export default createEditorContextMenu;
|
|
||||||
|
|||||||
108
frontend/src/views/editor/contextMenu/manager.ts
Normal file
108
frontend/src/views/editor/contextMenu/manager.ts
Normal 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);
|
||||||
|
}
|
||||||
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 块背景层 - 修复高度计算问题
|
* 块背景层 - 修复高度计算问题
|
||||||
|
*
|
||||||
|
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||||
|
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||||
|
* 行的坐标也不会受影响,边界线位置正确。
|
||||||
*/
|
*/
|
||||||
const blockLayer = layer({
|
const blockLayer = layer({
|
||||||
above: false,
|
above: false,
|
||||||
@@ -135,14 +139,17 @@ const blockLayer = layer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// view.coordsAtPos 如果编辑器不可见则返回 null
|
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||||
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
|
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||||
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
|
|
||||||
|
|
||||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||||
idx++;
|
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||||
return;
|
const toLineBlock = view.lineBlockAt(toPos);
|
||||||
}
|
|
||||||
|
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||||
|
// 转换为视口坐标进行后续计算
|
||||||
|
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||||
|
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||||
|
|
||||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||||
if (idx === blocks.length - 1) {
|
if (idx === blocks.length - 1) {
|
||||||
@@ -151,7 +158,7 @@ const blockLayer = layer({
|
|||||||
|
|
||||||
// 让最后一个块直接延伸到编辑器底部
|
// 让最后一个块直接延伸到编辑器底部
|
||||||
if (contentBottom < editorHeight) {
|
if (contentBottom < editorHeight) {
|
||||||
const extraHeight = editorHeight - contentBottom-10;
|
const extraHeight = editorHeight - contentBottom - 10;
|
||||||
toCoordsBottom += extraHeight;
|
toCoordsBottom += extraHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
import {jsonLanguage} from "@codemirror/lang-json";
|
import {jsonLanguage} from "@codemirror/lang-json";
|
||||||
import {pythonLanguage} from "@codemirror/lang-python";
|
import {pythonLanguage} from "@codemirror/lang-python";
|
||||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
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 {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 {javaLanguage} from "@codemirror/lang-java";
|
import {javaLanguage} from "@codemirror/lang-java";
|
||||||
import {phpLanguage} from "@codemirror/lang-php";
|
import {phpLanguage} from "@codemirror/lang-php";
|
||||||
import {cssLanguage} from "@codemirror/lang-css";
|
import {cssLanguage} from "@codemirror/lang-css";
|
||||||
@@ -22,9 +27,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
|||||||
import {sassLanguage} from "@codemirror/lang-sass";
|
import {sassLanguage} from "@codemirror/lang-sass";
|
||||||
import {lessLanguage} from "@codemirror/lang-less";
|
import {lessLanguage} from "@codemirror/lang-less";
|
||||||
import {angularLanguage} from "@codemirror/lang-angular";
|
import {angularLanguage} from "@codemirror/lang-angular";
|
||||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||||
import {StreamLanguage} from "@codemirror/language";
|
import {StreamLanguage} from "@codemirror/language";
|
||||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||||
@@ -64,6 +69,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
|||||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||||
|
import {languages} from "@codemirror/language-data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言信息类
|
* 语言信息类
|
||||||
@@ -110,7 +116,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
parser: "sql",
|
parser: "sql",
|
||||||
plugins: [sqlPrettierPlugin]
|
plugins: [sqlPrettierPlugin]
|
||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
|
base: markdownLanguage,
|
||||||
|
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
||||||
|
completeHTMLTags: true,
|
||||||
|
pasteURLAsLink: true,
|
||||||
|
htmlTagLanguage: html({
|
||||||
|
matchClosingTags: true,
|
||||||
|
autoCloseTags: true
|
||||||
|
}),
|
||||||
|
addKeymap: true,
|
||||||
|
codeLanguages: languages,
|
||||||
|
|
||||||
|
}).language.parser, ["md"], {
|
||||||
parser: "markdown",
|
parser: "markdown",
|
||||||
plugins: [markdownPrettierPlugin]
|
plugins: [markdownPrettierPlugin]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, Range } from '@codemirror/state';
|
||||||
import * as runtime from "@wailsio/runtime";
|
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 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/gi;
|
||||||
|
|
||||||
export interface HyperLinkState {
|
export interface HyperLinkState {
|
||||||
at: number;
|
at: number;
|
||||||
@@ -54,18 +53,8 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio
|
|||||||
const from = match.index;
|
const from = match.index;
|
||||||
const to = from + match[0].length;
|
const to = from + match[0].length;
|
||||||
|
|
||||||
// 检查当前位置是否在 HTTP 代码块中
|
|
||||||
const block = getNoteBlockFromPos(view.state, from);
|
|
||||||
if (block && block.language.name === 'http') {
|
|
||||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkMark = Decoration.mark({
|
const linkMark = Decoration.mark({
|
||||||
class: 'cm-hyper-link-text',
|
class: 'cm-hyper-link-text'
|
||||||
attributes: {
|
|
||||||
'data-url': match[0]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
widgets.push(linkMark.range(from, to));
|
widgets.push(linkMark.range(from, to));
|
||||||
|
|
||||||
@@ -91,14 +80,7 @@ const linkDecorator = (
|
|||||||
) =>
|
) =>
|
||||||
new MatchDecorator({
|
new MatchDecorator({
|
||||||
regexp: regexp || defaultRegexp,
|
regexp: regexp || defaultRegexp,
|
||||||
decorate: (add, from, to, match, view) => {
|
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];
|
const url = match[0];
|
||||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||||
if (matchData && matchData[url]) {
|
if (matchData && matchData[url]) {
|
||||||
@@ -109,10 +91,7 @@ const linkDecorator = (
|
|||||||
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
||||||
|
|
||||||
add(from, to, Decoration.mark({
|
add(from, to, Decoration.mark({
|
||||||
class: 'cm-hyper-link-text cm-hyper-link-underline',
|
class: 'cm-hyper-link-text cm-hyper-link-underline'
|
||||||
attributes: {
|
|
||||||
'data-url': urlStr
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||||
},
|
},
|
||||||
@@ -158,7 +137,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
|
|||||||
export const hyperLinkStyle = EditorView.baseTheme({
|
export const hyperLinkStyle = EditorView.baseTheme({
|
||||||
'.cm-hyper-link-text': {
|
'.cm-hyper-link-text': {
|
||||||
color: '#0969da',
|
color: '#0969da',
|
||||||
cursor: 'pointer',
|
cursor: 'text',
|
||||||
transition: 'color 0.2s ease',
|
transition: 'color 0.2s ease',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
textDecorationColor: '#0969da',
|
textDecorationColor: '#0969da',
|
||||||
@@ -216,24 +195,19 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
||||||
click: (event, view) => {
|
click: (event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement | null;
|
||||||
let urlElement = target;
|
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
|
||||||
|
|
||||||
while (urlElement && !urlElement.hasAttribute('data-url')) {
|
if (iconElement && iconElement.hasAttribute('data-url')) {
|
||||||
urlElement = urlElement.parentElement as HTMLElement;
|
const url = iconElement.getAttribute('data-url');
|
||||||
if (!urlElement || urlElement === document.body) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urlElement && urlElement.hasAttribute('data-url')) {
|
|
||||||
const url = urlElement.getAttribute('data-url');
|
|
||||||
if (url) {
|
if (url) {
|
||||||
runtime.Browser.OpenURL(url);
|
runtime.Browser.OpenURL(url);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -242,4 +216,4 @@ export const hyperLink: Extension = [
|
|||||||
hyperLinkExtension(),
|
hyperLinkExtension(),
|
||||||
hyperLinkStyle,
|
hyperLinkStyle,
|
||||||
hyperLinkClickHandler
|
hyperLinkClickHandler
|
||||||
];
|
];
|
||||||
|
|||||||
45
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
45
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { blockquote } from './plugins/blockquote';
|
||||||
|
import { codeblock } from './plugins/code-block';
|
||||||
|
import { headings } from './plugins/heading';
|
||||||
|
import { hideMarks } from './plugins/hide-mark';
|
||||||
|
import { image } from './plugins/image';
|
||||||
|
import { links } from './plugins/link';
|
||||||
|
import { lists } from './plugins/list';
|
||||||
|
import { headingSlugField } from './state/heading-slug';
|
||||||
|
import { emoji } from './plugins/emoji';
|
||||||
|
import { horizontalRule } from './plugins/horizontal-rule';
|
||||||
|
import { inlineCode } from './plugins/inline-code';
|
||||||
|
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
||||||
|
import { highlight } from './plugins/highlight';
|
||||||
|
import { insert } from './plugins/insert';
|
||||||
|
import { math } from './plugins/math';
|
||||||
|
import { footnote } from './plugins/footnote';
|
||||||
|
import table from "./plugins/table";
|
||||||
|
import {htmlBlockExtension} from "./plugins/html";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown extensions
|
||||||
|
*/
|
||||||
|
export const markdownExtensions: Extension = [
|
||||||
|
headingSlugField,
|
||||||
|
blockquote(),
|
||||||
|
codeblock(),
|
||||||
|
headings(),
|
||||||
|
hideMarks(),
|
||||||
|
lists(),
|
||||||
|
links(),
|
||||||
|
image(),
|
||||||
|
emoji(),
|
||||||
|
horizontalRule(),
|
||||||
|
inlineCode(),
|
||||||
|
subscriptSuperscript(),
|
||||||
|
highlight(),
|
||||||
|
insert(),
|
||||||
|
math(),
|
||||||
|
footnote(),
|
||||||
|
table(),
|
||||||
|
htmlBlockExtension
|
||||||
|
];
|
||||||
|
|
||||||
|
export default markdownExtensions;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Pre-computed line decoration */
|
||||||
|
const LINE_DECO = Decoration.line({ class: 'cm-blockquote' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blockquote plugin.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Decorates blockquote with left border
|
||||||
|
* - Hides quote marks (>) when cursor is outside
|
||||||
|
* - Supports nested blockquotes
|
||||||
|
*/
|
||||||
|
export function blockquote() {
|
||||||
|
return [blockQuotePlugin, baseTheme];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect blockquote ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectBlockquoteRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(node) {
|
||||||
|
if (node.type.name !== 'Blockquote') return;
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
ranges.push([node.from, node.to]);
|
||||||
|
return false; // Don't recurse into nested
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cursor's blockquote position (-1 if not in any).
|
||||||
|
*/
|
||||||
|
function getCursorBlockquotePos(view: EditorView, ranges: RangeTuple[]): number {
|
||||||
|
const sel = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [sel.from, sel.to];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(selRange, range)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build blockquote decorations for visible viewport.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { pos: number; endPos?: number; deco: Decoration }[] = [];
|
||||||
|
const processedLines = new Set<number>();
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(node) {
|
||||||
|
if (node.type.name !== 'Blockquote') return;
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
|
const inBlock = checkRangeOverlap(
|
||||||
|
[node.from, node.to],
|
||||||
|
[view.state.selection.main.from, view.state.selection.main.to]
|
||||||
|
);
|
||||||
|
if (inBlock) return false;
|
||||||
|
|
||||||
|
// Line decorations
|
||||||
|
const startLine = view.state.doc.lineAt(node.from).number;
|
||||||
|
const endLine = view.state.doc.lineAt(node.to).number;
|
||||||
|
|
||||||
|
for (let i = startLine; i <= endLine; i++) {
|
||||||
|
if (!processedLines.has(i)) {
|
||||||
|
processedLines.add(i);
|
||||||
|
const line = view.state.doc.line(i);
|
||||||
|
items.push({ pos: line.from, deco: LINE_DECO });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide quote marks
|
||||||
|
const cursor = node.node.cursor();
|
||||||
|
cursor.iterate((child) => {
|
||||||
|
if (child.type.name === 'QuoteMark') {
|
||||||
|
items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and build
|
||||||
|
items.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blockquote plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class BlockQuotePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private blockRanges: RangeTuple[] = [];
|
||||||
|
private cursorBlockPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.blockRanges = collectBlockquoteRanges(view);
|
||||||
|
this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.blockRanges = collectBlockquoteRanges(update.view);
|
||||||
|
this.cursorBlockPos = getCursorBlockquotePos(update.view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const newPos = getCursorBlockquotePos(update.view, this.blockRanges);
|
||||||
|
if (newPos !== this.cursorBlockPos) {
|
||||||
|
this.cursorBlockPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for blockquotes.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-blockquote': {
|
||||||
|
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||||
|
color: 'var(--cm-blockquote-color, #666)'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Code block node types in syntax tree */
|
||||||
|
const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']);
|
||||||
|
|
||||||
|
/** Copy button icon SVGs */
|
||||||
|
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||||
|
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||||
|
|
||||||
|
/** Pre-computed line decoration classes */
|
||||||
|
const LINE_DECO_NORMAL = Decoration.line({ class: 'cm-codeblock' });
|
||||||
|
const LINE_DECO_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
|
||||||
|
const LINE_DECO_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
|
||||||
|
const LINE_DECO_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
|
||||||
|
|
||||||
|
/** Code block metadata for widget */
|
||||||
|
interface CodeBlockMeta {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
language: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code block extension with language label and copy button.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Adds background styling to code blocks
|
||||||
|
* - Shows language label + copy button when language is specified
|
||||||
|
* - Hides markers when cursor is outside block
|
||||||
|
* - Optimized with viewport-only rendering and minimal rebuilds
|
||||||
|
*/
|
||||||
|
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget for displaying language label and copy button.
|
||||||
|
* Content is computed lazily on copy action.
|
||||||
|
*/
|
||||||
|
class CodeBlockInfoWidget extends WidgetType {
|
||||||
|
constructor(readonly meta: CodeBlockMeta) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: CodeBlockInfoWidget): boolean {
|
||||||
|
return other.meta.from === this.meta.from &&
|
||||||
|
other.meta.language === this.meta.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView): HTMLElement {
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.className = 'cm-code-block-info';
|
||||||
|
|
||||||
|
if (this.meta.language) {
|
||||||
|
const lang = document.createElement('span');
|
||||||
|
lang.className = 'cm-code-block-lang';
|
||||||
|
lang.textContent = this.meta.language;
|
||||||
|
container.append(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'cm-code-block-copy-btn';
|
||||||
|
btn.title = 'Copy';
|
||||||
|
btn.innerHTML = ICON_COPY;
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.copyContent(view, btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.append(btn);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy content extraction and copy */
|
||||||
|
private copyContent(view: EditorView, btn: HTMLButtonElement): void {
|
||||||
|
const { from, to } = this.meta;
|
||||||
|
const text = view.state.doc.sliceString(from, to);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
btn.innerHTML = ICON_CHECK;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = ICON_COPY;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed code block info from single tree traversal */
|
||||||
|
interface ParsedBlock {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
language: string | null;
|
||||||
|
marks: RangeTuple[]; // CodeMark and CodeInfo positions to hide
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a code block node in a single traversal.
|
||||||
|
* Extracts language and mark positions together.
|
||||||
|
*/
|
||||||
|
function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock {
|
||||||
|
let language: string | null = null;
|
||||||
|
const marks: RangeTuple[] = [];
|
||||||
|
|
||||||
|
node.toTree().iterate({
|
||||||
|
enter: ({ type, from, to }) => {
|
||||||
|
const absFrom = nodeFrom + from;
|
||||||
|
const absTo = nodeFrom + to;
|
||||||
|
|
||||||
|
if (type.name === 'CodeInfo') {
|
||||||
|
language = view.state.doc.sliceString(absFrom, absTo).trim();
|
||||||
|
marks.push([absFrom, absTo]);
|
||||||
|
} else if (type.name === 'CodeMark') {
|
||||||
|
marks.push([absFrom, absTo]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { from: nodeFrom, to: nodeTo, language, marks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which code block the cursor is in (returns block start position, or -1 if not in any).
|
||||||
|
*/
|
||||||
|
function getCursorBlockPosition(view: EditorView, blocks: RangeTuple[]): number {
|
||||||
|
const { ranges } = view.state.selection;
|
||||||
|
for (const sel of ranges) {
|
||||||
|
const selRange: RangeTuple = [sel.from, sel.to];
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (checkRangeOverlap(selRange, block)) {
|
||||||
|
return block[0]; // Return the block's start position as identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all code block ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectCodeBlockRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!CODE_BLOCK_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for visible code blocks.
|
||||||
|
* Uses RangeSetBuilder for efficient sorted construction.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { pos: number; endPos?: number; deco: Decoration; isWidget?: boolean; isReplace?: boolean }[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (!CODE_BLOCK_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Check if cursor is in this block
|
||||||
|
const inBlock = checkRangeOverlap(
|
||||||
|
[nodeFrom, nodeTo],
|
||||||
|
[view.state.selection.main.from, view.state.selection.main.to]
|
||||||
|
);
|
||||||
|
if (inBlock) return;
|
||||||
|
|
||||||
|
// Parse block in single traversal
|
||||||
|
const block = parseCodeBlock(view, nodeFrom, nodeTo, node);
|
||||||
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
|
||||||
|
// Add line decorations
|
||||||
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
|
const line = view.state.doc.line(num);
|
||||||
|
let deco: Decoration;
|
||||||
|
|
||||||
|
if (startLine.number === endLine.number) {
|
||||||
|
deco = LINE_DECO_SINGLE;
|
||||||
|
} else if (num === startLine.number) {
|
||||||
|
deco = LINE_DECO_BEGIN;
|
||||||
|
} else if (num === endLine.number) {
|
||||||
|
deco = LINE_DECO_END;
|
||||||
|
} else {
|
||||||
|
deco = LINE_DECO_NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ pos: line.from, deco });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add info widget
|
||||||
|
const meta: CodeBlockMeta = {
|
||||||
|
from: nodeFrom,
|
||||||
|
to: nodeTo,
|
||||||
|
language: block.language
|
||||||
|
};
|
||||||
|
items.push({
|
||||||
|
pos: startLine.to,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new CodeBlockInfoWidget(meta),
|
||||||
|
side: 1
|
||||||
|
}),
|
||||||
|
isWidget: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide marks
|
||||||
|
for (const [mFrom, mTo] of block.marks) {
|
||||||
|
items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position and add to builder
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.pos !== b.pos) return a.pos - b.pos;
|
||||||
|
// Widgets should come after line decorations at same position
|
||||||
|
return (a.isWidget ? 1 : 0) - (b.isWidget ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isReplace && item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code block plugin with optimized update detection.
|
||||||
|
*/
|
||||||
|
class CodeBlockPluginClass {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private blockRanges: RangeTuple[] = [];
|
||||||
|
private cursorBlockPos = -1; // Which block the cursor is in (-1 = none)
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.blockRanges = collectCodeBlockRanges(view);
|
||||||
|
this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate): void {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
// Always rebuild on doc or viewport change
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.blockRanges = collectCodeBlockRanges(update.view);
|
||||||
|
this.cursorBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selection changes, only rebuild if cursor moves to a different block
|
||||||
|
if (selectionSet) {
|
||||||
|
const newBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
||||||
|
|
||||||
|
if (newBlockPos !== this.cursorBlockPos) {
|
||||||
|
this.cursorBlockPos = newBlockPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for code blocks.
|
||||||
|
*/
|
||||||
|
const baseTheme = 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
196
frontend/src/views/editor/extensions/markdown/plugins/emoji.ts
Normal file
196
frontend/src/views/editor/extensions/markdown/plugins/emoji.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
import { emojies } from '@/common/constant/emojies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji plugin that converts :emoji_name: to actual emoji characters.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Detects emoji patterns like :smile:, :heart:, etc.
|
||||||
|
* - Replaces them with actual emoji characters
|
||||||
|
* - Shows the original text when cursor is nearby
|
||||||
|
* - Optimized with cached matches and minimal rebuilds
|
||||||
|
*/
|
||||||
|
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
||||||
|
|
||||||
|
/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */
|
||||||
|
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji widget with optimized rendering.
|
||||||
|
*/
|
||||||
|
class EmojiWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly emoji: string,
|
||||||
|
readonly name: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: EmojiWidget): boolean {
|
||||||
|
return other.emoji === this.emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-emoji';
|
||||||
|
span.textContent = this.emoji;
|
||||||
|
span.title = `:${this.name}:`;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached emoji match.
|
||||||
|
*/
|
||||||
|
interface EmojiMatch {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all emoji matches in visible ranges.
|
||||||
|
*/
|
||||||
|
function findAllEmojiMatches(view: EditorView): EmojiMatch[] {
|
||||||
|
const matches: EmojiMatch[] = [];
|
||||||
|
const doc = view.state.doc;
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
const text = doc.sliceString(from, to);
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
EMOJI_REGEX.lastIndex = 0;
|
||||||
|
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
||||||
|
const name = match[1].toLowerCase();
|
||||||
|
const emojiChar = emojies[name];
|
||||||
|
|
||||||
|
if (emojiChar) {
|
||||||
|
matches.push({
|
||||||
|
from: from + match.index,
|
||||||
|
to: from + match.index + match[0].length,
|
||||||
|
name,
|
||||||
|
emoji: emojiChar
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which emoji the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (let i = 0; i < matches.length; i++) {
|
||||||
|
if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations from cached matches.
|
||||||
|
*/
|
||||||
|
function buildDecorations(matches: EmojiMatch[], selFrom: number, selTo: number): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
// Skip if cursor overlaps this emoji
|
||||||
|
if (checkRangeOverlap([match.from, match.to], selRange)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
match.from,
|
||||||
|
match.to,
|
||||||
|
Decoration.replace({
|
||||||
|
widget: new EmojiWidget(match.emoji, match.name)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji plugin with cached matches and optimized updates.
|
||||||
|
*/
|
||||||
|
class EmojiPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private matches: EmojiMatch[] = [];
|
||||||
|
private cursorEmojiIdx = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.matches = findAllEmojiMatches(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
// Rebuild matches on doc or viewport change
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.matches = findAllEmojiMatches(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selection changes, only rebuild if cursor enters/leaves an emoji
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
|
||||||
|
if (newIdx !== this.cursorEmojiIdx) {
|
||||||
|
this.cursorEmojiIdx = newIdx;
|
||||||
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for emoji.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-emoji': {
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
cursor: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available emoji names.
|
||||||
|
*/
|
||||||
|
export function getEmojiNames(): string[] {
|
||||||
|
return Object.keys(emojies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emoji by name.
|
||||||
|
*/
|
||||||
|
export function getEmoji(name: string): string | undefined {
|
||||||
|
return emojies[name.toLowerCase()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* Footnote plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders footnote references as superscript numbers/labels
|
||||||
|
* - Renders inline footnotes as superscript numbers with embedded content
|
||||||
|
* - Shows footnote content on hover (tooltip)
|
||||||
|
* - Click to jump between reference and definition
|
||||||
|
* - Hides syntax marks when cursor is outside
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
hoverTooltip,
|
||||||
|
Tooltip,
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FootnoteDefinition {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FootnoteReference {
|
||||||
|
id: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineFootnoteInfo {
|
||||||
|
content: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collected footnote data with O(1) lookup indexes.
|
||||||
|
*/
|
||||||
|
interface FootnoteData {
|
||||||
|
definitions: Map<string, FootnoteDefinition>;
|
||||||
|
references: FootnoteReference[];
|
||||||
|
inlineFootnotes: InlineFootnoteInfo[];
|
||||||
|
referencesByPos: Map<number, FootnoteReference>;
|
||||||
|
inlineByPos: Map<number, InlineFootnoteInfo>;
|
||||||
|
definitionByPos: Map<number, FootnoteDefinition>; // For position-based lookup
|
||||||
|
firstRefById: Map<string, FootnoteReference>;
|
||||||
|
// All footnote ranges for cursor detection
|
||||||
|
allRanges: RangeTuple[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Footnote Collection (cached via closure)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let cachedData: FootnoteData | null = null;
|
||||||
|
let cachedDocLength = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all footnote data from the document.
|
||||||
|
*/
|
||||||
|
function collectFootnotes(state: EditorState): FootnoteData {
|
||||||
|
// Simple cache invalidation based on doc length
|
||||||
|
if (cachedData && cachedDocLength === state.doc.length) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definitions = new Map<string, FootnoteDefinition>();
|
||||||
|
const references: FootnoteReference[] = [];
|
||||||
|
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
||||||
|
const referencesByPos = new Map<number, FootnoteReference>();
|
||||||
|
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
||||||
|
const definitionByPos = new Map<number, FootnoteDefinition>();
|
||||||
|
const firstRefById = new Map<string, FootnoteReference>();
|
||||||
|
const allRanges: RangeTuple[] = [];
|
||||||
|
const seenIds = new Map<string, number>();
|
||||||
|
let inlineIndex = 0;
|
||||||
|
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter: ({ type, from, to, node }) => {
|
||||||
|
if (type.name === 'FootnoteDefinition') {
|
||||||
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
const contentNode = node.getChild('FootnoteDefinitionContent');
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
const content = contentNode
|
||||||
|
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const def: FootnoteDefinition = { id, content, from, to };
|
||||||
|
definitions.set(id, def);
|
||||||
|
definitionByPos.set(from, def);
|
||||||
|
allRanges.push([from, to]);
|
||||||
|
}
|
||||||
|
} else if (type.name === 'FootnoteReference') {
|
||||||
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
seenIds.set(id, seenIds.size + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref: FootnoteReference = {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
index: seenIds.get(id)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
references.push(ref);
|
||||||
|
referencesByPos.set(from, ref);
|
||||||
|
allRanges.push([from, to]);
|
||||||
|
|
||||||
|
if (!firstRefById.has(id)) {
|
||||||
|
firstRefById.set(id, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type.name === 'InlineFootnote') {
|
||||||
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
|
|
||||||
|
if (contentNode) {
|
||||||
|
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
|
||||||
|
inlineIndex++;
|
||||||
|
|
||||||
|
const info: InlineFootnoteInfo = {
|
||||||
|
content,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
index: inlineIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
inlineFootnotes.push(info);
|
||||||
|
inlineByPos.set(from, info);
|
||||||
|
allRanges.push([from, to]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedData = {
|
||||||
|
definitions,
|
||||||
|
references,
|
||||||
|
inlineFootnotes,
|
||||||
|
referencesByPos,
|
||||||
|
inlineByPos,
|
||||||
|
definitionByPos,
|
||||||
|
firstRefById,
|
||||||
|
allRanges,
|
||||||
|
};
|
||||||
|
cachedDocLength = state.doc.length;
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Widgets
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class FootnoteRefWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly id: string,
|
||||||
|
readonly index: number,
|
||||||
|
readonly hasDefinition: boolean
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-footnote-ref';
|
||||||
|
span.textContent = `[${this.index}]`;
|
||||||
|
span.dataset.footnoteId = this.id;
|
||||||
|
|
||||||
|
if (!this.hasDefinition) {
|
||||||
|
span.classList.add('cm-footnote-ref-undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: FootnoteRefWidget): boolean {
|
||||||
|
return this.id === other.id && this.index === other.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InlineFootnoteWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly content: string,
|
||||||
|
readonly index: number
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-inline-footnote-ref';
|
||||||
|
span.textContent = `[${this.index}]`;
|
||||||
|
span.dataset.footnoteContent = this.content;
|
||||||
|
span.dataset.footnoteIndex = String(this.index);
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: InlineFootnoteWidget): boolean {
|
||||||
|
return this.content === other.content && this.index === other.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FootnoteDefLabelWidget extends WidgetType {
|
||||||
|
constructor(readonly id: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-footnote-def-label';
|
||||||
|
span.textContent = `[${this.id}]`;
|
||||||
|
span.dataset.footnoteId = this.id;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: FootnoteDefLabelWidget): boolean {
|
||||||
|
return this.id === other.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cursor Detection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which footnote range the cursor is in (returns start position, -1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorFootnotePos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations using RangeSetBuilder.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView, data: FootnoteData): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { pos: number; endPos?: number; deco: Decoration; priority?: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange);
|
||||||
|
|
||||||
|
// Footnote References
|
||||||
|
if (type.name === 'FootnoteReference') {
|
||||||
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
|
const marks = node.getChildren('FootnoteReferenceMark');
|
||||||
|
|
||||||
|
if (!labelNode || marks.length < 2) return;
|
||||||
|
|
||||||
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
const ref = data.referencesByPos.get(nodeFrom);
|
||||||
|
|
||||||
|
if (!inCursor && ref && ref.id === id) {
|
||||||
|
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
||||||
|
items.push({
|
||||||
|
pos: nodeTo,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new FootnoteRefWidget(id, ref.index, data.definitions.has(id)),
|
||||||
|
side: 1,
|
||||||
|
}),
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footnote Definitions
|
||||||
|
if (type.name === 'FootnoteDefinition') {
|
||||||
|
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||||
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
|
||||||
|
if (!inCursor && marks.length >= 2 && labelNode) {
|
||||||
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
|
||||||
|
items.push({ pos: marks[0].from, endPos: marks[1].to, deco: invisibleDecoration });
|
||||||
|
items.push({
|
||||||
|
pos: marks[1].to,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new FootnoteDefLabelWidget(id),
|
||||||
|
side: 1,
|
||||||
|
}),
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline Footnotes
|
||||||
|
if (type.name === 'InlineFootnote') {
|
||||||
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
|
const marks = node.getChildren('InlineFootnoteMark');
|
||||||
|
|
||||||
|
if (!contentNode || marks.length < 2) return;
|
||||||
|
|
||||||
|
const inlineNote = data.inlineByPos.get(nodeFrom);
|
||||||
|
|
||||||
|
if (!inCursor && inlineNote) {
|
||||||
|
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
||||||
|
items.push({
|
||||||
|
pos: nodeTo,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new InlineFootnoteWidget(inlineNote.content, inlineNote.index),
|
||||||
|
side: 1,
|
||||||
|
}),
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position, widgets after replace at same position
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.pos !== b.pos) return a.pos - b.pos;
|
||||||
|
return (a.priority || 0) - (b.priority || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class FootnotePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private data: FootnoteData;
|
||||||
|
private cursorFootnotePos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.data = collectFootnotes(view.state);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view, this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged) {
|
||||||
|
// Invalidate cache on doc change
|
||||||
|
cachedData = null;
|
||||||
|
this.data = collectFootnotes(update.state);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewportChanged) {
|
||||||
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorFootnotePos) {
|
||||||
|
this.cursorFootnotePos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hover Tooltip
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const footnoteHoverTooltip = hoverTooltip(
|
||||||
|
(view, pos): Tooltip | null => {
|
||||||
|
const data = collectFootnotes(view.state);
|
||||||
|
|
||||||
|
// Check widget elements first
|
||||||
|
const coords = view.coordsAtPos(pos);
|
||||||
|
if (coords) {
|
||||||
|
const target = document.elementFromPoint(coords.left, coords.top) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (target?.classList.contains('cm-footnote-ref')) {
|
||||||
|
const id = target.dataset.footnoteId;
|
||||||
|
if (id) {
|
||||||
|
const def = data.definitions.get(id);
|
||||||
|
if (def) {
|
||||||
|
return {
|
||||||
|
pos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(id, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
||||||
|
const content = target.dataset.footnoteContent;
|
||||||
|
const index = target.dataset.footnoteIndex;
|
||||||
|
if (content && index) {
|
||||||
|
return {
|
||||||
|
pos,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(parseInt(index), content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by position using indexed data
|
||||||
|
const ref = data.referencesByPos.get(pos);
|
||||||
|
if (ref) {
|
||||||
|
const def = data.definitions.get(ref.id);
|
||||||
|
if (def) {
|
||||||
|
return {
|
||||||
|
pos: ref.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(ref.id, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inline = data.inlineByPos.get(pos);
|
||||||
|
if (inline) {
|
||||||
|
return {
|
||||||
|
pos: inline.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(inline.index, inline.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check if pos is within any footnote range
|
||||||
|
for (const ref of data.references) {
|
||||||
|
if (pos >= ref.from && pos <= ref.to) {
|
||||||
|
const def = data.definitions.get(ref.id);
|
||||||
|
if (def) {
|
||||||
|
return {
|
||||||
|
pos: ref.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(ref.id, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inline of data.inlineFootnotes) {
|
||||||
|
if (pos >= inline.from && pos <= inline.to) {
|
||||||
|
return {
|
||||||
|
pos: inline.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(inline.index, inline.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{ hoverTime: 300 }
|
||||||
|
);
|
||||||
|
|
||||||
|
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-footnote-tooltip';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'cm-footnote-tooltip-header';
|
||||||
|
header.textContent = `[^${id}]`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'cm-footnote-tooltip-body';
|
||||||
|
body.textContent = content || '(Empty footnote)';
|
||||||
|
|
||||||
|
dom.appendChild(header);
|
||||||
|
dom.appendChild(body);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-footnote-tooltip';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'cm-footnote-tooltip-header';
|
||||||
|
header.textContent = `Inline Footnote [${index}]`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'cm-footnote-tooltip-body';
|
||||||
|
body.textContent = content || '(Empty footnote)';
|
||||||
|
|
||||||
|
dom.appendChild(header);
|
||||||
|
dom.appendChild(body);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Click Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const footnoteClickHandler = EditorView.domEventHandlers({
|
||||||
|
mousedown(event, view) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Click on footnote reference → jump to definition
|
||||||
|
if (target.classList.contains('cm-footnote-ref')) {
|
||||||
|
const id = target.dataset.footnoteId;
|
||||||
|
if (id) {
|
||||||
|
const data = collectFootnotes(view.state);
|
||||||
|
const def = data.definitions.get(id);
|
||||||
|
if (def) {
|
||||||
|
event.preventDefault();
|
||||||
|
setTimeout(() => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: def.from },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on definition label → jump to first reference
|
||||||
|
if (target.classList.contains('cm-footnote-def-label')) {
|
||||||
|
const id = target.dataset.footnoteId;
|
||||||
|
if (id) {
|
||||||
|
const data = collectFootnotes(view.state);
|
||||||
|
const firstRef = data.firstRefById.get(id);
|
||||||
|
if (firstRef) {
|
||||||
|
event.preventDefault();
|
||||||
|
setTimeout(() => {
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: firstRef.from },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.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',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
'.cm-footnote-ref:hover': {
|
||||||
|
color: 'var(--cm-footnote-hover-color, #1557b0)',
|
||||||
|
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
|
||||||
|
},
|
||||||
|
'.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',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
'.cm-inline-footnote-ref:hover': {
|
||||||
|
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
|
||||||
|
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-footnote-def-label': {
|
||||||
|
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'.cm-footnote-def-label:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-footnote-tooltip': {
|
||||||
|
maxWidth: '400px',
|
||||||
|
padding: '0',
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
'.cm-footnote-tooltip-header': {
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '0.8em',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
},
|
||||||
|
'.cm-footnote-tooltip-body': {
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
||||||
|
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
||||||
|
},
|
||||||
|
'@keyframes cm-footnote-fade-in': {
|
||||||
|
from: { opacity: '0', transform: 'translateY(4px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footnote extension.
|
||||||
|
*/
|
||||||
|
export const footnote = (): Extension => [
|
||||||
|
footnotePlugin,
|
||||||
|
footnoteHoverTooltip,
|
||||||
|
footnoteClickHandler,
|
||||||
|
baseTheme,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default footnote;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get footnote data for external use.
|
||||||
|
*/
|
||||||
|
export function getFootnoteData(state: EditorState): FootnoteData {
|
||||||
|
return collectFootnotes(state);
|
||||||
|
}
|
||||||
168
frontend/src/views/editor/extensions/markdown/plugins/heading.ts
Normal file
168
frontend/src/views/editor/extensions/markdown/plugins/heading.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Hidden mark decoration */
|
||||||
|
const hiddenMarkDecoration = Decoration.mark({
|
||||||
|
class: 'cm-heading-mark-hidden'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all heading ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectHeadingRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(node) {
|
||||||
|
if (!node.type.name.startsWith('ATXHeading') &&
|
||||||
|
!node.type.name.startsWith('SetextHeading')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
ranges.push([node.from, node.to]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which heading the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build heading decorations using RangeSetBuilder.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(node) {
|
||||||
|
// Skip if cursor is in this heading
|
||||||
|
if (checkRangeOverlap([node.from, node.to], selRange)) return;
|
||||||
|
|
||||||
|
// ATX headings (# Heading)
|
||||||
|
if (node.type.name.startsWith('ATXHeading')) {
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
|
const header = node.node.firstChild;
|
||||||
|
if (header && header.type.name === 'HeaderMark') {
|
||||||
|
const markFrom = header.from;
|
||||||
|
// Include the space after #
|
||||||
|
const markTo = Math.min(header.to + 1, node.to);
|
||||||
|
items.push({ from: markFrom, to: markTo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Setext headings (underline style)
|
||||||
|
else if (node.type.name.startsWith('SetextHeading')) {
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
|
const cursor = node.node.cursor();
|
||||||
|
cursor.iterate((child) => {
|
||||||
|
if (child.type.name === 'HeaderMark') {
|
||||||
|
items.push({ from: child.from, to: child.to });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, hiddenMarkDecoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heading plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class HeadingPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private headingRanges: RangeTuple[] = [];
|
||||||
|
private cursorHeadingPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.headingRanges = collectHeadingRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.headingRanges = collectHeadingRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorHeadingPos) {
|
||||||
|
this.cursorHeadingPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme for hidden heading marks.
|
||||||
|
*/
|
||||||
|
const headingTheme = EditorView.baseTheme({
|
||||||
|
'.cm-heading-mark-hidden': {
|
||||||
|
fontSize: '0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headings plugin.
|
||||||
|
*/
|
||||||
|
export const headings = (): Extension => [headingPlugin, headingTheme];
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node types that contain markers to hide.
|
||||||
|
* Note: InlineCode is handled by inline-code.ts
|
||||||
|
*/
|
||||||
|
const TYPES_WITH_MARKS = new Set([
|
||||||
|
'Emphasis',
|
||||||
|
'StrongEmphasis',
|
||||||
|
'Strikethrough'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker node types to hide.
|
||||||
|
*/
|
||||||
|
const MARK_TYPES = new Set([
|
||||||
|
'EmphasisMark',
|
||||||
|
'StrikethroughMark'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Export for external use
|
||||||
|
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
|
||||||
|
export const markTypes = Array.from(MARK_TYPES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all mark ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectMarkRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which mark range the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorMarkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build mark hiding decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this range
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
// Collect mark positions
|
||||||
|
const innerTree = node.toTree();
|
||||||
|
innerTree.iterate({
|
||||||
|
enter({ type: markType, from: markFrom, to: markTo }) {
|
||||||
|
if (!MARK_TYPES.has(markType.name)) return;
|
||||||
|
items.push({
|
||||||
|
from: nodeFrom + markFrom,
|
||||||
|
to: nodeFrom + markTo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, invisibleDecoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide marks plugin with optimized updates.
|
||||||
|
*
|
||||||
|
* Hides emphasis marks (*, **, ~~) when cursor is outside.
|
||||||
|
* Note: InlineCode backticks are handled by inline-code.ts
|
||||||
|
*/
|
||||||
|
class HideMarkPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private markRanges: RangeTuple[] = [];
|
||||||
|
private cursorMarkPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.markRanges = collectMarkRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.markRanges = collectMarkRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorMarkPos) {
|
||||||
|
this.cursorMarkPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide marks plugin.
|
||||||
|
* Hides marks for emphasis, strong, and strikethrough.
|
||||||
|
*/
|
||||||
|
export const hideMarks = (): Extension => [
|
||||||
|
ViewPlugin.fromClass(HideMarkPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
})
|
||||||
|
];
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for highlighted content */
|
||||||
|
const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight plugin using syntax tree.
|
||||||
|
*
|
||||||
|
* Detects ==text== and renders as highlighted text.
|
||||||
|
*/
|
||||||
|
export const highlight = (): Extension => [highlightPlugin, baseTheme];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all highlight ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectHighlightRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'Highlight') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which highlight the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorHighlightPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build highlight decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Highlight') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this highlight
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const marks = node.getChildren('HighlightMark');
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
|
// Hide opening ==
|
||||||
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
|
||||||
|
// Apply highlight style to content
|
||||||
|
const contentStart = marks[0].to;
|
||||||
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
|
if (contentStart < contentEnd) {
|
||||||
|
items.push({ from: contentStart, to: contentEnd, deco: highlightMarkDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide closing ==
|
||||||
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class HighlightPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private highlightRanges: RangeTuple[] = [];
|
||||||
|
private cursorHighlightPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.highlightRanges = collectHighlightRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.highlightRanges = collectHighlightRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorHighlightPos) {
|
||||||
|
this.cursorHighlightPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for highlight.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-highlight': {
|
||||||
|
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal rule plugin that renders beautiful horizontal lines.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
|
||||||
|
* - Shows the original text when cursor is on the line
|
||||||
|
* - Uses inline widget to avoid affecting block system boundaries
|
||||||
|
*/
|
||||||
|
export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display a horizontal rule.
|
||||||
|
*/
|
||||||
|
class HorizontalRuleWidget extends WidgetType {
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-horizontal-rule-widget';
|
||||||
|
|
||||||
|
const hr = document.createElement('hr');
|
||||||
|
hr.className = 'cm-horizontal-rule';
|
||||||
|
span.appendChild(hr);
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(_other: HorizontalRuleWidget) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared widget instance (all HR widgets are identical) */
|
||||||
|
const hrWidget = new HorizontalRuleWidget();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all horizontal rule ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectHRRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'HorizontalRule') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which HR the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorHRPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build horizontal rule decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'HorizontalRule') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is on this HR
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, Decoration.replace({ widget: hrWidget }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal rule plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class HorizontalRulePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private hrRanges: RangeTuple[] = [];
|
||||||
|
private cursorHRPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.hrRanges = collectHRRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.hrRanges = collectHRRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorHRPos) {
|
||||||
|
this.cursorHRPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalRulePlugin = ViewPlugin.fromClass(HorizontalRulePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for horizontal rules.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.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'
|
||||||
|
}
|
||||||
|
});
|
||||||
348
frontend/src/views/editor/extensions/markdown/plugins/html.ts
Normal file
348
frontend/src/views/editor/extensions/markdown/plugins/html.ts
Normal 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 htmlBlockExtension: Extension = [
|
||||||
|
htmlBlockPlugin,
|
||||||
|
htmlTooltipState,
|
||||||
|
clickOutsideHandler,
|
||||||
|
theme
|
||||||
|
];
|
||||||
331
frontend/src/views/editor/extensions/markdown/plugins/image.ts
Normal file
331
frontend/src/views/editor/extensions/markdown/plugins/image.ts
Normal 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
|
||||||
|
];
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for code content */
|
||||||
|
const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline code styling plugin.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Adds background color, border radius, padding to code content
|
||||||
|
* - Hides backtick markers when cursor is outside
|
||||||
|
*/
|
||||||
|
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all inline code ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectCodeRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'InlineCode') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which inline code the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorCodePos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build inline code decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'InlineCode') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip when cursor is in this code
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||||
|
|
||||||
|
// Find backtick boundaries
|
||||||
|
let codeStart = nodeFrom;
|
||||||
|
let codeEnd = nodeTo;
|
||||||
|
|
||||||
|
// Count opening backticks
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length && text[i] === '`') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
codeStart = nodeFrom + i;
|
||||||
|
|
||||||
|
// Count closing backticks
|
||||||
|
let j = text.length - 1;
|
||||||
|
while (j >= 0 && text[j] === '`') {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
codeEnd = nodeFrom + j + 1;
|
||||||
|
|
||||||
|
// Hide opening backticks
|
||||||
|
if (nodeFrom < codeStart) {
|
||||||
|
items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add style to code content
|
||||||
|
if (codeStart < codeEnd) {
|
||||||
|
items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide closing backticks
|
||||||
|
if (codeEnd < nodeTo) {
|
||||||
|
items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline code plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class InlineCodePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private codeRanges: RangeTuple[] = [];
|
||||||
|
private cursorCodePos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.codeRanges = collectCodeRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.codeRanges = collectCodeRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorCodePos) {
|
||||||
|
this.cursorCodePos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for inline code.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-inline-code': {
|
||||||
|
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
fontFamily: 'var(--voidraft-font-mono)'
|
||||||
|
}
|
||||||
|
});
|
||||||
159
frontend/src/views/editor/extensions/markdown/plugins/insert.ts
Normal file
159
frontend/src/views/editor/extensions/markdown/plugins/insert.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for inserted content */
|
||||||
|
const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert plugin using syntax tree.
|
||||||
|
*
|
||||||
|
* Detects ++text++ and renders as inserted text (underline).
|
||||||
|
*/
|
||||||
|
export const insert = (): Extension => [insertPlugin, baseTheme];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all insert ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectInsertRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'Insert') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which insert the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorInsertPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build insert decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Insert') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this insert
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const marks = node.getChildren('InsertMark');
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
|
// Hide opening ++
|
||||||
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
|
||||||
|
// Apply insert style to content
|
||||||
|
const contentStart = marks[0].to;
|
||||||
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
|
if (contentStart < contentEnd) {
|
||||||
|
items.push({ from: contentStart, to: contentEnd, deco: insertMarkDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide closing ++
|
||||||
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class InsertPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private insertRanges: RangeTuple[] = [];
|
||||||
|
private cursorInsertPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.insertRanges = collectInsertRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.insertRanges = collectInsertRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorInsertPos) {
|
||||||
|
this.cursorInsertPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPlugin = ViewPlugin.fromClass(InsertPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for insert.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-insert': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}
|
||||||
|
});
|
||||||
202
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
202
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent node types that should not process.
|
||||||
|
* - Image: handled by image plugin
|
||||||
|
* - LinkReference: reference link definitions should be fully visible
|
||||||
|
*/
|
||||||
|
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links plugin.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Hides link markup when cursor is outside
|
||||||
|
* - Link icons and click events are handled by hyperlink extension
|
||||||
|
*/
|
||||||
|
export const links = (): Extension => [goToLinkPlugin];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link info for tracking.
|
||||||
|
*/
|
||||||
|
interface LinkInfo {
|
||||||
|
parentFrom: number;
|
||||||
|
parentTo: number;
|
||||||
|
urlFrom: number;
|
||||||
|
urlTo: number;
|
||||||
|
marks: { from: number; to: number }[];
|
||||||
|
linkTitle: { from: number; to: number } | null;
|
||||||
|
isAutoLink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all link ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectLinkRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, node }) => {
|
||||||
|
if (type.name !== 'URL') return;
|
||||||
|
|
||||||
|
const parent = node.parent;
|
||||||
|
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||||
|
if (seen.has(parent.from)) return;
|
||||||
|
seen.add(parent.from);
|
||||||
|
|
||||||
|
ranges.push([parent.from, parent.to]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which link the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorLinkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build link decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'URL') return;
|
||||||
|
|
||||||
|
const parent = node.parent;
|
||||||
|
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||||
|
|
||||||
|
// Use parent.from as unique key to handle multiple URLs in same link
|
||||||
|
if (seen.has(parent.from)) return;
|
||||||
|
seen.add(parent.from);
|
||||||
|
|
||||||
|
const marks = parent.getChildren('LinkMark');
|
||||||
|
const linkTitle = parent.getChild('LinkTitle');
|
||||||
|
|
||||||
|
// Find the ']' mark to distinguish link text from URL
|
||||||
|
const closeBracketMark = marks.find((mark) => {
|
||||||
|
const text = view.state.sliceDoc(mark.from, mark.to);
|
||||||
|
return text === ']';
|
||||||
|
});
|
||||||
|
|
||||||
|
// If URL is before ']', it's part of display text, don't hide
|
||||||
|
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cursor overlaps with the parent link
|
||||||
|
if (checkRangeOverlap([parent.from, parent.to], selRange)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide link marks and URL
|
||||||
|
if (marks.length > 0) {
|
||||||
|
for (const mark of marks) {
|
||||||
|
items.push({ from: mark.from, to: mark.to });
|
||||||
|
}
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
|
if (linkTitle) {
|
||||||
|
items.push({ from: linkTitle.from, to: linkTitle.to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle auto-links with < > markers
|
||||||
|
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
|
if (linkContent.startsWith('<') && linkContent.endsWith('>')) {
|
||||||
|
// Already hidden the whole URL above, no extra handling needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
// Deduplicate overlapping ranges
|
||||||
|
let lastTo = -1;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.from >= lastTo) {
|
||||||
|
builder.add(item.from, item.to, invisibleDecoration);
|
||||||
|
lastTo = item.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class LinkPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private linkRanges: RangeTuple[] = [];
|
||||||
|
private cursorLinkPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.linkRanges = collectLinkRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.linkRanges = collectLinkRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorLinkPos) {
|
||||||
|
this.cursorLinkPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
419
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
419
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { Range, RangeSetBuilder, EditorState } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Bullet list marker pattern */
|
||||||
|
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists plugin.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Custom bullet mark rendering (- → •)
|
||||||
|
* - Interactive task list checkboxes
|
||||||
|
*/
|
||||||
|
export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// List Bullet Plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ListBulletWidget extends WidgetType {
|
||||||
|
constructor(readonly bullet: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: ListBulletWidget): boolean {
|
||||||
|
return other.bullet === this.bullet;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-list-bullet';
|
||||||
|
span.textContent = '•';
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all list mark ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectBulletRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'ListMark') return;
|
||||||
|
|
||||||
|
// Skip task list items
|
||||||
|
const parent = node.parent;
|
||||||
|
if (parent?.getChild('Task')) return;
|
||||||
|
|
||||||
|
// Only bullet markers
|
||||||
|
const text = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
|
if (!BULLET_LIST_MARKER_RE.test(text)) return;
|
||||||
|
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which bullet the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorBulletPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build list bullet decorations.
|
||||||
|
*/
|
||||||
|
function buildBulletDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; bullet: string }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'ListMark') return;
|
||||||
|
|
||||||
|
// Skip task list items
|
||||||
|
const parent = node.parent;
|
||||||
|
if (parent?.getChild('Task')) return;
|
||||||
|
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this mark
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const bullet = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
|
if (BULLET_LIST_MARKER_RE.test(bullet)) {
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo, bullet });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, Decoration.replace({
|
||||||
|
widget: new ListBulletWidget(item.bullet)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List bullet plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class ListBulletPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private bulletRanges: RangeTuple[] = [];
|
||||||
|
private cursorBulletPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.bulletRanges = collectBulletRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
|
this.decorations = buildBulletDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.bulletRanges = collectBulletRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
|
this.decorations = buildBulletDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorBulletPos) {
|
||||||
|
this.cursorBulletPos = newPos;
|
||||||
|
this.decorations = buildBulletDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task List Plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class TaskCheckboxWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly checked: boolean,
|
||||||
|
readonly pos: number
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: TaskCheckboxWidget): boolean {
|
||||||
|
return other.checked === this.checked && other.pos === this.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView): HTMLElement {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.className = 'cm-task-checkbox';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.checked = this.checked;
|
||||||
|
checkbox.tabIndex = -1;
|
||||||
|
|
||||||
|
checkbox.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const newValue = !this.checked;
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: this.pos,
|
||||||
|
to: this.pos + 1,
|
||||||
|
insert: newValue ? 'x' : ' '
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(checkbox);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all task ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectTaskRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Task') return;
|
||||||
|
|
||||||
|
const listItem = node.parent;
|
||||||
|
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||||
|
|
||||||
|
const listMark = listItem.getChild('ListMark');
|
||||||
|
if (!listMark) return;
|
||||||
|
|
||||||
|
if (seen.has(listMark.from)) return;
|
||||||
|
seen.add(listMark.from);
|
||||||
|
|
||||||
|
// Track the full range from ListMark to TaskMarker
|
||||||
|
const taskMarker = node.getChild('TaskMarker');
|
||||||
|
if (taskMarker) {
|
||||||
|
ranges.push([listMark.from, taskMarker.to]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which task the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorTaskPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build task list decorations.
|
||||||
|
*/
|
||||||
|
function buildTaskDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration; priority: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: taskFrom, to: taskTo, node }) => {
|
||||||
|
if (type.name !== 'Task') return;
|
||||||
|
|
||||||
|
const listItem = node.parent;
|
||||||
|
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||||
|
|
||||||
|
const listMark = listItem.getChild('ListMark');
|
||||||
|
const taskMarker = node.getChild('TaskMarker');
|
||||||
|
if (!listMark || !taskMarker) return;
|
||||||
|
|
||||||
|
if (seen.has(listMark.from)) return;
|
||||||
|
seen.add(listMark.from);
|
||||||
|
|
||||||
|
const replaceFrom = listMark.from;
|
||||||
|
const replaceTo = taskMarker.to;
|
||||||
|
|
||||||
|
// Skip if cursor is in this range
|
||||||
|
if (checkRangeOverlap([replaceFrom, replaceTo], selRange)) return;
|
||||||
|
|
||||||
|
// Check if task is checked
|
||||||
|
const markerText = view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||||
|
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||||
|
const checkboxPos = taskMarker.from + 1;
|
||||||
|
|
||||||
|
// Add strikethrough for checked items
|
||||||
|
if (isChecked) {
|
||||||
|
items.push({
|
||||||
|
from: taskFrom,
|
||||||
|
to: taskTo,
|
||||||
|
deco: Decoration.mark({ class: 'cm-task-checked' }),
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace "- [x]" or "- [ ]" with checkbox widget
|
||||||
|
items.push({
|
||||||
|
from: replaceFrom,
|
||||||
|
to: replaceTo,
|
||||||
|
deco: Decoration.replace({
|
||||||
|
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
||||||
|
}),
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position, then priority
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.from !== b.from) return a.from - b.from;
|
||||||
|
return a.priority - b.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task list plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class TaskListPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private taskRanges: RangeTuple[] = [];
|
||||||
|
private cursorTaskPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.taskRanges = collectTaskRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
this.decorations = buildTaskDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.taskRanges = collectTaskRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
this.decorations = buildTaskDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorTaskPos) {
|
||||||
|
this.cursorTaskPos = newPos;
|
||||||
|
this.decorations = buildTaskDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskListPlugin = ViewPlugin.fromClass(TaskListPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.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'
|
||||||
|
}
|
||||||
|
});
|
||||||
422
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
422
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Math plugin for CodeMirror using KaTeX.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders inline math $...$ as inline formula
|
||||||
|
* - Renders block math $$...$$ as block formula
|
||||||
|
* - Block math: lines remain, content hidden, formula overlays on top
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||||
|
import { LruCache } from '@/common/utils/lruCache';
|
||||||
|
|
||||||
|
interface KatexCacheValue {
|
||||||
|
html: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LRU cache for KaTeX rendering results.
|
||||||
|
* Key format: "inline:latex" or "block:latex"
|
||||||
|
*/
|
||||||
|
const katexCache = new LruCache<string, KatexCacheValue>(200);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached KaTeX render result or render and cache it.
|
||||||
|
*/
|
||||||
|
function renderKatex(latex: string, displayMode: boolean): KatexCacheValue {
|
||||||
|
const cacheKey = `${displayMode ? 'block' : 'inline'}:${latex}`;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = katexCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render and cache
|
||||||
|
let result: KatexCacheValue;
|
||||||
|
try {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
throwOnError: !displayMode, // inline throws, block doesn't
|
||||||
|
displayMode,
|
||||||
|
output: 'html'
|
||||||
|
});
|
||||||
|
result = { html, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
result = {
|
||||||
|
html: '',
|
||||||
|
error: e instanceof Error ? e.message : 'Render error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
katexCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display inline math formula.
|
||||||
|
* Uses cached KaTeX rendering for performance.
|
||||||
|
*/
|
||||||
|
class InlineMathWidget extends WidgetType {
|
||||||
|
constructor(readonly latex: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'cm-inline-math';
|
||||||
|
|
||||||
|
// Use cached render
|
||||||
|
const { html, error } = renderKatex(this.latex, false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
span.textContent = this.latex;
|
||||||
|
span.title = error;
|
||||||
|
} else {
|
||||||
|
span.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: InlineMathWidget): boolean {
|
||||||
|
return this.latex === other.latex;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display block math formula.
|
||||||
|
* Uses absolute positioning to overlay on source lines.
|
||||||
|
*/
|
||||||
|
class BlockMathWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly latex: string,
|
||||||
|
readonly lineCount: number = 1,
|
||||||
|
readonly lineHeight: number = 22
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'cm-block-math-container';
|
||||||
|
// Set height to cover all source lines
|
||||||
|
const height = this.lineCount * this.lineHeight;
|
||||||
|
container.style.height = `${height}px`;
|
||||||
|
|
||||||
|
const inner = document.createElement('div');
|
||||||
|
inner.className = 'cm-block-math';
|
||||||
|
|
||||||
|
// Use cached render
|
||||||
|
const { html, error } = renderKatex(this.latex, true);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
inner.textContent = this.latex;
|
||||||
|
inner.title = error;
|
||||||
|
} else {
|
||||||
|
inner.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(inner);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: BlockMathWidget): boolean {
|
||||||
|
return this.latex === other.latex && this.lineCount === other.lineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a math region in the document.
|
||||||
|
*/
|
||||||
|
interface MathRegion {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of building decorations, includes math regions for cursor tracking.
|
||||||
|
*/
|
||||||
|
interface BuildResult {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
mathRegions: MathRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the math region containing the given position.
|
||||||
|
* Returns the region index or -1 if not in any region.
|
||||||
|
*/
|
||||||
|
function findMathRegionIndex(pos: number, regions: MathRegion[]): number {
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
if (pos >= regions[i].from && pos <= regions[i].to) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for math formulas.
|
||||||
|
* Also collects math regions for cursor tracking optimization.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): BuildResult {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const mathRegions: MathRegion[] = [];
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
// Handle inline math
|
||||||
|
if (type.name === 'InlineMath') {
|
||||||
|
// Collect math region for cursor tracking
|
||||||
|
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
const marks = node.getChildren('InlineMathMark');
|
||||||
|
|
||||||
|
if (!cursorInRange && marks.length >= 2) {
|
||||||
|
// Get latex content (without $ marks)
|
||||||
|
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||||
|
|
||||||
|
// Hide the entire syntax
|
||||||
|
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||||
|
|
||||||
|
// Add widget at the end
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new InlineMathWidget(latex),
|
||||||
|
side: 1
|
||||||
|
}).range(nodeTo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle block math ($$...$$)
|
||||||
|
if (type.name === 'BlockMath') {
|
||||||
|
// Collect math region for cursor tracking
|
||||||
|
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
|
const marks = node.getChildren('BlockMathMark');
|
||||||
|
|
||||||
|
if (!cursorInRange && marks.length >= 2) {
|
||||||
|
// Get latex content (without $$ marks)
|
||||||
|
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||||
|
|
||||||
|
// Calculate line info
|
||||||
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
const lineCount = endLine.number - startLine.number + 1;
|
||||||
|
const lineHeight = view.defaultLineHeight;
|
||||||
|
|
||||||
|
// Check if block math spans multiple lines
|
||||||
|
const hasLineBreak = lineCount > 1;
|
||||||
|
|
||||||
|
if (hasLineBreak) {
|
||||||
|
// For multi-line: use line decorations to hide content
|
||||||
|
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||||
|
const line = view.state.doc.line(lineNum);
|
||||||
|
decorations.push(
|
||||||
|
Decoration.line({
|
||||||
|
class: 'cm-block-math-line'
|
||||||
|
}).range(line.from)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add widget on the first line (positioned absolutely)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
||||||
|
side: -1
|
||||||
|
}).range(startLine.from)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Single line: make content transparent, overlay widget
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'cm-block-math-content-hidden'
|
||||||
|
}).range(nodeFrom, nodeTo)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add widget at the start (positioned absolutely)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new BlockMathWidget(latex, 1, lineHeight),
|
||||||
|
side: -1
|
||||||
|
}).range(nodeFrom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: Decoration.set(decorations, true),
|
||||||
|
mathRegions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Math plugin with optimized update detection.
|
||||||
|
*/
|
||||||
|
class MathPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private mathRegions: MathRegion[] = [];
|
||||||
|
private lastSelectionHead: number = -1;
|
||||||
|
private lastMathRegionIndex: number = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
const result = buildDecorations(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
|
this.lastSelectionHead = view.state.selection.main.head;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
// Always rebuild on document change or viewport change
|
||||||
|
if (update.docChanged || update.viewportChanged) {
|
||||||
|
const result = buildDecorations(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
|
this.lastSelectionHead = update.state.selection.main.head;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selection changes, only rebuild if cursor changes math region context
|
||||||
|
if (update.selectionSet) {
|
||||||
|
const newHead = update.state.selection.main.head;
|
||||||
|
|
||||||
|
if (newHead !== this.lastSelectionHead) {
|
||||||
|
const newRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
|
||||||
|
|
||||||
|
// Only rebuild if:
|
||||||
|
// 1. Cursor entered a math region (was outside, now inside)
|
||||||
|
// 2. Cursor left a math region (was inside, now outside)
|
||||||
|
// 3. Cursor moved to a different math region
|
||||||
|
if (newRegionIndex !== this.lastMathRegionIndex) {
|
||||||
|
const result = buildDecorations(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSelectionHead = newHead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mathPlugin = ViewPlugin.fromClass(
|
||||||
|
MathPlugin,
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for math.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
// Inline math
|
||||||
|
'.cm-inline-math': {
|
||||||
|
display: 'inline',
|
||||||
|
verticalAlign: 'baseline',
|
||||||
|
},
|
||||||
|
'.cm-inline-math .katex': {
|
||||||
|
fontSize: 'inherit',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Block math container - absolute positioned to overlay on source
|
||||||
|
'.cm-block-math-container': {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '1',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Block math inner
|
||||||
|
'.cm-block-math': {
|
||||||
|
display: 'inline-block',
|
||||||
|
textAlign: 'center',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
'.cm-block-math .katex-display': {
|
||||||
|
margin: '0',
|
||||||
|
},
|
||||||
|
'.cm-block-math .katex': {
|
||||||
|
fontSize: '1.1em',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden line content for block math (text transparent but line preserved)
|
||||||
|
// Use high specificity to override rainbow brackets and other plugins
|
||||||
|
'.cm-line.cm-block-math-line': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
caretColor: 'transparent',
|
||||||
|
},
|
||||||
|
'.cm-line.cm-block-math-line span': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
// Override rainbow brackets in hidden math lines
|
||||||
|
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden content for single-line block math
|
||||||
|
'.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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Math extension.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Parses inline math $...$ and block math $$...$$
|
||||||
|
* - Renders formulas using KaTeX
|
||||||
|
* - Block math preserves line structure, overlays rendered formula
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
*/
|
||||||
|
export const math = (): Extension => [
|
||||||
|
mathPlugin,
|
||||||
|
baseTheme
|
||||||
|
];
|
||||||
|
|
||||||
|
export default math;
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Pre-computed mark decorations */
|
||||||
|
const superscriptMarkDecoration = Decoration.mark({ class: 'cm-superscript' });
|
||||||
|
const subscriptMarkDecoration = Decoration.mark({ class: 'cm-subscript' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscript and Superscript plugin using syntax tree.
|
||||||
|
*
|
||||||
|
* - Superscript: ^text^ → renders as superscript
|
||||||
|
* - Subscript: ~text~ → renders as subscript
|
||||||
|
*
|
||||||
|
* Note: Inline footnotes ^[content] are handled by the Footnote extension.
|
||||||
|
*/
|
||||||
|
export const subscriptSuperscript = (): Extension => [
|
||||||
|
subscriptSuperscriptPlugin,
|
||||||
|
baseTheme
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Node types to handle */
|
||||||
|
const SCRIPT_TYPES = new Set(['Superscript', 'Subscript']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all superscript/subscript ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectScriptRanges(view: EditorView): RangeTuple[] {
|
||||||
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!SCRIPT_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which script element the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorScriptPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for subscript and superscript.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (!SCRIPT_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this element
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const isSuperscript = type.name === 'Superscript';
|
||||||
|
const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark';
|
||||||
|
const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration;
|
||||||
|
|
||||||
|
const marks = node.getChildren(markName);
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
|
// Hide opening mark
|
||||||
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
|
||||||
|
// Apply style to content
|
||||||
|
const contentStart = marks[0].to;
|
||||||
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
|
if (contentStart < contentEnd) {
|
||||||
|
items.push({ from: contentStart, to: contentEnd, deco: contentDeco });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide closing mark
|
||||||
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscript/Superscript plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class SubscriptSuperscriptPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private scriptRanges: RangeTuple[] = [];
|
||||||
|
private cursorScriptPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.scriptRanges = collectScriptRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.scriptRanges = collectScriptRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorScriptPos) {
|
||||||
|
this.cursorScriptPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
||||||
|
SubscriptSuperscriptPlugin,
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for subscript and superscript.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
'.cm-superscript': {
|
||||||
|
verticalAlign: 'super',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
color: 'var(--cm-superscript-color, inherit)'
|
||||||
|
},
|
||||||
|
'.cm-subscript': {
|
||||||
|
verticalAlign: 'sub',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
color: 'var(--cm-subscript-color, inherit)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
833
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
833
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
/**
|
||||||
|
* Table plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders markdown tables as beautiful HTML tables
|
||||||
|
* - Lines remain, content hidden, table overlays on top (same as math.ts)
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
* - Supports alignment (left, center, right)
|
||||||
|
*
|
||||||
|
* Table syntax tree structure from @lezer/markdown:
|
||||||
|
* - Table (root)
|
||||||
|
* - TableHeader (first row)
|
||||||
|
* - TableDelimiter (|)
|
||||||
|
* - TableCell (content)
|
||||||
|
* - TableDelimiter (separator row |---|---|)
|
||||||
|
* - TableRow (data rows)
|
||||||
|
* - TableCell (content)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
import { syntaxTree, foldedRanges } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { isCursorInRange } from '../util';
|
||||||
|
import { LruCache } from '@/common/utils/lruCache';
|
||||||
|
import { generateContentHash } from '@/common/utils/hashUtils';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types and Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Cell alignment type */
|
||||||
|
type CellAlign = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
/** Parsed table data */
|
||||||
|
interface TableData {
|
||||||
|
headers: string[];
|
||||||
|
alignments: CellAlign[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table range info for tracking */
|
||||||
|
interface TableRange {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache using LruCache from utils
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** LRU cache for parsed table data - keyed by position for fast lookup */
|
||||||
|
const tableCacheByPos = new LruCache<string, { hash: string; data: TableData }>(50);
|
||||||
|
|
||||||
|
/** LRU cache for inline markdown rendering */
|
||||||
|
const inlineRenderCache = new LruCache<string, string>(200);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or parse table data with two-level caching.
|
||||||
|
* First checks position, then verifies content hash only if position matches.
|
||||||
|
* This avoids expensive hash computation on cache miss.
|
||||||
|
*/
|
||||||
|
function getCachedTableData(
|
||||||
|
state: import('@codemirror/state').EditorState,
|
||||||
|
tableNode: SyntaxNode
|
||||||
|
): TableData | null {
|
||||||
|
const posKey = `${tableNode.from}-${tableNode.to}`;
|
||||||
|
|
||||||
|
// First level: check if we have data for this position
|
||||||
|
const cached = tableCacheByPos.get(posKey);
|
||||||
|
if (cached) {
|
||||||
|
// Second level: verify content hash matches (lazy hash computation)
|
||||||
|
const content = state.sliceDoc(tableNode.from, tableNode.to);
|
||||||
|
const contentHash = generateContentHash(content);
|
||||||
|
if (cached.hash === contentHash) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - parse and cache
|
||||||
|
const content = state.sliceDoc(tableNode.from, tableNode.to);
|
||||||
|
const data = parseTableData(state, tableNode);
|
||||||
|
if (data) {
|
||||||
|
tableCacheByPos.set(posKey, {
|
||||||
|
hash: generateContentHash(content),
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Parsing Functions (Optimized)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse alignment from delimiter row.
|
||||||
|
* Optimized: early returns, minimal string operations.
|
||||||
|
*/
|
||||||
|
function parseAlignment(delimiterText: string): CellAlign {
|
||||||
|
const len = delimiterText.length;
|
||||||
|
if (len === 0) return 'left';
|
||||||
|
|
||||||
|
// Find first and last non-space characters
|
||||||
|
let start = 0;
|
||||||
|
let end = len - 1;
|
||||||
|
while (start < len && delimiterText.charCodeAt(start) === 32) start++;
|
||||||
|
while (end > start && delimiterText.charCodeAt(end) === 32) end--;
|
||||||
|
|
||||||
|
if (start > end) return 'left';
|
||||||
|
|
||||||
|
const hasLeftColon = delimiterText.charCodeAt(start) === 58; // ':'
|
||||||
|
const hasRightColon = delimiterText.charCodeAt(end) === 58;
|
||||||
|
|
||||||
|
if (hasLeftColon && hasRightColon) return 'center';
|
||||||
|
if (hasRightColon) return 'right';
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a row text into cells by splitting on |
|
||||||
|
* Optimized: single-pass parsing without multiple string operations.
|
||||||
|
*/
|
||||||
|
function parseRowText(rowText: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
const len = rowText.length;
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = len;
|
||||||
|
|
||||||
|
// Skip leading whitespace
|
||||||
|
while (start < len && rowText.charCodeAt(start) <= 32) start++;
|
||||||
|
// Skip trailing whitespace
|
||||||
|
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
|
||||||
|
|
||||||
|
// Skip leading |
|
||||||
|
if (start < end && rowText.charCodeAt(start) === 124) start++;
|
||||||
|
// Skip trailing |
|
||||||
|
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
|
||||||
|
|
||||||
|
// Parse cells in single pass
|
||||||
|
let cellStart = start;
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (i === end || rowText.charCodeAt(i) === 124) {
|
||||||
|
// Extract and trim cell
|
||||||
|
let cs = cellStart;
|
||||||
|
let ce = i;
|
||||||
|
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
|
||||||
|
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
|
||||||
|
cells.push(rowText.substring(cs, ce));
|
||||||
|
cellStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse table data from syntax tree node.
|
||||||
|
*
|
||||||
|
* Table syntax tree structure from @lezer/markdown:
|
||||||
|
* - Table (root)
|
||||||
|
* - TableHeader (contains TableCell children)
|
||||||
|
* - TableDelimiter (the |---|---| line)
|
||||||
|
* - TableRow (contains TableCell children)
|
||||||
|
*/
|
||||||
|
function parseTableData(state: import('@codemirror/state').EditorState, tableNode: SyntaxNode): TableData | null {
|
||||||
|
const headers: string[] = [];
|
||||||
|
const alignments: CellAlign[] = [];
|
||||||
|
const rows: string[][] = [];
|
||||||
|
|
||||||
|
// Get TableHeader
|
||||||
|
const headerNode = tableNode.getChild('TableHeader');
|
||||||
|
if (!headerNode) return null;
|
||||||
|
|
||||||
|
// Get TableCell children from header
|
||||||
|
const headerCells = headerNode.getChildren('TableCell');
|
||||||
|
|
||||||
|
if (headerCells.length > 0) {
|
||||||
|
// Parse from TableCell nodes
|
||||||
|
for (const cell of headerCells) {
|
||||||
|
const text = state.sliceDoc(cell.from, cell.to).trim();
|
||||||
|
headers.push(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: parse the entire header row text
|
||||||
|
const headerText = state.sliceDoc(headerNode.from, headerNode.to);
|
||||||
|
const parsedHeaders = parseRowText(headerText);
|
||||||
|
headers.push(...parsedHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers.length === 0) return null;
|
||||||
|
|
||||||
|
// Find delimiter row to get alignments
|
||||||
|
// The delimiter is a direct child of Table
|
||||||
|
let child = tableNode.firstChild;
|
||||||
|
while (child) {
|
||||||
|
if (child.type.name === 'TableDelimiter') {
|
||||||
|
const delimText = state.sliceDoc(child.from, child.to);
|
||||||
|
// Check if this contains --- (alignment row)
|
||||||
|
if (delimText.includes('-')) {
|
||||||
|
const parts = parseRowText(delimText);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('-')) {
|
||||||
|
alignments.push(parseAlignment(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child = child.nextSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing alignments with 'left'
|
||||||
|
while (alignments.length < headers.length) {
|
||||||
|
alignments.push('left');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data rows
|
||||||
|
const rowNodes = tableNode.getChildren('TableRow');
|
||||||
|
|
||||||
|
for (const rowNode of rowNodes) {
|
||||||
|
const rowData: string[] = [];
|
||||||
|
const cells = rowNode.getChildren('TableCell');
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
// Parse from TableCell nodes
|
||||||
|
for (const cell of cells) {
|
||||||
|
const text = state.sliceDoc(cell.from, cell.to).trim();
|
||||||
|
rowData.push(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: parse the entire row text
|
||||||
|
const rowText = state.sliceDoc(rowNode.from, rowNode.to);
|
||||||
|
const parsedCells = parseRowText(rowText);
|
||||||
|
rowData.push(...parsedCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing cells with empty string
|
||||||
|
while (rowData.length < headers.length) {
|
||||||
|
rowData.push('');
|
||||||
|
}
|
||||||
|
rows.push(rowData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, alignments, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Pre-compiled regex patterns for better performance
|
||||||
|
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||||
|
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||||
|
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||||
|
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
||||||
|
const CODE_RE = /`([^`]+)`/g;
|
||||||
|
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const STRIKE_RE = /~~(.+?)~~/g;
|
||||||
|
|
||||||
|
// Regex to detect HTML tags (opening, closing, or self-closing)
|
||||||
|
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content with DOMPurify.
|
||||||
|
*/
|
||||||
|
function sanitizeHTML(html: string): string {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ADD_TAGS: ['code', 'strong', 'em', 'del', 'a', 'img', 'br', 'span'],
|
||||||
|
ADD_ATTR: ['href', 'target', 'src', 'alt', 'class', 'style'],
|
||||||
|
ALLOW_DATA_ATTR: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert inline markdown syntax to HTML.
|
||||||
|
* Handles: **bold**, *italic*, `code`, [link](url), ~~strikethrough~~, and HTML tags
|
||||||
|
* Optimized with pre-compiled regex and LRU caching.
|
||||||
|
*/
|
||||||
|
function renderInlineMarkdown(text: string): string {
|
||||||
|
// Check cache first
|
||||||
|
const cached = inlineRenderCache.get(text);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
let html = text;
|
||||||
|
|
||||||
|
// Check if text contains HTML tags
|
||||||
|
const hasHTMLTags = HTML_TAG_RE.test(text);
|
||||||
|
|
||||||
|
if (hasHTMLTags) {
|
||||||
|
// If contains HTML tags, process markdown first without escaping < >
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
|
||||||
|
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_ (but not inside words for _)
|
||||||
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
|
||||||
|
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code: `code` - but don't double-process if already has <code>
|
||||||
|
if (!html.includes('<code>')) {
|
||||||
|
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
html = html.replace(STRIKE_RE, '<del>$1</del>');
|
||||||
|
|
||||||
|
// Sanitize HTML for security
|
||||||
|
html = sanitizeHTML(html);
|
||||||
|
} else {
|
||||||
|
// No HTML tags - escape < > and process markdown
|
||||||
|
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
|
||||||
|
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_ (but not inside words for _)
|
||||||
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
|
||||||
|
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code: `code`
|
||||||
|
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
html = html.replace(STRIKE_RE, '<del>$1</del>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result using LRU cache
|
||||||
|
inlineRenderCache.set(text, html);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display rendered table.
|
||||||
|
* Uses absolute positioning to overlay on source lines.
|
||||||
|
* Optimized with innerHTML for faster DOM creation.
|
||||||
|
*/
|
||||||
|
class TableWidget extends WidgetType {
|
||||||
|
// Cache the generated HTML to avoid regenerating on each toDOM call
|
||||||
|
private cachedHTML: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly tableData: TableData,
|
||||||
|
readonly lineCount: number,
|
||||||
|
readonly lineHeight: number,
|
||||||
|
readonly visualHeight: number,
|
||||||
|
readonly contentWidth: number
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build table HTML string (much faster than DOM API for large tables).
|
||||||
|
*/
|
||||||
|
private buildTableHTML(): string {
|
||||||
|
if (this.cachedHTML) return this.cachedHTML;
|
||||||
|
|
||||||
|
// Calculate row heights
|
||||||
|
const headerRatio = 2 / this.lineCount;
|
||||||
|
const dataRowRatio = 1 / this.lineCount;
|
||||||
|
const headerHeight = this.visualHeight * headerRatio;
|
||||||
|
const dataRowHeight = this.visualHeight * dataRowRatio;
|
||||||
|
|
||||||
|
// Build header cells
|
||||||
|
const headerCells = this.tableData.headers.map((header, idx) => {
|
||||||
|
const align = this.tableData.alignments[idx] || 'left';
|
||||||
|
const escapedTitle = header.replace(/"/g, '"');
|
||||||
|
return `<th class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(header)}</th>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Build body rows
|
||||||
|
const bodyRows = this.tableData.rows.map(row => {
|
||||||
|
const cells = row.map((cell, idx) => {
|
||||||
|
const align = this.tableData.alignments[idx] || 'left';
|
||||||
|
const escapedTitle = cell.replace(/"/g, '"');
|
||||||
|
return `<td class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(cell)}</td>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr style="height:${dataRowHeight}px">${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
this.cachedHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
||||||
|
return this.cachedHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'cm-table-container';
|
||||||
|
container.style.height = `${this.visualHeight}px`;
|
||||||
|
|
||||||
|
const tableWrapper = document.createElement('div');
|
||||||
|
tableWrapper.className = 'cm-table-wrapper';
|
||||||
|
tableWrapper.style.maxWidth = `${this.contentWidth}px`;
|
||||||
|
tableWrapper.style.maxHeight = `${this.visualHeight}px`;
|
||||||
|
|
||||||
|
// Use innerHTML for faster DOM creation (single parse vs many createElement calls)
|
||||||
|
tableWrapper.innerHTML = this.buildTableHTML();
|
||||||
|
|
||||||
|
container.appendChild(tableWrapper);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: TableWidget): boolean {
|
||||||
|
// Quick dimension checks first (most likely to differ)
|
||||||
|
if (this.visualHeight !== other.visualHeight ||
|
||||||
|
this.contentWidth !== other.contentWidth ||
|
||||||
|
this.lineCount !== other.lineCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use reference equality for tableData if same object
|
||||||
|
if (this.tableData === other.tableData) return true;
|
||||||
|
|
||||||
|
// Quick length checks
|
||||||
|
const headers1 = this.tableData.headers;
|
||||||
|
const headers2 = other.tableData.headers;
|
||||||
|
const rows1 = this.tableData.rows;
|
||||||
|
const rows2 = other.tableData.rows;
|
||||||
|
|
||||||
|
if (headers1.length !== headers2.length || rows1.length !== rows2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare headers (usually short)
|
||||||
|
for (let i = 0, len = headers1.length; i < len; i++) {
|
||||||
|
if (headers1[i] !== headers2[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare rows
|
||||||
|
for (let i = 0, rowLen = rows1.length; i < rowLen; i++) {
|
||||||
|
const row1 = rows1[i];
|
||||||
|
const row2 = rows2[i];
|
||||||
|
if (row1.length !== row2.length) return false;
|
||||||
|
for (let j = 0, cellLen = row1.length; j < cellLen; j++) {
|
||||||
|
if (row1[j] !== row2[j]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a range overlaps with any folded region.
|
||||||
|
*/
|
||||||
|
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||||
|
const folded = foldedRanges(view.state);
|
||||||
|
const cursor = folded.iter();
|
||||||
|
while (cursor.value) {
|
||||||
|
// Check if ranges overlap
|
||||||
|
if (cursor.from < to && cursor.to > from) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cursor.next();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of building decorations - includes both decorations and table ranges */
|
||||||
|
interface BuildResult {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
tableRanges: TableRange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for tables and collect table ranges in a single pass.
|
||||||
|
* Optimized: single syntax tree traversal instead of two separate ones.
|
||||||
|
*/
|
||||||
|
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const tableRanges: TableRange[] = [];
|
||||||
|
const contentWidth = view.contentDOM.clientWidth - 10;
|
||||||
|
const lineHeight = view.defaultLineHeight;
|
||||||
|
|
||||||
|
// Pre-create the line decoration to reuse (same class for all hidden lines)
|
||||||
|
const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Table') return;
|
||||||
|
|
||||||
|
// Always collect table ranges for selection tracking
|
||||||
|
tableRanges.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
|
// Skip rendering if table is in a folded region
|
||||||
|
if (isInFoldedRange(view, nodeFrom, nodeTo)) return;
|
||||||
|
|
||||||
|
// Skip rendering if cursor/selection is in table range
|
||||||
|
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||||
|
|
||||||
|
// Get cached or parse table data
|
||||||
|
const tableData = getCachedTableData(view.state, node);
|
||||||
|
if (!tableData) return;
|
||||||
|
|
||||||
|
// Calculate line info
|
||||||
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
const lineCount = endLine.number - startLine.number + 1;
|
||||||
|
|
||||||
|
// Get visual height using lineBlockAt (includes wrapped lines)
|
||||||
|
const startBlock = view.lineBlockAt(nodeFrom);
|
||||||
|
const endBlock = view.lineBlockAt(nodeTo);
|
||||||
|
const visualHeight = endBlock.bottom - startBlock.top;
|
||||||
|
|
||||||
|
// Add line decorations to hide content (reuse decoration object)
|
||||||
|
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||||
|
const line = view.state.doc.line(lineNum);
|
||||||
|
decorations.push(hiddenLineDecoration.range(line.from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add widget on the first line (positioned absolutely)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new TableWidget(tableData, lineCount, lineHeight, visualHeight, contentWidth),
|
||||||
|
side: -1
|
||||||
|
}).range(startLine.from)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: Decoration.set(decorations, true),
|
||||||
|
tableRanges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which table the selection is in (if any).
|
||||||
|
* Returns table index or -1 if not in any table.
|
||||||
|
* Optimized: early exit on first match.
|
||||||
|
*/
|
||||||
|
function findSelectionTableIndex(
|
||||||
|
selectionRanges: readonly { from: number; to: number }[],
|
||||||
|
tableRanges: TableRange[]
|
||||||
|
): number {
|
||||||
|
// Early exit if no tables
|
||||||
|
if (tableRanges.length === 0) return -1;
|
||||||
|
|
||||||
|
for (const sel of selectionRanges) {
|
||||||
|
const selFrom = sel.from;
|
||||||
|
const selTo = sel.to;
|
||||||
|
for (let i = 0; i < tableRanges.length; i++) {
|
||||||
|
const table = tableRanges[i];
|
||||||
|
// Inline overlap check (avoid function call overhead)
|
||||||
|
if (selFrom <= table.to && table.from <= selTo) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table plugin with optimized update detection.
|
||||||
|
*
|
||||||
|
* Performance optimizations:
|
||||||
|
* - Single syntax tree traversal (buildDecorationsAndRanges)
|
||||||
|
* - Tracks table ranges to minimize unnecessary rebuilds
|
||||||
|
* - Only rebuilds when selection enters/exits table OR switches between tables
|
||||||
|
* - Detects both cursor position AND selection range changes
|
||||||
|
*/
|
||||||
|
class TablePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private tableRanges: TableRange[] = [];
|
||||||
|
private lastContentWidth: number = 0;
|
||||||
|
// Track last selection state for comparison
|
||||||
|
private lastSelectionFrom: number = -1;
|
||||||
|
private lastSelectionTo: number = -1;
|
||||||
|
// Track which table the selection is in (-1 = not in any table)
|
||||||
|
private lastTableIndex: number = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
this.lastContentWidth = view.contentDOM.clientWidth;
|
||||||
|
// Initialize selection tracking
|
||||||
|
const mainSel = view.state.selection.main;
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(view.state.selection.ranges, this.tableRanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const view = update.view;
|
||||||
|
const currentContentWidth = view.contentDOM.clientWidth;
|
||||||
|
|
||||||
|
// Check if content width changed (requires rebuild for proper sizing)
|
||||||
|
const widthChanged = Math.abs(currentContentWidth - this.lastContentWidth) > 1;
|
||||||
|
if (widthChanged) {
|
||||||
|
this.lastContentWidth = currentContentWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full rebuild needed for:
|
||||||
|
// - Document changes (table content may have changed)
|
||||||
|
// - Viewport changes (new tables may be visible)
|
||||||
|
// - Geometry changes (folding, line height changes)
|
||||||
|
// - Width changes (table needs resizing)
|
||||||
|
if (update.docChanged || update.viewportChanged || update.geometryChanged || widthChanged) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
// Update selection tracking
|
||||||
|
const mainSel = update.state.selection.main;
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selection changes, check if selection moved in/out of a table OR between tables
|
||||||
|
if (update.selectionSet) {
|
||||||
|
const mainSel = update.state.selection.main;
|
||||||
|
const selectionChanged = mainSel.from !== this.lastSelectionFrom ||
|
||||||
|
mainSel.to !== this.lastSelectionTo;
|
||||||
|
|
||||||
|
if (selectionChanged) {
|
||||||
|
// Find which table (if any) the selection is now in
|
||||||
|
const currentTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
|
||||||
|
// Rebuild if selection moved to a different table (including in/out)
|
||||||
|
if (currentTableIndex !== this.lastTableIndex) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
// Re-check after rebuild (table ranges may have changed)
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
} else {
|
||||||
|
this.lastTableIndex = currentTableIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracking state
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tablePlugin = ViewPlugin.fromClass(
|
||||||
|
TablePlugin,
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for tables.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
// Table container - same as math.ts
|
||||||
|
'.cm-table-container': {
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '2',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Table wrapper - scrollable when needed
|
||||||
|
'.cm-table-wrapper': {
|
||||||
|
display: 'inline-block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Table styles - use inset box-shadow for outer border (not clipped by overflow)
|
||||||
|
'.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',
|
||||||
|
// Prevent text wrapping to maintain row height
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '300px',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data cells: left divider + bottom divider
|
||||||
|
'.cm-table td': {
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// First column data cells: only bottom divider
|
||||||
|
'.cm-table td:first-child': {
|
||||||
|
boxShadow: '0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last row data cells: only left divider (no bottom)
|
||||||
|
'.cm-table tbody tr:last-child td': {
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last row first column: no dividers
|
||||||
|
'.cm-table tbody tr:last-child td:first-child': {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th': {
|
||||||
|
backgroundColor: 'var(--cm-table-header-bg)',
|
||||||
|
fontWeight: '600',
|
||||||
|
// Header cells: left divider + bottom divider
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th:first-child': {
|
||||||
|
// First header cell: only bottom divider
|
||||||
|
boxShadow: '0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table tbody tr:hover': {
|
||||||
|
backgroundColor: 'var(--cm-table-row-hover)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alignment classes - use higher specificity to override default
|
||||||
|
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': {
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inline elements in table cells
|
||||||
|
'.cm-table code': {
|
||||||
|
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'var(--voidraft-font-mono)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table a': {
|
||||||
|
color: 'var(--selection-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table a:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden line content for table (text transparent but line preserved)
|
||||||
|
// Use high specificity to override rainbow brackets and other plugins
|
||||||
|
'.cm-line.cm-table-line-hidden': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
caretColor: 'transparent',
|
||||||
|
},
|
||||||
|
'.cm-line.cm-table-line-hidden span': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
// Override rainbow brackets in hidden table lines
|
||||||
|
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table extension.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Parses markdown tables using syntax tree
|
||||||
|
* - Renders tables as beautiful HTML tables
|
||||||
|
* - Table preserves line structure, overlays rendered table
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
*/
|
||||||
|
export const table = (): Extension => [
|
||||||
|
tablePlugin,
|
||||||
|
baseTheme
|
||||||
|
];
|
||||||
|
|
||||||
|
export default table;
|
||||||
|
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
259
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
259
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal 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;
|
||||||
202
frontend/src/views/editor/extensions/markdown/util.ts
Normal file
202
frontend/src/views/editor/extensions/markdown/util.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
// 只有有效的 URL(http/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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Markdown 预览面板相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 预览面板状态
|
|
||||||
export interface PreviewState {
|
|
||||||
documentId: number; // 预览所属的文档ID
|
|
||||||
blockFrom: number;
|
|
||||||
blockTo: number;
|
|
||||||
closing?: boolean; // 标记面板正在关闭
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
|||||||
decorations: (v) => v.decorations,
|
decorations: (v) => v.decorations,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function rainbowBracketsExtension() {
|
export default function index() {
|
||||||
return [
|
return [
|
||||||
rainbowBracketsPlugin,
|
rainbowBracketsPlugin,
|
||||||
EditorView.baseTheme({
|
EditorView.baseTheme({
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
export const spellcheck = (): Extension => {
|
||||||
|
return EditorView.contentAttributes.of({
|
||||||
|
spellcheck: 'true',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, nextTick, onUnmounted, ref, watch} from 'vue';
|
||||||
|
import {translatorManager} from './manager';
|
||||||
|
import {useTranslationStore} from '@/stores/translationStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
portalTarget?: HTMLElement | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const state = translatorManager.useState();
|
||||||
|
const translationStore = useTranslationStore();
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const isVisible = computed(() => state.value.visible);
|
||||||
|
const sourceText = computed(() => state.value.sourceText);
|
||||||
|
const position = computed(() => state.value.position);
|
||||||
|
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||||
|
|
||||||
|
const sourceLangSelector = ref('');
|
||||||
|
const targetLangSelector = ref('');
|
||||||
|
const translatorSelector = ref('');
|
||||||
|
const translatedText = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// 监听可见性变化
|
||||||
|
watch(isVisible, async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
adjustedPosition.value = { ...position.value };
|
||||||
|
await nextTick();
|
||||||
|
adjustDialogPosition();
|
||||||
|
await initializeTranslation();
|
||||||
|
await nextTick();
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
isDragging.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogStyle = computed(() => ({
|
||||||
|
left: `${adjustedPosition.value.x}px`,
|
||||||
|
top: `${adjustedPosition.value.y}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const availableLanguages = computed(() => {
|
||||||
|
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||||
|
if (!languageMap) return [];
|
||||||
|
return Object.entries(languageMap).map(([code, info]: [string, any]) => ({
|
||||||
|
code,
|
||||||
|
name: info.Name || info.name || code
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableTranslators = computed(() => translationStore.translators);
|
||||||
|
|
||||||
|
function adjustDialogPosition() {
|
||||||
|
const dialogEl = dialogRef.value;
|
||||||
|
const container = props.portalTarget;
|
||||||
|
if (!dialogEl || !container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const dialogRect = dialogEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
let x = adjustedPosition.value.x;
|
||||||
|
let y = adjustedPosition.value.y;
|
||||||
|
|
||||||
|
// 限制在容器范围内
|
||||||
|
x = Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width - 8));
|
||||||
|
y = Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height - 8));
|
||||||
|
|
||||||
|
adjustedPosition.value = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPosition(x: number, y: number) {
|
||||||
|
const container = props.portalTarget;
|
||||||
|
const dialogEl = dialogRef.value;
|
||||||
|
if (!container || !dialogEl) return { x, y };
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const dialogRect = dialogEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width)),
|
||||||
|
y: Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeTranslation() {
|
||||||
|
isLoading.value = true;
|
||||||
|
translatedText.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadTranslators();
|
||||||
|
await translate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize translation:', error);
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTranslators() {
|
||||||
|
const translators = translationStore.translators;
|
||||||
|
if (translators.length > 0) {
|
||||||
|
translatorSelector.value = translators[0];
|
||||||
|
}
|
||||||
|
resetLanguageSelectors();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLanguageSelectors() {
|
||||||
|
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||||
|
if (!languageMap) return;
|
||||||
|
|
||||||
|
const languages = Object.keys(languageMap);
|
||||||
|
if (languages.length > 0) {
|
||||||
|
sourceLangSelector.value = languages[0];
|
||||||
|
targetLangSelector.value = languages[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTranslatorChange() {
|
||||||
|
resetLanguageSelectors();
|
||||||
|
translate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapLanguages() {
|
||||||
|
const temp = sourceLangSelector.value;
|
||||||
|
sourceLangSelector.value = targetLangSelector.value;
|
||||||
|
targetLangSelector.value = temp;
|
||||||
|
translate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translate() {
|
||||||
|
const sourceLang = sourceLangSelector.value;
|
||||||
|
const targetLang = targetLangSelector.value;
|
||||||
|
const translatorType = translatorSelector.value;
|
||||||
|
|
||||||
|
if (!sourceLang || !targetLang || !translatorType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
translatedText.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await translationStore.translateText(
|
||||||
|
sourceText.value,
|
||||||
|
sourceLang,
|
||||||
|
targetLang,
|
||||||
|
translatorType
|
||||||
|
);
|
||||||
|
|
||||||
|
translatedText.value = result.translatedText || result.error || '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Translation failed:', err);
|
||||||
|
translatedText.value = 'Translation failed';
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('select, button')) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const rect = dialogRef.value!.getBoundingClientRect();
|
||||||
|
dragStart.value = {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
};
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
document.addEventListener('mousemove', onDrag);
|
||||||
|
document.addEventListener('mouseup', endDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrag(e: MouseEvent) {
|
||||||
|
adjustedPosition.value = clampPosition(
|
||||||
|
e.clientX - dragStart.value.x,
|
||||||
|
e.clientY - dragStart.value.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', onDrag);
|
||||||
|
document.removeEventListener('mouseup', endDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(translatedText.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy text:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (isDragging.value) return;
|
||||||
|
if (dialogRef.value?.contains(e.target as Node)) return;
|
||||||
|
translatorManager.hide();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport :to="teleportTarget">
|
||||||
|
<template v-if="isVisible">
|
||||||
|
<div
|
||||||
|
ref="dialogRef"
|
||||||
|
class="cm-translation-tooltip"
|
||||||
|
:class="{ 'cm-translation-dragging': isDragging }"
|
||||||
|
:style="dialogStyle"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
@keydown.esc="translatorManager.hide"
|
||||||
|
@contextmenu.prevent
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="cm-translation-header">
|
||||||
|
<div class="cm-translation-controls">
|
||||||
|
<select
|
||||||
|
v-model="sourceLangSelector"
|
||||||
|
class="cm-translation-select"
|
||||||
|
@change="translate"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||||
|
{{ lang.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="cm-translation-swap" @click="swapLanguages" @mousedown.stop title="交换语言">
|
||||||
|
<svg viewBox="0 0 24 24" width="11" height="11">
|
||||||
|
<path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-model="targetLangSelector"
|
||||||
|
class="cm-translation-select"
|
||||||
|
@change="translate"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||||
|
{{ lang.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-model="translatorSelector"
|
||||||
|
class="cm-translation-select"
|
||||||
|
@change="handleTranslatorChange"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<option v-for="translator in availableTranslators" :key="translator" :value="translator">
|
||||||
|
{{ translator }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cm-translation-scroll-container">
|
||||||
|
<div v-if="isLoading" class="cm-translation-loading">
|
||||||
|
Translation...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="cm-translation-result">
|
||||||
|
<div class="cm-translation-result-wrapper">
|
||||||
|
<button
|
||||||
|
v-if="translatedText"
|
||||||
|
class="cm-translation-copy-btn"
|
||||||
|
@click="copyToClipboard"
|
||||||
|
@mousedown.stop
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="cm-translation-target">{{ translatedText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cm-translation-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background: var(--settings-card-bg, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 6px;
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--voidraft-font-mono, system-ui, -apple-system, sans-serif), serif;
|
||||||
|
font-size: 10px;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 10000;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-dragging {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10001;
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-header {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-select {
|
||||||
|
padding: 2px 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
background: var(--bg-primary, #f8f8f8);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 65px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-color, rgba(66, 133, 244, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-swap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
background: var(--bg-primary, transparent);
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-swap:hover {
|
||||||
|
background: var(--bg-hover, rgba(66, 133, 244, 0.08));
|
||||||
|
border-color: var(--border-color, rgba(66, 133, 244, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-scroll-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-scroll-container::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-result-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||||
|
background: var(--bg-primary, rgba(255, 255, 255, 0.9));
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-copy-btn:hover {
|
||||||
|
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-copy-btn svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-target {
|
||||||
|
padding: 5px;
|
||||||
|
padding-right: 24px;
|
||||||
|
background: var(--bg-primary, rgba(66, 133, 244, 0.03));
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-loading {
|
||||||
|
padding: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-translation-loading::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--text-muted, rgba(0, 0, 0, 0.2));
|
||||||
|
border-top-color: var(--text-muted, #666);
|
||||||
|
animation: cm-translation-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cm-translation-spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,355 +1,84 @@
|
|||||||
import { Extension, StateField, StateEffect, StateEffectType } from '@codemirror/state';
|
import { Extension, StateField } from '@codemirror/state';
|
||||||
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
|
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
|
||||||
import { createTranslationTooltip } from './tooltip';
|
import { translatorManager } from './manager';
|
||||||
import {
|
import { TRANSLATION_ICON_SVG } from '@/common/constant/translation';
|
||||||
TranslatorConfig,
|
|
||||||
DEFAULT_TRANSLATION_CONFIG,
|
|
||||||
TRANSLATION_ICON_SVG
|
|
||||||
} from '@/common/constant/translation';
|
|
||||||
|
|
||||||
|
function TranslationTooltips(state: any): readonly Tooltip[] {
|
||||||
|
const selection = state.selection.main;
|
||||||
|
if (selection.empty) return [];
|
||||||
|
|
||||||
|
const selectedText = state.sliceDoc(selection.from, selection.to);
|
||||||
|
if (!selectedText.trim()) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
pos: selection.to,
|
||||||
|
above: false,
|
||||||
|
strictSide: true,
|
||||||
|
arrow: false,
|
||||||
|
create: (view) => {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-translator-button';
|
||||||
|
dom.innerHTML = TRANSLATION_ICON_SVG;
|
||||||
|
|
||||||
class TranslatorExtension {
|
dom.addEventListener('mousedown', (e) => {
|
||||||
private config: TranslatorConfig;
|
e.preventDefault();
|
||||||
private setTranslationTooltip: StateEffectType<Tooltip | null>;
|
e.stopPropagation();
|
||||||
private translationTooltipField: StateField<readonly Tooltip[]>;
|
showTranslatorDialog(view);
|
||||||
private translationButtonField: StateField<readonly Tooltip[]>;
|
});
|
||||||
|
|
||||||
constructor(config?: Partial<TranslatorConfig>) {
|
return { dom };
|
||||||
// 初始化配置
|
|
||||||
this.config = {
|
|
||||||
minSelectionLength: DEFAULT_TRANSLATION_CONFIG.minSelectionLength,
|
|
||||||
maxTranslationLength: DEFAULT_TRANSLATION_CONFIG.maxTranslationLength,
|
|
||||||
...config
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化状态效果
|
|
||||||
this.setTranslationTooltip = StateEffect.define<Tooltip | null>();
|
|
||||||
|
|
||||||
// 初始化翻译气泡状态字段
|
|
||||||
this.translationTooltipField = StateField.define<readonly Tooltip[]>({
|
|
||||||
create: () => [],
|
|
||||||
update: (tooltips, tr) => {
|
|
||||||
// 检查是否有特定的状态效果来更新tooltips
|
|
||||||
for (const effect of tr.effects) {
|
|
||||||
if (effect.is(this.setTranslationTooltip)) {
|
|
||||||
return effect.value ? [effect.value] : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果文档或选择变化,隐藏气泡
|
|
||||||
if (tr.docChanged || tr.selection) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return tooltips;
|
|
||||||
},
|
|
||||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化翻译按钮状态字段
|
|
||||||
this.translationButtonField = StateField.define<readonly Tooltip[]>({
|
|
||||||
create: (state) => this.getTranslationButtonTooltips(state),
|
|
||||||
update: (tooltips, tr) => {
|
|
||||||
// 如果文档或选择变化,重新计算tooltip
|
|
||||||
if (tr.docChanged || tr.selection) {
|
|
||||||
return this.getTranslationButtonTooltips(tr.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有翻译气泡显示,如果有则不显示按钮
|
|
||||||
if (tr.state.field(this.translationTooltipField).length > 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return tooltips;
|
|
||||||
},
|
|
||||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据当前选择获取翻译按钮tooltip
|
|
||||||
*/
|
|
||||||
private getTranslationButtonTooltips(state: any): readonly Tooltip[] {
|
|
||||||
// 如果气泡已显示,则不显示按钮
|
|
||||||
if (state.field(this.translationTooltipField).length > 0) return [];
|
|
||||||
|
|
||||||
const selection = state.selection.main;
|
|
||||||
|
|
||||||
// 如果没有选中文本,不显示按钮
|
|
||||||
if (selection.empty) return [];
|
|
||||||
|
|
||||||
// 获取选中的文本
|
|
||||||
const selectedText = state.sliceDoc(selection.from, selection.to);
|
|
||||||
|
|
||||||
// 检查文本是否只包含空格
|
|
||||||
if (!selectedText.trim()) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
}];
|
||||||
// 检查文本长度条件
|
|
||||||
if (selectedText.length < this.config.minSelectionLength ||
|
|
||||||
selectedText.length > this.config.maxTranslationLength) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回翻译按钮tooltip配置
|
|
||||||
return [{
|
|
||||||
pos: selection.to,
|
|
||||||
above: false,
|
|
||||||
strictSide: true,
|
|
||||||
arrow: false,
|
|
||||||
create: (view) => {
|
|
||||||
// 创建按钮DOM
|
|
||||||
const dom = document.createElement('div');
|
|
||||||
dom.className = 'cm-translator-button';
|
|
||||||
dom.innerHTML = TRANSLATION_ICON_SVG;
|
|
||||||
|
|
||||||
// 点击事件
|
|
||||||
dom.addEventListener('mousedown', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// 显示翻译气泡
|
|
||||||
this.showTranslationTooltip(view);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { dom };
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示翻译气泡
|
|
||||||
*/
|
|
||||||
private showTranslationTooltip(view: EditorView) {
|
|
||||||
// 直接从当前选择获取文本
|
|
||||||
const selection = view.state.selection.main;
|
|
||||||
if (selection.empty) return;
|
|
||||||
|
|
||||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
|
||||||
if (!selectedText.trim()) return;
|
|
||||||
|
|
||||||
// 创建翻译气泡
|
|
||||||
const tooltip = createTranslationTooltip(view, selectedText);
|
|
||||||
|
|
||||||
// 更新状态以显示气泡
|
|
||||||
view.dispatch({
|
|
||||||
effects: this.setTranslationTooltip.of(tooltip)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建扩展
|
|
||||||
*/
|
|
||||||
createExtension(): Extension {
|
|
||||||
return [
|
|
||||||
// 翻译按钮tooltip
|
|
||||||
this.translationButtonField,
|
|
||||||
// 翻译气泡tooltip
|
|
||||||
this.translationTooltipField,
|
|
||||||
|
|
||||||
// 添加基础样式
|
|
||||||
EditorView.baseTheme({
|
|
||||||
".cm-translator-button": {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "var(--bg-secondary, transparent)",
|
|
||||||
color: "var(--text-muted, #4285f4)",
|
|
||||||
border: "1px solid var(--border-color, #dadce0)",
|
|
||||||
borderRadius: "3px",
|
|
||||||
padding: "2px",
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
|
|
||||||
userSelect: "none",
|
|
||||||
"&:hover": {
|
|
||||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 翻译气泡样式
|
|
||||||
".cm-translation-tooltip": {
|
|
||||||
background: "var(--bg-secondary, #fff)",
|
|
||||||
color: "var(--text-primary, #333)",
|
|
||||||
border: "1px solid var(--border-color, #dadce0)",
|
|
||||||
borderRadius: "3px",
|
|
||||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
|
||||||
padding: "8px",
|
|
||||||
maxWidth: "300px",
|
|
||||||
maxHeight: "200px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
|
|
||||||
fontSize: "11px",
|
|
||||||
userSelect: "none",
|
|
||||||
cursor: "grab"
|
|
||||||
},
|
|
||||||
|
|
||||||
// 拖拽状态样式
|
|
||||||
".cm-translation-dragging": {
|
|
||||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.2)",
|
|
||||||
zIndex: "1000",
|
|
||||||
cursor: "grabbing !important"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-header": {
|
|
||||||
marginBottom: "8px",
|
|
||||||
flexShrink: "0"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-controls": {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "4px",
|
|
||||||
flexWrap: "nowrap"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-select": {
|
|
||||||
padding: "2px 4px",
|
|
||||||
borderRadius: "3px",
|
|
||||||
border: "1px solid var(--border-color, #dadce0)",
|
|
||||||
background: "var(--bg-primary, #f5f5f5)",
|
|
||||||
fontSize: "11px",
|
|
||||||
color: "var(--text-primary, #333)",
|
|
||||||
flex: "1",
|
|
||||||
minWidth: "0",
|
|
||||||
maxWidth: "80px"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-swap": {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "16px",
|
|
||||||
height: "16px",
|
|
||||||
borderRadius: "3px",
|
|
||||||
border: "1px solid var(--border-color, #dadce0)",
|
|
||||||
background: "var(--bg-primary, transparent)",
|
|
||||||
color: "var(--text-muted, #666)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "0",
|
|
||||||
flexShrink: "0",
|
|
||||||
"&:hover": {
|
|
||||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 滚动容器
|
|
||||||
".cm-translation-scroll-container": {
|
|
||||||
overflowY: "auto",
|
|
||||||
flex: "1",
|
|
||||||
minHeight: "0"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-result": {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-result-header": {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
marginBottom: "4px"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-result-wrapper": {
|
|
||||||
position: "relative",
|
|
||||||
width: "100%"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-copy-btn": {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "20px",
|
|
||||||
height: "20px",
|
|
||||||
borderRadius: "3px",
|
|
||||||
border: "1px solid var(--border-color, #dadce0)",
|
|
||||||
background: "var(--bg-primary, transparent)",
|
|
||||||
color: "var(--text-muted, #666)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "0",
|
|
||||||
position: "absolute",
|
|
||||||
top: "4px",
|
|
||||||
right: "4px",
|
|
||||||
zIndex: "2",
|
|
||||||
opacity: "0.7",
|
|
||||||
"&:hover": {
|
|
||||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
|
|
||||||
opacity: "1"
|
|
||||||
},
|
|
||||||
"&.copied": {
|
|
||||||
background: "var(--bg-success, #4caf50)",
|
|
||||||
color: "white",
|
|
||||||
border: "1px solid var(--bg-success, #4caf50)",
|
|
||||||
opacity: "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-target": {
|
|
||||||
padding: "6px",
|
|
||||||
paddingRight: "28px", // 为复制按钮留出空间
|
|
||||||
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
|
|
||||||
color: "var(--text-primary, #333)",
|
|
||||||
borderRadius: "3px",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-notice": {
|
|
||||||
fontSize: "10px",
|
|
||||||
color: "var(--text-muted, #888)",
|
|
||||||
padding: "2px 0",
|
|
||||||
fontStyle: "italic",
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: "2px"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-error": {
|
|
||||||
color: "var(--text-danger, #d32f2f)",
|
|
||||||
fontStyle: "italic"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-loading": {
|
|
||||||
padding: "8px",
|
|
||||||
textAlign: "center",
|
|
||||||
color: "var(--text-muted, #666)",
|
|
||||||
fontSize: "11px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "6px"
|
|
||||||
},
|
|
||||||
|
|
||||||
".cm-translation-loading::before": {
|
|
||||||
content: "''",
|
|
||||||
display: "inline-block",
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: "2px solid var(--text-muted, #666)",
|
|
||||||
borderTopColor: "transparent",
|
|
||||||
animation: "cm-translation-spin 1s linear infinite"
|
|
||||||
},
|
|
||||||
|
|
||||||
"@keyframes cm-translation-spin": {
|
|
||||||
"0%": { transform: "rotate(0deg)" },
|
|
||||||
"100%": { transform: "rotate(360deg)" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function showTranslatorDialog(view: EditorView) {
|
||||||
* 创建翻译扩展
|
const selection = view.state.selection.main;
|
||||||
*/
|
if (selection.empty) return;
|
||||||
export function createTranslatorExtension(config?: Partial<TranslatorConfig>): Extension {
|
|
||||||
const translatorExtension = new TranslatorExtension(config);
|
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||||
return translatorExtension.createExtension();
|
if (!selectedText.trim()) return;
|
||||||
|
|
||||||
|
const coords = view.coordsAtPos(selection.to);
|
||||||
|
if (!coords) return;
|
||||||
|
|
||||||
|
translatorManager.show(view, coords.left, coords.bottom + 5, selectedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translationButtonField = StateField.define<readonly Tooltip[]>({
|
||||||
|
create: (state) => TranslationTooltips(state),
|
||||||
|
update: (tooltips, tr) => {
|
||||||
|
if (tr.docChanged || tr.selection) {
|
||||||
|
return TranslationTooltips(tr.state);
|
||||||
|
}
|
||||||
|
return tooltips;
|
||||||
|
},
|
||||||
|
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createTranslatorExtension(): Extension {
|
||||||
|
return [
|
||||||
|
translationButtonField,
|
||||||
|
EditorView.baseTheme({
|
||||||
|
".cm-translator-button": {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--bg-secondary, transparent)",
|
||||||
|
color: "var(--text-muted, #4285f4)",
|
||||||
|
border: "1px solid var(--border-color, #dadce0)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
padding: "2px",
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
|
||||||
|
userSelect: "none",
|
||||||
|
"&:hover": {
|
||||||
|
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createTranslatorExtension;
|
export default createTranslatorExtension;
|
||||||
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { EditorView } from '@codemirror/view';
|
||||||
|
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||||
|
|
||||||
|
interface TranslatorPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranslatorState {
|
||||||
|
visible: boolean;
|
||||||
|
position: TranslatorPosition;
|
||||||
|
sourceText: string;
|
||||||
|
view: EditorView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TranslatorManager {
|
||||||
|
private state: ShallowRef<TranslatorState> = shallowRef({
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
sourceText: '',
|
||||||
|
view: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useState() {
|
||||||
|
return readonly(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
show(view: EditorView, clientX: number, clientY: number, text: string): void {
|
||||||
|
this.state.value = {
|
||||||
|
visible: true,
|
||||||
|
position: { x: clientX, y: clientY },
|
||||||
|
sourceText: text,
|
||||||
|
view
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
if (!this.state.value.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = this.state.value.view;
|
||||||
|
this.state.value = {
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
sourceText: '',
|
||||||
|
view: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (view) {
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.state.value = {
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
sourceText: '',
|
||||||
|
view: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translatorManager = new TranslatorManager();
|
||||||
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
import {EditorView, Tooltip, TooltipView} from '@codemirror/view';
|
|
||||||
import {useTranslationStore} from '@/stores/translationStore';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译气泡弹窗类
|
|
||||||
* 提供文本翻译功能的交互式界面
|
|
||||||
*/
|
|
||||||
export class TranslationTooltip implements TooltipView {
|
|
||||||
// ===== 核心属性 =====
|
|
||||||
dom!: HTMLElement;
|
|
||||||
sourceText: string;
|
|
||||||
translationStore: ReturnType<typeof useTranslationStore>;
|
|
||||||
|
|
||||||
// ===== UI 元素 =====
|
|
||||||
private translatorSelector!: HTMLSelectElement;
|
|
||||||
private sourceLangSelector!: HTMLSelectElement;
|
|
||||||
private targetLangSelector!: HTMLSelectElement;
|
|
||||||
private resultContainer!: HTMLDivElement;
|
|
||||||
private loadingIndicator!: HTMLDivElement;
|
|
||||||
private swapButton!: HTMLButtonElement;
|
|
||||||
|
|
||||||
// ===== 状态管理 =====
|
|
||||||
private translatedText: string = '';
|
|
||||||
private eventListeners: Array<{element: HTMLElement | Document, event: string, handler: EventListener}> = [];
|
|
||||||
|
|
||||||
// ===== 拖拽状态 =====
|
|
||||||
private isDragging: boolean = false;
|
|
||||||
private dragOffset: { x: number; y: number } = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
constructor(_view: EditorView, text: string) {
|
|
||||||
this.sourceText = text;
|
|
||||||
this.translationStore = useTranslationStore();
|
|
||||||
|
|
||||||
this.initializeDOM();
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.initializeTranslation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== DOM 初始化 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化DOM结构
|
|
||||||
*/
|
|
||||||
private initializeDOM(): void {
|
|
||||||
this.dom = this.createElement('div', 'cm-translation-tooltip');
|
|
||||||
// 设置为绝对定位,允许拖拽移动
|
|
||||||
this.dom.style.position = 'absolute';
|
|
||||||
|
|
||||||
const header = this.createHeader();
|
|
||||||
const scrollContainer = this.createScrollContainer();
|
|
||||||
|
|
||||||
this.dom.appendChild(header);
|
|
||||||
this.dom.appendChild(scrollContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建头部控制区域
|
|
||||||
*/
|
|
||||||
private createHeader(): HTMLElement {
|
|
||||||
const header = this.createElement('div', 'cm-translation-header');
|
|
||||||
|
|
||||||
const controlsContainer = this.createElement('div', 'cm-translation-controls');
|
|
||||||
|
|
||||||
// 创建所有控制元素
|
|
||||||
this.sourceLangSelector = this.createSelector('cm-translation-select');
|
|
||||||
this.swapButton = this.createSwapButton();
|
|
||||||
this.targetLangSelector = this.createSelector('cm-translation-select');
|
|
||||||
this.translatorSelector = this.createTranslatorSelector();
|
|
||||||
|
|
||||||
// 添加到控制容器
|
|
||||||
controlsContainer.appendChild(this.sourceLangSelector);
|
|
||||||
controlsContainer.appendChild(this.swapButton);
|
|
||||||
controlsContainer.appendChild(this.targetLangSelector);
|
|
||||||
controlsContainer.appendChild(this.translatorSelector);
|
|
||||||
|
|
||||||
header.appendChild(controlsContainer);
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建滚动容器
|
|
||||||
*/
|
|
||||||
private createScrollContainer(): HTMLElement {
|
|
||||||
const scrollContainer = this.createElement('div', 'cm-translation-scroll-container');
|
|
||||||
|
|
||||||
this.loadingIndicator = this.createElement('div', 'cm-translation-loading') as HTMLDivElement;
|
|
||||||
this.loadingIndicator.textContent = 'Translation...';
|
|
||||||
this.loadingIndicator.style.display = 'none';
|
|
||||||
|
|
||||||
this.resultContainer = this.createElement('div', 'cm-translation-result') as HTMLDivElement;
|
|
||||||
|
|
||||||
scrollContainer.appendChild(this.loadingIndicator);
|
|
||||||
scrollContainer.appendChild(this.resultContainer);
|
|
||||||
|
|
||||||
return scrollContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建选择器元素
|
|
||||||
*/
|
|
||||||
private createSelector(className: string): HTMLSelectElement {
|
|
||||||
const select = this.createElement('select', className) as HTMLSelectElement;
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建语言交换按钮
|
|
||||||
*/
|
|
||||||
private createSwapButton(): HTMLButtonElement {
|
|
||||||
const button = this.createElement('button', 'cm-translation-swap') as HTMLButtonElement;
|
|
||||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/></svg>`;
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建翻译器选择器
|
|
||||||
*/
|
|
||||||
private createTranslatorSelector(): HTMLSelectElement {
|
|
||||||
const select = this.createSelector('cm-translation-select');
|
|
||||||
const tempOption = this.createElement('option') as HTMLOptionElement;
|
|
||||||
tempOption.textContent = 'Loading...';
|
|
||||||
select.appendChild(tempOption);
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用DOM元素创建方法
|
|
||||||
*/
|
|
||||||
private createElement(tag: string, className?: string): HTMLElement {
|
|
||||||
const element = document.createElement(tag);
|
|
||||||
if (className) {
|
|
||||||
element.className = className;
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 事件管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置事件监听器
|
|
||||||
*/
|
|
||||||
private setupEventListeners(): void {
|
|
||||||
this.addEventListenerWithCleanup(this.sourceLangSelector, 'change', () => {
|
|
||||||
this.handleLanguageChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addEventListenerWithCleanup(this.targetLangSelector, 'change', () => {
|
|
||||||
this.handleLanguageChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addEventListenerWithCleanup(this.swapButton, 'click', () => {
|
|
||||||
this.swapLanguages();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加拖拽事件监听器
|
|
||||||
this.setupDragListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加事件监听器并记录以便清理
|
|
||||||
*/
|
|
||||||
private addEventListenerWithCleanup(element: HTMLElement | Document, event: string, handler: EventListener): void {
|
|
||||||
element.addEventListener(event, handler);
|
|
||||||
this.eventListeners.push({ element, event, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理所有事件监听器
|
|
||||||
*/
|
|
||||||
private cleanupEventListeners(): void {
|
|
||||||
this.eventListeners.forEach(({ element, event, handler }) => {
|
|
||||||
element.removeEventListener(event, handler);
|
|
||||||
});
|
|
||||||
this.eventListeners = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 初始化和生命周期 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化翻译功能
|
|
||||||
*/
|
|
||||||
private async initializeTranslation(): Promise<void> {
|
|
||||||
this.showLoading();
|
|
||||||
this.resultContainer.innerHTML = '<div class="cm-translation-loading">Loading...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.loadTranslators();
|
|
||||||
await this.translate();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize translation:', error);
|
|
||||||
this.hideLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 语言管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置拖拽事件监听器
|
|
||||||
*/
|
|
||||||
private setupDragListeners(): void {
|
|
||||||
// 在整个翻译框上监听鼠标按下事件
|
|
||||||
this.addEventListenerWithCleanup(this.dom, 'mousedown', (e: Event) => {
|
|
||||||
const mouseEvent = e as MouseEvent;
|
|
||||||
const target = mouseEvent.target as HTMLElement;
|
|
||||||
|
|
||||||
// 如果点击的是交互元素(按钮、选择框等),不启动拖拽
|
|
||||||
if (target.tagName === 'SELECT' || target.tagName === 'BUTTON' ||
|
|
||||||
target.tagName === 'OPTION' || target.closest('select') || target.closest('button')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startDrag(mouseEvent);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标移动
|
|
||||||
this.addEventListenerWithCleanup(document, 'mousemove', (e: Event) => {
|
|
||||||
const mouseEvent = e as MouseEvent;
|
|
||||||
this.onDrag(mouseEvent);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标释放结束拖拽
|
|
||||||
this.addEventListenerWithCleanup(document, 'mouseup', () => {
|
|
||||||
this.endDrag();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始拖拽
|
|
||||||
*/
|
|
||||||
private startDrag(e: MouseEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
this.isDragging = true;
|
|
||||||
|
|
||||||
const rect = this.dom.getBoundingClientRect();
|
|
||||||
this.dragOffset = {
|
|
||||||
x: e.clientX - rect.left,
|
|
||||||
y: e.clientY - rect.top
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加拖拽状态样式
|
|
||||||
this.dom.classList.add('cm-translation-dragging');
|
|
||||||
this.dom.style.cursor = 'grabbing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拖拽过程中
|
|
||||||
*/
|
|
||||||
private onDrag(e: MouseEvent): void {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const newX = e.clientX - this.dragOffset.x;
|
|
||||||
const newY = e.clientY - this.dragOffset.y;
|
|
||||||
|
|
||||||
// 确保不会拖拽到视窗外
|
|
||||||
const maxX = window.innerWidth - this.dom.offsetWidth;
|
|
||||||
const maxY = window.innerHeight - this.dom.offsetHeight;
|
|
||||||
|
|
||||||
const clampedX = Math.max(0, Math.min(newX, maxX));
|
|
||||||
const clampedY = Math.max(0, Math.min(newY, maxY));
|
|
||||||
|
|
||||||
this.dom.style.left = `${clampedX}px`;
|
|
||||||
this.dom.style.top = `${clampedY}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 结束拖拽
|
|
||||||
*/
|
|
||||||
private endDrag(): void {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
this.isDragging = false;
|
|
||||||
|
|
||||||
// 移除拖拽状态样式
|
|
||||||
this.dom.classList.remove('cm-translation-dragging');
|
|
||||||
this.dom.style.cursor = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理语言变更
|
|
||||||
*/
|
|
||||||
private handleLanguageChange(): void {
|
|
||||||
// 语言变更后重新翻译,具体的语言限制逻辑在store中处理
|
|
||||||
this.translate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 交换源语言和目标语言
|
|
||||||
*/
|
|
||||||
private swapLanguages(): void {
|
|
||||||
const temp = this.sourceLangSelector.value;
|
|
||||||
this.sourceLangSelector.value = this.targetLangSelector.value;
|
|
||||||
this.targetLangSelector.value = temp;
|
|
||||||
this.translate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 翻译器管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载翻译器选项
|
|
||||||
*/
|
|
||||||
private async loadTranslators(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
this.clearSelectOptions(this.translatorSelector);
|
|
||||||
|
|
||||||
const translators = this.translationStore.translators;
|
|
||||||
this.populateTranslatorOptions(translators);
|
|
||||||
|
|
||||||
// 添加翻译器变更事件监听
|
|
||||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
|
||||||
this.handleTranslatorChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.updateLanguageSelectors();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load translators:', error);
|
|
||||||
this.loadDefaultTranslators();
|
|
||||||
await this.updateLanguageSelectors();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 填充翻译器选项
|
|
||||||
*/
|
|
||||||
private populateTranslatorOptions(translators: string[]): void {
|
|
||||||
translators.forEach((translator, index) => {
|
|
||||||
const option = this.createElement('option') as HTMLOptionElement;
|
|
||||||
option.value = translator;
|
|
||||||
option.textContent = translator;
|
|
||||||
option.selected = index === 0; // 选择第一个翻译器
|
|
||||||
this.translatorSelector.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载默认翻译器
|
|
||||||
*/
|
|
||||||
private loadDefaultTranslators(): void {
|
|
||||||
this.clearSelectOptions(this.translatorSelector);
|
|
||||||
|
|
||||||
// 使用从后端获取的翻译器列表
|
|
||||||
const translators = this.translationStore.translators;
|
|
||||||
this.populateTranslatorOptions(translators);
|
|
||||||
|
|
||||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
|
||||||
this.handleTranslatorChange();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理翻译器选择变化
|
|
||||||
*/
|
|
||||||
private async handleTranslatorChange(): Promise<void> {
|
|
||||||
await this.updateLanguageSelectors();
|
|
||||||
this.translate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 语言选择器管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新语言选择器
|
|
||||||
*/
|
|
||||||
private async updateLanguageSelectors(): Promise<void> {
|
|
||||||
const currentTranslator = this.translatorSelector.value;
|
|
||||||
|
|
||||||
// 保存当前选中的语言
|
|
||||||
const currentSourceLang = this.sourceLangSelector.value || '';
|
|
||||||
const currentTargetLang = this.targetLangSelector.value;
|
|
||||||
|
|
||||||
// 清空选择器
|
|
||||||
this.clearSelectOptions(this.sourceLangSelector);
|
|
||||||
this.clearSelectOptions(this.targetLangSelector);
|
|
||||||
|
|
||||||
// 直接使用预加载的语言映射
|
|
||||||
const languageMap = this.translationStore.translatorLanguages[currentTranslator];
|
|
||||||
|
|
||||||
if (!languageMap || Object.keys(languageMap).length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加语言选项
|
|
||||||
Object.entries(languageMap).forEach(([code, langInfo]) => {
|
|
||||||
this.addLanguageOption(code, langInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 恢复之前的语言选择
|
|
||||||
this.restoreLanguageSelection(currentSourceLang, currentTargetLang);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空选择器选项
|
|
||||||
*/
|
|
||||||
private clearSelectOptions(selector: HTMLSelectElement): void {
|
|
||||||
while (selector.firstChild) {
|
|
||||||
selector.removeChild(selector.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加语言选项到选择器
|
|
||||||
*/
|
|
||||||
private addLanguageOption(code: string, langInfo: any): void {
|
|
||||||
const displayName = langInfo.Name || langInfo.name || code;
|
|
||||||
|
|
||||||
// 添加源语言选项
|
|
||||||
const sourceOption = this.createElement('option') as HTMLOptionElement;
|
|
||||||
sourceOption.value = code;
|
|
||||||
sourceOption.textContent = displayName;
|
|
||||||
this.sourceLangSelector.appendChild(sourceOption);
|
|
||||||
|
|
||||||
// 添加目标语言选项
|
|
||||||
const targetOption = this.createElement('option') as HTMLOptionElement;
|
|
||||||
targetOption.value = code;
|
|
||||||
targetOption.textContent = displayName;
|
|
||||||
this.targetLangSelector.appendChild(targetOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 恢复语言选择
|
|
||||||
*/
|
|
||||||
private restoreLanguageSelection(sourceLang: string, targetLang: string): void {
|
|
||||||
// 设置源语言
|
|
||||||
if (sourceLang && this.hasLanguageOption(this.sourceLangSelector, sourceLang)) {
|
|
||||||
this.sourceLangSelector.value = sourceLang;
|
|
||||||
} else if (this.sourceLangSelector.options.length > 0) {
|
|
||||||
this.sourceLangSelector.selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置目标语言
|
|
||||||
if (targetLang && this.hasLanguageOption(this.targetLangSelector, targetLang)) {
|
|
||||||
this.targetLangSelector.value = targetLang;
|
|
||||||
} else if (this.targetLangSelector.options.length > 0) {
|
|
||||||
this.targetLangSelector.selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保源语言和目标语言不同
|
|
||||||
this.handleLanguageChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查选择器是否有指定语言选项
|
|
||||||
*/
|
|
||||||
private hasLanguageOption(selector: HTMLSelectElement, langCode: string): boolean {
|
|
||||||
return Array.from(selector.options).some(option => option.value === langCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 翻译功能 =====
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行翻译
|
|
||||||
*/
|
|
||||||
private async translate(): Promise<void> {
|
|
||||||
const sourceLang = this.sourceLangSelector.value;
|
|
||||||
const targetLang = this.targetLangSelector.value;
|
|
||||||
const translatorType = this.translatorSelector.value;
|
|
||||||
|
|
||||||
this.showLoading();
|
|
||||||
this.resultContainer.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.translationStore.translateText(
|
|
||||||
this.sourceText,
|
|
||||||
sourceLang,
|
|
||||||
targetLang,
|
|
||||||
translatorType
|
|
||||||
);
|
|
||||||
|
|
||||||
this.displayTranslationResult(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Translation failed:', err);
|
|
||||||
this.displayError('Translation failed');
|
|
||||||
} finally {
|
|
||||||
this.hideLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UI 状态管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示加载状态
|
|
||||||
*/
|
|
||||||
private showLoading(): void {
|
|
||||||
this.loadingIndicator.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏加载状态
|
|
||||||
*/
|
|
||||||
private hideLoading(): void {
|
|
||||||
this.loadingIndicator.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示错误信息
|
|
||||||
*/
|
|
||||||
private displayError(message: string): void {
|
|
||||||
this.resultContainer.innerHTML = '';
|
|
||||||
this.translatedText = '';
|
|
||||||
|
|
||||||
const errorElement = this.createElement('div', 'cm-translation-error');
|
|
||||||
errorElement.textContent = message;
|
|
||||||
this.resultContainer.appendChild(errorElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 结果显示 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示翻译结果
|
|
||||||
*/
|
|
||||||
private displayTranslationResult(result: any): void {
|
|
||||||
this.resultContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const resultWrapper = this.createElement('div', 'cm-translation-result-wrapper');
|
|
||||||
const translatedTextElem = this.createElement('div', 'cm-translation-target');
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
translatedTextElem.classList.add('cm-translation-error');
|
|
||||||
translatedTextElem.textContent = result.error;
|
|
||||||
this.translatedText = '';
|
|
||||||
} else {
|
|
||||||
this.translatedText = result.translatedText || '';
|
|
||||||
translatedTextElem.textContent = this.translatedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加复制按钮
|
|
||||||
if (this.translatedText) {
|
|
||||||
const copyButton = this.createCopyButton();
|
|
||||||
resultWrapper.appendChild(copyButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
resultWrapper.appendChild(translatedTextElem);
|
|
||||||
this.resultContainer.appendChild(resultWrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建复制按钮
|
|
||||||
*/
|
|
||||||
private createCopyButton(): HTMLButtonElement {
|
|
||||||
const copyButton = this.createElement('button', 'cm-translation-copy-btn') as HTMLButtonElement;
|
|
||||||
copyButton.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
|
|
||||||
|
|
||||||
this.addEventListenerWithCleanup(copyButton, 'click', () => {
|
|
||||||
this.copyToClipboard(copyButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
return copyButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制文本到剪贴板
|
|
||||||
*/
|
|
||||||
private async copyToClipboard(button: HTMLButtonElement): Promise<void> {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(this.translatedText);
|
|
||||||
this.showCopySuccess(button);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy text:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示复制成功状态
|
|
||||||
*/
|
|
||||||
private showCopySuccess(button: HTMLButtonElement): void {
|
|
||||||
const originalHTML = button.innerHTML;
|
|
||||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
|
|
||||||
button.classList.add('copied');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalHTML;
|
|
||||||
button.classList.remove('copied');
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 生命周期管理 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁组件时的清理工作
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.cleanupEventListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建翻译气泡
|
|
||||||
export function createTranslationTooltip(view: EditorView, text: string): Tooltip {
|
|
||||||
return {
|
|
||||||
pos: view.state.selection.main.to, // 紧贴文本末尾
|
|
||||||
above: false,
|
|
||||||
strictSide: false,
|
|
||||||
arrow: true,
|
|
||||||
create: () => new TranslationTooltip(view, text)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
|
|||||||
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
|
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
|
||||||
import {transposeChars} from '../extensions/codeblock';
|
import {transposeChars} from '../extensions/codeblock';
|
||||||
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
|
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
|
||||||
import {textHighlightToggleCommand} from '../extensions/textHighlight/textHighlightExtension';
|
import {textHighlightToggleCommand} from '../extensions/textHighlight';
|
||||||
import {
|
import {
|
||||||
copyLineDown,
|
copyLineDown,
|
||||||
copyLineUp,
|
copyLineUp,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Extension } from '@codemirror/state';
|
import { Extension } from '@codemirror/state';
|
||||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||||
import { useExtensionStore } from '@/stores/extensionStore';
|
import { useExtensionStore } from '@/stores/extensionStore';
|
||||||
import { KeymapManager } from './keymapManager';
|
import { Manager } from './manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步创建快捷键扩展
|
* 异步创建快捷键扩展
|
||||||
@@ -23,7 +23,7 @@ export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
|||||||
// 获取启用的扩展ID列表
|
// 获取启用的扩展ID列表
|
||||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||||
|
|
||||||
return KeymapManager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
return Manager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,10 +37,10 @@ export const updateKeymapExtension = (view: any): void => {
|
|||||||
// 获取启用的扩展ID列表
|
// 获取启用的扩展ID列表
|
||||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||||
|
|
||||||
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
Manager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export { KeymapManager } from './keymapManager';
|
export { Manager } from './manager';
|
||||||
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
||||||
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
||||||
@@ -8,7 +8,7 @@ import {getCommandHandler, isCommandRegistered} from './commands';
|
|||||||
* 快捷键管理器
|
* 快捷键管理器
|
||||||
* 负责将后端配置转换为CodeMirror快捷键扩展
|
* 负责将后端配置转换为CodeMirror快捷键扩展
|
||||||
*/
|
*/
|
||||||
export class KeymapManager {
|
export class Manager {
|
||||||
private static compartment = new Compartment();
|
private static compartment = new Compartment();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import {Compartment, Extension} from '@codemirror/state';
|
|
||||||
import {EditorView} from '@codemirror/view';
|
|
||||||
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
import {ExtensionState, EditorViewInfo, ExtensionFactory} from './types'
|
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展管理器
|
|
||||||
* 负责管理所有动态扩展的注册、启用、禁用和配置更新
|
|
||||||
* 采用统一配置,多视图同步的设计模式
|
|
||||||
*/
|
|
||||||
export class ExtensionManager {
|
|
||||||
// 统一的扩展状态存储
|
|
||||||
private extensionStates = new Map<ExtensionID, ExtensionState>();
|
|
||||||
|
|
||||||
// 编辑器视图管理
|
|
||||||
private viewsMap = new Map<number, EditorViewInfo>();
|
|
||||||
private activeViewId: number | null = null;
|
|
||||||
|
|
||||||
// 注册的扩展工厂
|
|
||||||
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
|
|
||||||
|
|
||||||
// 防抖处理
|
|
||||||
private debouncedUpdateFunctions = new Map<ExtensionID, {
|
|
||||||
debouncedFn: (enabled: boolean, config: any) => void;
|
|
||||||
cancel: () => void;
|
|
||||||
flush: () => void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册扩展工厂
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param factory 扩展工厂
|
|
||||||
*/
|
|
||||||
registerExtension(id: ExtensionID, factory: ExtensionFactory): void {
|
|
||||||
this.extensionFactories.set(id, factory);
|
|
||||||
|
|
||||||
// 创建初始状态
|
|
||||||
if (!this.extensionStates.has(id)) {
|
|
||||||
const compartment = new Compartment();
|
|
||||||
const defaultConfig = factory.getDefaultConfig();
|
|
||||||
|
|
||||||
this.extensionStates.set(id, {
|
|
||||||
id,
|
|
||||||
factory,
|
|
||||||
config: defaultConfig,
|
|
||||||
enabled: false,
|
|
||||||
compartment,
|
|
||||||
extension: [] // 默认为空扩展(禁用状态)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个扩展创建防抖函数
|
|
||||||
if (!this.debouncedUpdateFunctions.has(id)) {
|
|
||||||
const { debouncedFn, cancel, flush } = createDebounce(
|
|
||||||
(enabled: boolean, config: any) => {
|
|
||||||
this.updateExtensionImmediate(id, enabled, config);
|
|
||||||
},
|
|
||||||
{ delay: 300 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.debouncedUpdateFunctions.set(id, {
|
|
||||||
debouncedFn,
|
|
||||||
cancel,
|
|
||||||
flush
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有注册的扩展ID列表
|
|
||||||
*/
|
|
||||||
getRegisteredExtensions(): ExtensionID[] {
|
|
||||||
return Array.from(this.extensionFactories.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查扩展是否已注册
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
isExtensionRegistered(id: ExtensionID): boolean {
|
|
||||||
return this.extensionFactories.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从后端配置初始化扩展状态
|
|
||||||
* @param extensionConfigs 后端扩展配置列表
|
|
||||||
*/
|
|
||||||
initializeExtensionsFromConfig(extensionConfigs: ExtensionConfig[]): void {
|
|
||||||
for (const config of extensionConfigs) {
|
|
||||||
const factory = this.extensionFactories.get(config.id);
|
|
||||||
if (!factory) continue;
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (factory.validateConfig && !factory.validateConfig(config.config)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建扩展实例
|
|
||||||
const extension = config.enabled ? factory.create(config.config) : [];
|
|
||||||
|
|
||||||
// 如果状态已存在则更新,否则创建新状态
|
|
||||||
if (this.extensionStates.has(config.id)) {
|
|
||||||
const state = this.extensionStates.get(config.id)!;
|
|
||||||
state.config = config.config;
|
|
||||||
state.enabled = config.enabled;
|
|
||||||
state.extension = extension;
|
|
||||||
} else {
|
|
||||||
const compartment = new Compartment();
|
|
||||||
this.extensionStates.set(config.id, {
|
|
||||||
id: config.id,
|
|
||||||
factory,
|
|
||||||
config: config.config,
|
|
||||||
enabled: config.enabled,
|
|
||||||
compartment,
|
|
||||||
extension
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to initialize extension ${config.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取初始扩展配置数组(用于创建编辑器)
|
|
||||||
* @returns CodeMirror扩展数组
|
|
||||||
*/
|
|
||||||
getInitialExtensions(): Extension[] {
|
|
||||||
const extensions: Extension[] = [];
|
|
||||||
|
|
||||||
// 为每个注册的扩展添加compartment
|
|
||||||
for (const state of this.extensionStates.values()) {
|
|
||||||
extensions.push(state.compartment.of(state.extension));
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置编辑器视图
|
|
||||||
* @param view 编辑器视图实例
|
|
||||||
* @param documentId 文档ID
|
|
||||||
*/
|
|
||||||
setView(view: EditorView, documentId: number): void {
|
|
||||||
// 保存视图信息
|
|
||||||
this.viewsMap.set(documentId, {
|
|
||||||
view,
|
|
||||||
documentId,
|
|
||||||
registered: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 设置当前活动视图
|
|
||||||
this.activeViewId = documentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前活动视图
|
|
||||||
*/
|
|
||||||
private getActiveView(): EditorView | null {
|
|
||||||
if (this.activeViewId === null) return null;
|
|
||||||
const viewInfo = this.viewsMap.get(this.activeViewId);
|
|
||||||
return viewInfo ? viewInfo.view : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新单个扩展配置并应用到所有视图(带防抖功能)
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param enabled 是否启用
|
|
||||||
* @param config 扩展配置
|
|
||||||
*/
|
|
||||||
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
|
||||||
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
|
|
||||||
if (debouncedUpdate) {
|
|
||||||
debouncedUpdate.debouncedFn(enabled, config);
|
|
||||||
} else {
|
|
||||||
// 如果没有防抖函数,直接执行
|
|
||||||
this.updateExtensionImmediate(id, enabled, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 立即更新扩展(无防抖)
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param enabled 是否启用
|
|
||||||
* @param config 扩展配置
|
|
||||||
*/
|
|
||||||
updateExtensionImmediate(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
|
||||||
// 获取扩展状态
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
// 获取工厂
|
|
||||||
const factory = state.factory;
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (factory.validateConfig && !factory.validateConfig(config)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建新的扩展实例
|
|
||||||
const extension = enabled ? factory.create(config) : [];
|
|
||||||
|
|
||||||
// 更新内部状态
|
|
||||||
state.config = config;
|
|
||||||
state.enabled = enabled;
|
|
||||||
state.extension = extension;
|
|
||||||
|
|
||||||
// 应用到所有视图
|
|
||||||
this.applyExtensionToAllViews(id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update extension ${id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将指定扩展的当前状态应用到所有视图
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
private applyExtensionToAllViews(id: ExtensionID): void {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
// 遍历所有视图并应用更改
|
|
||||||
for (const viewInfo of this.viewsMap.values()) {
|
|
||||||
try {
|
|
||||||
if (!viewInfo.registered) continue;
|
|
||||||
|
|
||||||
viewInfo.view.dispatch({
|
|
||||||
effects: state.compartment.reconfigure(state.extension)
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to apply extension ${id} to document ${viewInfo.documentId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展当前状态
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
getExtensionState(id: ExtensionID): {
|
|
||||||
enabled: boolean
|
|
||||||
config: any
|
|
||||||
} | null {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: state.enabled,
|
|
||||||
config: state.config
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置扩展到默认配置
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
resetExtensionToDefault(id: ExtensionID): void {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
const defaultConfig = state.factory.getDefaultConfig();
|
|
||||||
this.updateExtension(id, true, defaultConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从管理器中移除视图
|
|
||||||
* @param documentId 文档ID
|
|
||||||
*/
|
|
||||||
removeView(documentId: number): void {
|
|
||||||
if (this.activeViewId === documentId) {
|
|
||||||
this.activeViewId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.viewsMap.delete(documentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁管理器
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
// 清除所有防抖函数
|
|
||||||
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
this.debouncedUpdateFunctions.clear();
|
|
||||||
|
|
||||||
this.viewsMap.clear();
|
|
||||||
this.activeViewId = null;
|
|
||||||
this.extensionFactories.clear();
|
|
||||||
this.extensionStates.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +1,146 @@
|
|||||||
import {ExtensionManager} from './extensionManager';
|
import {Manager} from './manager';
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import {ExtensionFactory} from './types'
|
import {ExtensionDefinition} from './types';
|
||||||
|
|
||||||
// 导入现有扩展的创建函数
|
|
||||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
|
||||||
import {createTextHighlighter} from '../extensions/textHighlight/textHighlightExtension';
|
|
||||||
|
|
||||||
|
import index from '../extensions/rainbowBracket';
|
||||||
|
import {createTextHighlighter} from '../extensions/textHighlight';
|
||||||
import {color} from '../extensions/colorSelector';
|
import {color} from '../extensions/colorSelector';
|
||||||
import {hyperLink} from '../extensions/hyperlink';
|
import {hyperLink} from '../extensions/hyperlink';
|
||||||
import {minimap} from '../extensions/minimap';
|
import {minimap} from '../extensions/minimap';
|
||||||
import {vscodeSearch} from '../extensions/vscodeSearch';
|
import {vscodeSearch} from '../extensions/vscodeSearch';
|
||||||
import {createCheckboxExtension} from '../extensions/checkbox';
|
import {createCheckboxExtension} from '../extensions/checkbox';
|
||||||
import {createTranslatorExtension} from '../extensions/translator';
|
import {createTranslatorExtension} from '../extensions/translator';
|
||||||
|
|
||||||
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
||||||
|
|
||||||
/**
|
type ExtensionEntry = {
|
||||||
* 彩虹括号扩展工厂
|
definition: ExtensionDefinition
|
||||||
*/
|
displayNameKey: string
|
||||||
export const rainbowBracketsFactory: ExtensionFactory = {
|
descriptionKey: string
|
||||||
create(_config: any) {
|
|
||||||
return rainbowBracketsExtension();
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
type RegisteredExtensionID = Exclude<ExtensionID, ExtensionID.$zero | ExtensionID.ExtensionEditor>;
|
||||||
* 文本高亮扩展工厂
|
|
||||||
*/
|
|
||||||
export const textHighlightFactory: ExtensionFactory = {
|
|
||||||
create(config: any) {
|
|
||||||
return createTextHighlighter({
|
|
||||||
backgroundColor: config.backgroundColor || '#FFD700',
|
|
||||||
opacity: config.opacity || 0.3
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
backgroundColor: '#FFD700', // 金黄色
|
|
||||||
opacity: 0.3 // 透明度
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object' &&
|
|
||||||
(!config.backgroundColor || typeof config.backgroundColor === 'string') &&
|
|
||||||
(!config.opacity || (typeof config.opacity === 'number' && config.opacity >= 0 && config.opacity <= 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const defineExtension = (create: (config: any) => any, defaultConfig: Record<string, any> = {}): ExtensionDefinition => ({
|
||||||
* 小地图扩展工厂
|
create,
|
||||||
*/
|
defaultConfig
|
||||||
export const minimapFactory: ExtensionFactory = {
|
});
|
||||||
create(config: any) {
|
|
||||||
const options = {
|
|
||||||
displayText: config.displayText || 'characters',
|
|
||||||
showOverlay: config.showOverlay || 'always',
|
|
||||||
autohide: config.autohide || false
|
|
||||||
};
|
|
||||||
return minimap(options);
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
displayText: 'characters',
|
|
||||||
showOverlay: 'always',
|
|
||||||
autohide: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object' &&
|
|
||||||
(!config.displayText || typeof config.displayText === 'string') &&
|
|
||||||
(!config.showOverlay || typeof config.showOverlay === 'string') &&
|
|
||||||
(!config.autohide || typeof config.autohide === 'boolean');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||||
* 超链接扩展工厂
|
|
||||||
*/
|
|
||||||
export const hyperlinkFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return hyperLink;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 颜色选择器扩展工厂
|
|
||||||
*/
|
|
||||||
export const colorSelectorFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return color;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索扩展工厂
|
|
||||||
*/
|
|
||||||
export const searchFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return vscodeSearch;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const foldFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return foldingOnIndent;
|
|
||||||
},
|
|
||||||
getDefaultConfig(): any {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any): boolean {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择框扩展工厂
|
|
||||||
*/
|
|
||||||
export const checkboxFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return createCheckboxExtension();
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译扩展工厂
|
|
||||||
*/
|
|
||||||
export const translatorFactory: ExtensionFactory = {
|
|
||||||
create(config: any) {
|
|
||||||
return createTranslatorExtension({
|
|
||||||
minSelectionLength: config.minSelectionLength || 2,
|
|
||||||
maxTranslationLength: config.maxTranslationLength || 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
minSelectionLength: 2,
|
|
||||||
maxTranslationLength: 5000,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所有扩展的统一配置
|
|
||||||
* 排除$zero值以避免TypeScript类型错误
|
|
||||||
*/
|
|
||||||
const EXTENSION_CONFIGS = {
|
|
||||||
|
|
||||||
// 编辑增强扩展
|
|
||||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||||
factory: rainbowBracketsFactory,
|
definition: defineExtension(() => index()),
|
||||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionHyperlink]: {
|
[ExtensionID.ExtensionHyperlink]: {
|
||||||
factory: hyperlinkFactory,
|
definition: defineExtension(() => hyperLink),
|
||||||
displayNameKey: 'extensions.hyperlink.name',
|
displayNameKey: 'extensions.hyperlink.name',
|
||||||
descriptionKey: 'extensions.hyperlink.description'
|
descriptionKey: 'extensions.hyperlink.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionColorSelector]: {
|
[ExtensionID.ExtensionColorSelector]: {
|
||||||
factory: colorSelectorFactory,
|
definition: defineExtension(() => color),
|
||||||
displayNameKey: 'extensions.colorSelector.name',
|
displayNameKey: 'extensions.colorSelector.name',
|
||||||
descriptionKey: 'extensions.colorSelector.description'
|
descriptionKey: 'extensions.colorSelector.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionTranslator]: {
|
[ExtensionID.ExtensionTranslator]: {
|
||||||
factory: translatorFactory,
|
definition: defineExtension(() => createTranslatorExtension()),
|
||||||
displayNameKey: 'extensions.translator.name',
|
displayNameKey: 'extensions.translator.name',
|
||||||
descriptionKey: 'extensions.translator.description'
|
descriptionKey: 'extensions.translator.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
// UI增强扩展
|
|
||||||
[ExtensionID.ExtensionMinimap]: {
|
[ExtensionID.ExtensionMinimap]: {
|
||||||
factory: minimapFactory,
|
definition: defineExtension((config: any) => minimap({
|
||||||
|
displayText: config?.displayText ?? 'characters',
|
||||||
|
showOverlay: config?.showOverlay ?? 'always',
|
||||||
|
autohide: config?.autohide ?? false
|
||||||
|
}), {
|
||||||
|
displayText: 'characters',
|
||||||
|
showOverlay: 'always',
|
||||||
|
autohide: false
|
||||||
|
}),
|
||||||
displayNameKey: 'extensions.minimap.name',
|
displayNameKey: 'extensions.minimap.name',
|
||||||
descriptionKey: 'extensions.minimap.description'
|
descriptionKey: 'extensions.minimap.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
// 工具扩展
|
|
||||||
[ExtensionID.ExtensionSearch]: {
|
[ExtensionID.ExtensionSearch]: {
|
||||||
factory: searchFactory,
|
definition: defineExtension(() => vscodeSearch),
|
||||||
displayNameKey: 'extensions.search.name',
|
displayNameKey: 'extensions.search.name',
|
||||||
descriptionKey: 'extensions.search.description'
|
descriptionKey: 'extensions.search.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
[ExtensionID.ExtensionFold]: {
|
[ExtensionID.ExtensionFold]: {
|
||||||
factory: foldFactory,
|
definition: defineExtension(() => foldingOnIndent),
|
||||||
displayNameKey: 'extensions.fold.name',
|
displayNameKey: 'extensions.fold.name',
|
||||||
descriptionKey: 'extensions.fold.description'
|
descriptionKey: 'extensions.fold.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionTextHighlight]: {
|
[ExtensionID.ExtensionTextHighlight]: {
|
||||||
factory: textHighlightFactory,
|
definition: defineExtension((config: any) => createTextHighlighter({
|
||||||
|
backgroundColor: config?.backgroundColor ?? '#FFD700',
|
||||||
|
opacity: config?.opacity ?? 0.3
|
||||||
|
}), {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
opacity: 0.3
|
||||||
|
}),
|
||||||
displayNameKey: 'extensions.textHighlight.name',
|
displayNameKey: 'extensions.textHighlight.name',
|
||||||
descriptionKey: 'extensions.textHighlight.description'
|
descriptionKey: 'extensions.textHighlight.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionCheckbox]: {
|
[ExtensionID.ExtensionCheckbox]: {
|
||||||
factory: checkboxFactory,
|
definition: defineExtension(() => createCheckboxExtension()),
|
||||||
displayNameKey: 'extensions.checkbox.name',
|
displayNameKey: 'extensions.checkbox.name',
|
||||||
descriptionKey: 'extensions.checkbox.description'
|
descriptionKey: 'extensions.checkbox.description'
|
||||||
}
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const isRegisteredExtension = (id: ExtensionID): id is RegisteredExtensionID =>
|
||||||
|
Object.prototype.hasOwnProperty.call(EXTENSION_REGISTRY, id);
|
||||||
|
|
||||||
|
const getRegistryEntry = (id: ExtensionID): ExtensionEntry | undefined => {
|
||||||
|
if (!isRegisteredExtension(id)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return EXTENSION_REGISTRY[id];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function registerAllExtensions(manager: Manager): void {
|
||||||
* 注册所有扩展工厂到管理器
|
(Object.entries(EXTENSION_REGISTRY) as [RegisteredExtensionID, ExtensionEntry][]).forEach(([id, entry]) => {
|
||||||
* @param manager 扩展管理器实例
|
manager.registerExtension(id, entry.definition);
|
||||||
*/
|
|
||||||
export function registerAllExtensions(manager: ExtensionManager): void {
|
|
||||||
Object.entries(EXTENSION_CONFIGS).forEach(([id, config]) => {
|
|
||||||
manager.registerExtension(id as ExtensionID, config.factory);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展工厂的显示名称
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 显示名称
|
|
||||||
*/
|
|
||||||
export function getExtensionDisplayName(id: ExtensionID): string {
|
export function getExtensionDisplayName(id: ExtensionID): string {
|
||||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
const entry = getRegistryEntry(id);
|
||||||
return config?.displayNameKey ? i18n.global.t(config.displayNameKey) : id;
|
return entry?.displayNameKey ? i18n.global.t(entry.displayNameKey) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展工厂的描述
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 描述
|
|
||||||
*/
|
|
||||||
export function getExtensionDescription(id: ExtensionID): string {
|
export function getExtensionDescription(id: ExtensionID): string {
|
||||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
const entry = getRegistryEntry(id);
|
||||||
return config?.descriptionKey ? i18n.global.t(config.descriptionKey) : '';
|
return entry?.descriptionKey ? i18n.global.t(entry.descriptionKey) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getExtensionDefinition(id: ExtensionID): ExtensionDefinition | undefined {
|
||||||
* 获取扩展工厂实例
|
return getRegistryEntry(id)?.definition;
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 扩展工厂实例
|
|
||||||
*/
|
|
||||||
export function getExtensionFactory(id: ExtensionID): ExtensionFactory | undefined {
|
|
||||||
return EXTENSION_CONFIGS[id as ExtensionID]?.factory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展的默认配置
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 默认配置对象
|
|
||||||
*/
|
|
||||||
export function getExtensionDefaultConfig(id: ExtensionID): any {
|
export function getExtensionDefaultConfig(id: ExtensionID): any {
|
||||||
const factory = getExtensionFactory(id);
|
const definition = getExtensionDefinition(id);
|
||||||
return factory?.getDefaultConfig() || {};
|
if (!definition) return {};
|
||||||
|
return cloneConfig(definition.defaultConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查扩展是否有配置项
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 是否有配置项
|
|
||||||
*/
|
|
||||||
export function hasExtensionConfig(id: ExtensionID): boolean {
|
export function hasExtensionConfig(id: ExtensionID): boolean {
|
||||||
const defaultConfig = getExtensionDefaultConfig(id);
|
return Object.keys(getExtensionDefaultConfig(id)).length > 0;
|
||||||
return Object.keys(defaultConfig).length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有可用扩展的ID列表
|
|
||||||
* @returns 扩展ID数组
|
|
||||||
*/
|
|
||||||
export function getAllExtensionIds(): ExtensionID[] {
|
export function getAllExtensionIds(): ExtensionID[] {
|
||||||
return Object.keys(EXTENSION_CONFIGS) as ExtensionID[];
|
return Object.keys(EXTENSION_REGISTRY) as RegisteredExtensionID[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloneConfig = (config: any) => {
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
return config.map(cloneConfig);
|
||||||
|
}
|
||||||
|
if (config && typeof config === 'object') {
|
||||||
|
return Object.keys(config).reduce((acc, key) => {
|
||||||
|
acc[key] = cloneConfig(config[key]);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {Extension} from '@codemirror/state';
|
import {Extension} from '@codemirror/state';
|
||||||
import {EditorView} from '@codemirror/view';
|
import {EditorView} from '@codemirror/view';
|
||||||
import {useExtensionStore} from '@/stores/extensionStore';
|
import {useExtensionStore} from '@/stores/extensionStore';
|
||||||
import {ExtensionManager} from './extensionManager';
|
import {Manager} from './manager';
|
||||||
import {registerAllExtensions} from './extensions';
|
import {registerAllExtensions} from './extensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局扩展管理器实例
|
* 全局扩展管理器实例
|
||||||
*/
|
*/
|
||||||
const extensionManager = new ExtensionManager();
|
const extensionManager = new Manager();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步创建动态扩展
|
* 异步创建动态扩展
|
||||||
@@ -26,7 +26,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化扩展管理器配置
|
// 初始化扩展管理器配置
|
||||||
extensionManager.initializeExtensionsFromConfig(extensionStore.extensions);
|
extensionManager.initExtensions(extensionStore.extensions);
|
||||||
|
|
||||||
// 获取初始扩展配置
|
// 获取初始扩展配置
|
||||||
return extensionManager.getInitialExtensions();
|
return extensionManager.getInitialExtensions();
|
||||||
@@ -36,7 +36,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
|||||||
* 获取扩展管理器实例
|
* 获取扩展管理器实例
|
||||||
* @returns 扩展管理器
|
* @returns 扩展管理器
|
||||||
*/
|
*/
|
||||||
export const getExtensionManager = (): ExtensionManager => {
|
export const getExtensionManager = (): Manager => {
|
||||||
return extensionManager;
|
return extensionManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,5 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export {ExtensionManager} from './extensionManager';
|
export {Manager} from './manager';
|
||||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
||||||
135
frontend/src/views/editor/manager/manager.ts
Normal file
135
frontend/src/views/editor/manager/manager.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {Compartment, Extension} from '@codemirror/state';
|
||||||
|
import {EditorView} from '@codemirror/view';
|
||||||
|
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
import {ExtensionDefinition, ExtensionState} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展管理器
|
||||||
|
* 负责注册、初始化与同步所有动态扩展
|
||||||
|
*/
|
||||||
|
export class Manager {
|
||||||
|
private extensionStates = new Map<ExtensionID, ExtensionState>();
|
||||||
|
private views = new Map<number, EditorView>();
|
||||||
|
|
||||||
|
registerExtension(id: ExtensionID, definition: ExtensionDefinition): void {
|
||||||
|
const existingState = this.extensionStates.get(id);
|
||||||
|
if (existingState) {
|
||||||
|
existingState.definition = definition;
|
||||||
|
if (existingState.config === undefined) {
|
||||||
|
existingState.config = this.cloneConfig(definition.defaultConfig ?? {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const compartment = new Compartment();
|
||||||
|
const defaultConfig = this.cloneConfig(definition.defaultConfig ?? {});
|
||||||
|
this.extensionStates.set(id, {
|
||||||
|
id,
|
||||||
|
definition,
|
||||||
|
config: defaultConfig,
|
||||||
|
enabled: false,
|
||||||
|
compartment,
|
||||||
|
extension: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initExtensions(extensionConfigs: ExtensionConfig[]): void {
|
||||||
|
for (const config of extensionConfigs) {
|
||||||
|
const state = this.extensionStates.get(config.id);
|
||||||
|
if (!state) continue;
|
||||||
|
const resolvedConfig = this.cloneConfig(config.config ?? state.definition.defaultConfig ?? {});
|
||||||
|
this.commitExtensionState(state, config.enabled, resolvedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitialExtensions(): Extension[] {
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
for (const state of this.extensionStates.values()) {
|
||||||
|
extensions.push(state.compartment.of(state.extension));
|
||||||
|
}
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(view: EditorView, documentId: number): void {
|
||||||
|
this.views.set(documentId, view);
|
||||||
|
this.applyAllExtensionsToView(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExtension(id: ExtensionID, enabled: boolean, config?: any): void {
|
||||||
|
const state = this.extensionStates.get(id);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const resolvedConfig = this.resolveConfig(state, config);
|
||||||
|
this.commitExtensionState(state, enabled, resolvedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeView(documentId: number): void {
|
||||||
|
this.views.delete(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.views.clear();
|
||||||
|
this.extensionStates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveConfig(state: ExtensionState, config?: any): any {
|
||||||
|
if (config !== undefined) {
|
||||||
|
return this.cloneConfig(config);
|
||||||
|
}
|
||||||
|
if (state.config !== undefined) {
|
||||||
|
return this.cloneConfig(state.config);
|
||||||
|
}
|
||||||
|
return this.cloneConfig(state.definition.defaultConfig ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitExtensionState(state: ExtensionState, enabled: boolean, config: any): void {
|
||||||
|
try {
|
||||||
|
const runtimeExtension = enabled ? state.definition.create(config) : [];
|
||||||
|
state.enabled = enabled;
|
||||||
|
state.config = config;
|
||||||
|
state.extension = runtimeExtension;
|
||||||
|
this.applyExtensionToAllViews(state.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update extension ${state.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyExtensionToAllViews(id: ExtensionID): void {
|
||||||
|
const state = this.extensionStates.get(id);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
for (const [documentId, view] of this.views.entries()) {
|
||||||
|
try {
|
||||||
|
view.dispatch({effects: state.compartment.reconfigure(state.extension)});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to apply extension ${id} to document ${documentId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAllExtensionsToView(view: EditorView): void {
|
||||||
|
const effects: any[] = [];
|
||||||
|
for (const state of this.extensionStates.values()) {
|
||||||
|
effects.push(state.compartment.reconfigure(state.extension));
|
||||||
|
}
|
||||||
|
if (effects.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
view.dispatch({effects});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register extensions on view:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneConfig<T>(config: T): T {
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
return config.map(item => this.cloneConfig(item)) as unknown as T;
|
||||||
|
}
|
||||||
|
if (config && typeof config === 'object') {
|
||||||
|
return Object.keys(config as Record<string, any>).reduce((acc, key) => {
|
||||||
|
(acc as any)[key] = this.cloneConfig((config as Record<string, any>)[key]);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>) as T;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1 @@
|
|||||||
import {Compartment, Extension} from '@codemirror/state';
|
import {Compartment, Extension} from '@codemirror/state';
|
||||||
import {EditorView} from '@codemirror/view';
|
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
/**
|
|
||||||
* 扩展工厂接口
|
|
||||||
* 每个扩展需要实现此接口来创建和配置扩展
|
|
||||||
*/
|
|
||||||
export interface ExtensionFactory {
|
|
||||||
/**
|
|
||||||
* 创建扩展实例
|
|
||||||
* @param config 扩展配置
|
|
||||||
* @returns CodeMirror扩展
|
|
||||||
*/
|
|
||||||
create(config: any): Extension
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取默认配置
|
|
||||||
* @returns 默认配置对象
|
|
||||||
*/
|
|
||||||
getDefaultConfig(): any
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证配置
|
|
||||||
* @param config 配置对象
|
|
||||||
* @returns 是否有效
|
|
||||||
*/
|
|
||||||
validateConfig?(config: any): boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展状态
|
|
||||||
*/
|
|
||||||
export interface ExtensionState {
|
|
||||||
id: ExtensionID
|
|
||||||
factory: ExtensionFactory
|
|
||||||
config: any
|
|
||||||
enabled: boolean
|
|
||||||
compartment: Compartment
|
|
||||||
extension: Extension
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视图信息
|
|
||||||
*/
|
|
||||||
export interface EditorViewInfo {
|
|
||||||
view: EditorView
|
|
||||||
documentId: number
|
|
||||||
registered: boolean
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import {tags} from '@lezer/highlight';
|
|||||||
import {Extension} from '@codemirror/state';
|
import {Extension} from '@codemirror/state';
|
||||||
import type {ThemeColors} from './types';
|
import type {ThemeColors} from './types';
|
||||||
|
|
||||||
|
const MONO_FONT_FALLBACK = 'var(--voidraft-font-mono, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建通用主题
|
* 创建通用主题
|
||||||
* @param colors 主题颜色配置
|
* @param colors 主题颜色配置
|
||||||
@@ -12,28 +14,15 @@ import type {ThemeColors} from './types';
|
|||||||
export function createBaseTheme(colors: ThemeColors): Extension {
|
export function createBaseTheme(colors: ThemeColors): Extension {
|
||||||
// 编辑器主题样式
|
// 编辑器主题样式
|
||||||
const theme = EditorView.theme({
|
const theme = EditorView.theme({
|
||||||
|
|
||||||
'&': {
|
'&': {
|
||||||
color: colors.foreground,
|
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 确保编辑器容器背景一致
|
|
||||||
'.cm-editor': {
|
'.cm-editor': {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 确保滚动区域背景一致
|
|
||||||
'.cm-scroller': {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 编辑器内容
|
|
||||||
'.cm-content': {
|
|
||||||
caretColor: colors.cursor,
|
|
||||||
paddingTop: '4px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 光标
|
// 光标
|
||||||
'.cm-cursor, .cm-dropCursor': {
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
borderLeftColor: colors.cursor,
|
borderLeftColor: colors.cursor,
|
||||||
@@ -42,19 +31,11 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
|||||||
marginTop: '-2px',
|
marginTop: '-2px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择
|
// 选择背景
|
||||||
'.cm-selectionBackground': {
|
|
||||||
backgroundColor: colors.selectionBlur,
|
|
||||||
},
|
|
||||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||||
backgroundColor: colors.selection,
|
backgroundColor: colors.selection,
|
||||||
},
|
},
|
||||||
'.cm-content ::selection': {
|
|
||||||
backgroundColor: colors.selection,
|
|
||||||
},
|
|
||||||
'.cm-activeLine.code-empty-block-selected': {
|
|
||||||
backgroundColor: colors.selection,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 当前行高亮
|
// 当前行高亮
|
||||||
'.cm-activeLine': {
|
'.cm-activeLine': {
|
||||||
@@ -66,7 +47,6 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
|||||||
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
|
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
|
||||||
color: colors.lineNumber,
|
color: colors.lineNumber,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRight: colors.dark ? 'none' : `1px solid ${colors.borderLight}`,
|
|
||||||
padding: '0 2px 0 4px',
|
padding: '0 2px 0 4px',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
},
|
},
|
||||||
@@ -75,105 +55,11 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
|||||||
color: colors.activeLineNumber,
|
color: colors.activeLineNumber,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 折叠功能
|
|
||||||
'.cm-foldGutter': {
|
|
||||||
marginLeft: '0px',
|
|
||||||
},
|
|
||||||
'.cm-foldGutter .cm-gutterElement': {
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 400ms',
|
|
||||||
},
|
|
||||||
'.cm-gutters:hover .cm-gutterElement': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'.cm-foldPlaceholder': {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: colors.comment,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 面板
|
|
||||||
'.cm-panels': {
|
|
||||||
// backgroundColor: colors.dropdownBackground,
|
|
||||||
// color: colors.foreground
|
|
||||||
},
|
|
||||||
'.cm-panels.cm-panels-top': {
|
|
||||||
borderBottom: '2px solid black'
|
|
||||||
},
|
|
||||||
'.cm-panels.cm-panels-bottom': {
|
|
||||||
animation: 'panelSlideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
||||||
},
|
|
||||||
'@keyframes panelSlideUp': {
|
|
||||||
from: {
|
|
||||||
transform: 'translateY(100%)',
|
|
||||||
opacity: '0'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
opacity: '1'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'@keyframes panelSlideDown': {
|
|
||||||
from: {
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
opacity: '1'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
transform: 'translateY(100%)',
|
|
||||||
opacity: '0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 搜索匹配
|
|
||||||
'.cm-searchMatch': {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
outline: `1px solid ${colors.searchMatch}`,
|
|
||||||
},
|
|
||||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
|
||||||
backgroundColor: colors.searchMatch,
|
|
||||||
color: colors.background,
|
|
||||||
},
|
|
||||||
'.cm-selectionMatch': {
|
|
||||||
backgroundColor: colors.dark ? '#50606D' : '#e6f3ff',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 括号匹配
|
// 括号匹配
|
||||||
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
||||||
outline: `0.5px solid ${colors.searchMatch}`,
|
outline: `0.5px solid ${colors.matchingBracket}`,
|
||||||
},
|
|
||||||
'&.cm-focused .cm-matchingBracket': {
|
|
||||||
backgroundColor: colors.matchingBracket,
|
|
||||||
color: 'inherit',
|
|
||||||
},
|
|
||||||
'&.cm-focused .cm-nonmatchingBracket': {
|
|
||||||
outline: colors.dark ? '0.5px solid #bc8f8f' : '0.5px solid #d73a49',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 编辑器焦点
|
|
||||||
'&.cm-editor.cm-focused': {
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 工具提示
|
|
||||||
'.cm-tooltip': {
|
|
||||||
border: colors.dark ? 'none' : `1px solid ${colors.dropdownBorder}`,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
color: colors.foreground,
|
|
||||||
boxShadow: colors.dark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
|
||||||
},
|
|
||||||
'.cm-tooltip .cm-tooltip-arrow:before': {
|
|
||||||
borderTopColor: 'transparent',
|
|
||||||
borderBottomColor: 'transparent',
|
|
||||||
},
|
|
||||||
'.cm-tooltip .cm-tooltip-arrow:after': {
|
|
||||||
borderTopColor: colors.surface,
|
|
||||||
borderBottomColor: colors.surface,
|
|
||||||
},
|
|
||||||
'.cm-tooltip-autocomplete': {
|
|
||||||
'& > ul > li[aria-selected]': {
|
|
||||||
backgroundColor: colors.activeLine,
|
|
||||||
color: colors.foreground,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 代码块层(自定义)
|
// 代码块层(自定义)
|
||||||
@@ -195,8 +81,19 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
|||||||
background: colors.backgroundSecondary,
|
background: colors.backgroundSecondary,
|
||||||
borderTop: `1px solid ${colors.borderColor}`,
|
borderTop: `1px solid ${colors.borderColor}`,
|
||||||
},
|
},
|
||||||
|
'.code-block-empty-selected': {
|
||||||
|
backgroundColor: colors.selection,
|
||||||
|
},
|
||||||
|
// 代码块开始标记
|
||||||
|
'.code-block-start': {
|
||||||
|
height: '12px',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
'.code-block-start.first': {
|
||||||
|
height: '0px',
|
||||||
|
},
|
||||||
|
|
||||||
// 数学计算结果(自定义)
|
// 数学计算结果
|
||||||
'.code-blocks-math-result': {
|
'.code-blocks-math-result': {
|
||||||
paddingLeft: "12px",
|
paddingLeft: "12px",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -223,91 +120,116 @@ export function createBaseTheme(colors: ThemeColors): Extension {
|
|||||||
'.code-blocks-math-result-copied.fade-out': {
|
'.code-blocks-math-result-copied.fade-out': {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 代码块开始标记(自定义)
|
|
||||||
'.code-block-start': {
|
|
||||||
height: '12px',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
'.code-block-start.first': {
|
|
||||||
height: '0px',
|
|
||||||
},
|
|
||||||
}, {dark: colors.dark});
|
}, {dark: colors.dark});
|
||||||
|
|
||||||
// 语法高亮样式
|
|
||||||
const highlightStyle = HighlightStyle.define([
|
const highlightStyle = HighlightStyle.define([
|
||||||
// 关键字
|
{tag: tags.comment, color: colors.comment, fontStyle: 'italic'},
|
||||||
|
{tag: tags.lineComment, color: colors.lineComment, fontStyle: 'italic'},
|
||||||
|
{tag: tags.blockComment, color: colors.blockComment, fontStyle: 'italic'},
|
||||||
|
{tag: tags.docComment, color: colors.docComment, fontStyle: 'italic'},
|
||||||
|
|
||||||
|
{tag: tags.name, color: colors.name},
|
||||||
|
{tag: tags.variableName, color: colors.variableName},
|
||||||
|
{tag: tags.typeName, color: colors.typeName},
|
||||||
|
{tag: tags.tagName, color: colors.tagName},
|
||||||
|
{tag: tags.propertyName, color: colors.propertyName},
|
||||||
|
{tag: tags.attributeName, color: colors.attributeName},
|
||||||
|
{tag: tags.className, color: colors.className},
|
||||||
|
{tag: tags.labelName, color: colors.labelName},
|
||||||
|
{tag: tags.namespace, color: colors.namespace},
|
||||||
|
{tag: tags.macroName, color: colors.macroName},
|
||||||
|
|
||||||
|
{tag: tags.literal, color: colors.literal},
|
||||||
|
{tag: tags.string, color: colors.string},
|
||||||
|
{tag: tags.docString, color: colors.docString},
|
||||||
|
{tag: tags.character, color: colors.character},
|
||||||
|
{tag: tags.attributeValue, color: colors.attributeValue},
|
||||||
|
{tag: tags.number, color: colors.number},
|
||||||
|
{tag: tags.integer, color: colors.integer},
|
||||||
|
{tag: tags.float, color: colors.float},
|
||||||
|
{tag: tags.bool, color: colors.bool},
|
||||||
|
{tag: tags.regexp, color: colors.regexp},
|
||||||
|
{tag: tags.escape, color: colors.escape},
|
||||||
|
{tag: tags.color, color: colors.color},
|
||||||
|
{tag: tags.url, color: colors.url},
|
||||||
|
|
||||||
{tag: tags.keyword, color: colors.keyword},
|
{tag: tags.keyword, color: colors.keyword},
|
||||||
|
{tag: tags.self, color: colors.self},
|
||||||
|
{tag: tags.null, color: colors.null},
|
||||||
|
{tag: tags.atom, color: colors.atom},
|
||||||
|
{tag: tags.unit, color: colors.unit},
|
||||||
|
{tag: tags.modifier, color: colors.modifier},
|
||||||
|
{tag: tags.operatorKeyword, color: colors.operatorKeyword},
|
||||||
|
{tag: tags.controlKeyword, color: colors.controlKeyword},
|
||||||
|
{tag: tags.definitionKeyword, color: colors.definitionKeyword},
|
||||||
|
{tag: tags.moduleKeyword, color: colors.moduleKeyword},
|
||||||
|
|
||||||
// 操作符
|
{tag: tags.operator, color: colors.operator},
|
||||||
{tag: [tags.operator, tags.operatorKeyword], color: colors.operator},
|
{tag: tags.derefOperator, color: colors.derefOperator},
|
||||||
|
{tag: tags.arithmeticOperator, color: colors.arithmeticOperator},
|
||||||
|
{tag: tags.logicOperator, color: colors.logicOperator},
|
||||||
|
{tag: tags.bitwiseOperator, color: colors.bitwiseOperator},
|
||||||
|
{tag: tags.compareOperator, color: colors.compareOperator},
|
||||||
|
{tag: tags.updateOperator, color: colors.updateOperator},
|
||||||
|
{tag: tags.definitionOperator, color: colors.definitionOperator},
|
||||||
|
{tag: tags.typeOperator, color: colors.typeOperator},
|
||||||
|
{tag: tags.controlOperator, color: colors.controlOperator},
|
||||||
|
|
||||||
// 名称、变量
|
{tag: tags.punctuation, color: colors.punctuation},
|
||||||
{tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: colors.variable},
|
{tag: tags.separator, color: colors.separator},
|
||||||
{tag: [tags.variableName], color: colors.variable},
|
{tag: tags.bracket, color: colors.bracket},
|
||||||
{tag: [tags.labelName], color: colors.operator},
|
{tag: tags.angleBracket, color: colors.angleBracket},
|
||||||
{tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: colors.variable},
|
{tag: tags.squareBracket, color: colors.squareBracket},
|
||||||
|
{tag: tags.paren, color: colors.paren},
|
||||||
|
{tag: tags.brace, color: colors.brace},
|
||||||
|
|
||||||
// 函数
|
{tag: tags.content, color: colors.content},
|
||||||
{tag: [tags.function(tags.variableName)], color: colors.function},
|
{tag: tags.heading, color: colors.heading, fontWeight: 'bold'},
|
||||||
{tag: [tags.propertyName], color: colors.function},
|
{tag: tags.heading1, color: colors.heading1, fontWeight: 'bold', fontSize: '1.4em'},
|
||||||
|
{tag: tags.heading2, color: colors.heading2, fontWeight: 'bold', fontSize: '1.3em'},
|
||||||
|
{tag: tags.heading3, color: colors.heading3, fontWeight: 'bold', fontSize: '1.2em'},
|
||||||
|
{tag: tags.heading4, color: colors.heading4, fontWeight: 'bold', fontSize: '1.1em'},
|
||||||
|
{tag: tags.heading5, color: colors.heading5, fontWeight: 'bold'},
|
||||||
|
{tag: tags.heading6, color: colors.heading6, fontWeight: 'bold'},
|
||||||
|
{tag: tags.contentSeparator, color: colors.contentSeparator},
|
||||||
|
{tag: tags.list, color: colors.list},
|
||||||
|
{tag: tags.quote, color: colors.quote, fontStyle: 'italic'},
|
||||||
|
{tag: tags.emphasis, color: colors.emphasis, fontStyle: 'italic'},
|
||||||
|
{tag: tags.strong, color: colors.strong, fontWeight: 'bold'},
|
||||||
|
{tag: tags.link, color: colors.link, textDecoration: 'underline'},
|
||||||
|
{tag: tags.monospace, color: colors.monospace, fontFamily: MONO_FONT_FALLBACK},
|
||||||
|
{tag: tags.strikethrough, color: colors.strikethrough, textDecoration: 'line-through'},
|
||||||
|
|
||||||
// 类型、类
|
{tag: tags.inserted, color: colors.inserted},
|
||||||
{tag: [tags.typeName], color: colors.type},
|
{tag: tags.deleted, color: colors.deleted},
|
||||||
{tag: [tags.className], color: colors.class},
|
{tag: tags.changed, color: colors.changed},
|
||||||
|
|
||||||
// 常量
|
{tag: tags.meta, color: colors.meta, fontStyle: 'italic'},
|
||||||
{tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: colors.constant},
|
{tag: tags.documentMeta, color: colors.documentMeta},
|
||||||
|
{tag: tags.annotation, color: colors.annotation},
|
||||||
|
{tag: tags.processingInstruction, color: colors.processingInstruction},
|
||||||
|
|
||||||
// 字符串
|
{tag: tags.definition(tags.variableName), color: colors.definition},
|
||||||
{tag: [tags.processingInstruction, tags.string, tags.inserted], color: colors.string},
|
{tag: tags.definition(tags.propertyName), color: colors.definition},
|
||||||
{tag: [tags.special(tags.string)], color: colors.string},
|
{tag: tags.definition(tags.name), color: colors.definition},
|
||||||
{tag: [tags.quote], color: colors.comment},
|
{tag: tags.constant(tags.variableName), color: colors.constant},
|
||||||
|
{tag: tags.constant(tags.propertyName), color: colors.constant},
|
||||||
|
{tag: tags.constant(tags.name), color: colors.constant},
|
||||||
|
{tag: tags.function(tags.variableName), color: colors.function},
|
||||||
|
{tag: tags.function(tags.propertyName), color: colors.function},
|
||||||
|
{tag: tags.function(tags.name), color: colors.function},
|
||||||
|
{tag: tags.standard(tags.variableName), color: colors.standard},
|
||||||
|
{tag: tags.standard(tags.name), color: colors.standard},
|
||||||
|
{tag: tags.local(tags.variableName), color: colors.local},
|
||||||
|
{tag: tags.local(tags.name), color: colors.local},
|
||||||
|
{tag: tags.special(tags.variableName), color: colors.special},
|
||||||
|
{tag: tags.special(tags.name), color: colors.special},
|
||||||
|
{tag: tags.special(tags.string), color: colors.special},
|
||||||
|
|
||||||
// 数字
|
|
||||||
{
|
|
||||||
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
|
|
||||||
color: colors.number
|
|
||||||
},
|
|
||||||
|
|
||||||
// 正则表达式
|
|
||||||
{tag: [tags.url, tags.escape, tags.regexp, tags.link], color: colors.regexp},
|
|
||||||
|
|
||||||
// 注释
|
|
||||||
{tag: [tags.meta, tags.comment], color: colors.comment, fontStyle: 'italic'},
|
|
||||||
|
|
||||||
// 分隔符、括号
|
|
||||||
{tag: [tags.definition(tags.name), tags.separator], color: colors.variable},
|
|
||||||
{tag: [tags.brace], color: colors.variable},
|
|
||||||
{tag: [tags.squareBracket], color: colors.dark ? '#bf616a' : colors.keyword},
|
|
||||||
{tag: [tags.angleBracket], color: colors.dark ? '#d08770' : colors.operator},
|
|
||||||
{tag: [tags.attributeName], color: colors.variable},
|
|
||||||
|
|
||||||
// 标签
|
|
||||||
{tag: [tags.tagName], color: colors.number},
|
|
||||||
|
|
||||||
// 注解
|
|
||||||
{tag: [tags.annotation], color: colors.invalid},
|
|
||||||
|
|
||||||
// 特殊样式
|
|
||||||
{tag: tags.strong, fontWeight: 'bold'},
|
|
||||||
{tag: tags.emphasis, fontStyle: 'italic'},
|
|
||||||
{tag: tags.strikethrough, textDecoration: 'line-through'},
|
|
||||||
{tag: tags.link, color: colors.variable, textDecoration: 'underline'},
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
{tag: tags.heading, fontWeight: 'bold', color: colors.heading},
|
|
||||||
{tag: [tags.heading1, tags.heading2], fontSize: '1.4em'},
|
|
||||||
{tag: [tags.heading3, tags.heading4], fontSize: '1.2em'},
|
|
||||||
{tag: [tags.heading5, tags.heading6], fontSize: '1.1em'},
|
|
||||||
|
|
||||||
// 无效内容
|
|
||||||
{tag: tags.invalid, color: colors.invalid},
|
{tag: tags.invalid, color: colors.invalid},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
theme,
|
theme,
|
||||||
syntaxHighlighting(highlightStyle),
|
syntaxHighlighting(highlightStyle),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,110 @@
|
|||||||
import {Extension} from '@codemirror/state'
|
import {Extension} from '@codemirror/state';
|
||||||
import {createBaseTheme} from '../base'
|
import {createBaseTheme} from '../base';
|
||||||
import type {ThemeColors} from '../types'
|
import type {ThemeColors} from '../types';
|
||||||
|
|
||||||
export const config: ThemeColors = {
|
export const config: ThemeColors = {
|
||||||
name: 'aura',
|
themeName: 'aura',
|
||||||
dark: true,
|
dark: true,
|
||||||
|
|
||||||
// 基础色调
|
|
||||||
background: '#21202e',
|
background: '#21202e',
|
||||||
backgroundSecondary: '#2B2A3BFF',
|
backgroundSecondary: '#2b2a3b',
|
||||||
surface: '#21202e',
|
|
||||||
dropdownBackground: '#21202e',
|
|
||||||
dropdownBorder: '#3b334b',
|
|
||||||
|
|
||||||
// 文本颜色
|
|
||||||
foreground: '#edecee',
|
foreground: '#edecee',
|
||||||
foregroundSecondary: '#edecee',
|
|
||||||
comment: '#6d6d6d',
|
|
||||||
|
|
||||||
// 语法高亮色 - 核心
|
|
||||||
keyword: '#a277ff',
|
|
||||||
string: '#61ffca',
|
|
||||||
function: '#ffca85',
|
|
||||||
number: '#61ffca',
|
|
||||||
operator: '#a277ff',
|
|
||||||
variable: '#edecee',
|
|
||||||
type: '#82e2ff',
|
|
||||||
|
|
||||||
// 语法高亮色 - 扩展
|
|
||||||
constant: '#61ffca',
|
|
||||||
storage: '#a277ff',
|
|
||||||
parameter: '#edecee',
|
|
||||||
class: '#82e2ff',
|
|
||||||
heading: '#a277ff',
|
|
||||||
invalid: '#ff6767',
|
|
||||||
regexp: '#61ffca',
|
|
||||||
|
|
||||||
// 界面元素
|
|
||||||
cursor: '#a277ff',
|
cursor: '#a277ff',
|
||||||
selection: '#3d375e7f',
|
selection: '#3d375e7f',
|
||||||
selectionBlur: '#3d375e7f',
|
|
||||||
activeLine: '#4d4b6622',
|
activeLine: '#4d4b6622',
|
||||||
lineNumber: '#a394f033',
|
lineNumber: '#a394f033',
|
||||||
activeLineNumber: '#cdccce',
|
activeLineNumber: '#cdccce',
|
||||||
|
diffInserted: '#61ffca',
|
||||||
// 边框和分割线
|
diffDeleted: '#ff6767',
|
||||||
|
diffChanged: '#ffca85',
|
||||||
borderColor: '#3b334b',
|
borderColor: '#3b334b',
|
||||||
borderLight: '#edecee19',
|
|
||||||
|
|
||||||
// 搜索和匹配
|
|
||||||
searchMatch: '#61ffca',
|
|
||||||
matchingBracket: '#a394f033',
|
matchingBracket: '#a394f033',
|
||||||
}
|
|
||||||
|
|
||||||
// 使用通用主题工厂函数创建 Aura 主题
|
comment: '#6d6d6d',
|
||||||
export const aura: Extension = createBaseTheme(config)
|
lineComment: '#5c5c5c',
|
||||||
|
blockComment: '#5a5a5a',
|
||||||
|
docComment: '#747474',
|
||||||
|
name: '#edecee',
|
||||||
|
variableName: '#edecee',
|
||||||
|
typeName: '#82e2ff',
|
||||||
|
tagName: '#7cd4ff',
|
||||||
|
propertyName: '#d2d1f9',
|
||||||
|
attributeName: '#f6d1ff',
|
||||||
|
className: '#95dbff',
|
||||||
|
labelName: '#ffc285',
|
||||||
|
namespace: '#6fd0ff',
|
||||||
|
macroName: '#ffca85',
|
||||||
|
literal: '#82e2ff',
|
||||||
|
string: '#61ffca',
|
||||||
|
docString: '#61ffca',
|
||||||
|
character: '#73ffd7',
|
||||||
|
attributeValue: '#ffe3c4',
|
||||||
|
number: '#82e2ff',
|
||||||
|
integer: '#82e2ff',
|
||||||
|
float: '#82e2ff',
|
||||||
|
bool: '#ffd18b',
|
||||||
|
regexp: '#61ffca',
|
||||||
|
escape: '#4ff7c6',
|
||||||
|
color: '#ffc57c',
|
||||||
|
url: '#7cd4ff',
|
||||||
|
keyword: '#a277ff',
|
||||||
|
self: '#c89eff',
|
||||||
|
null: '#f69aff',
|
||||||
|
atom: '#61ffca',
|
||||||
|
unit: '#61ffca',
|
||||||
|
modifier: '#c094ff',
|
||||||
|
operatorKeyword: '#b98dff',
|
||||||
|
controlKeyword: '#c17aff',
|
||||||
|
definitionKeyword: '#bd8eff',
|
||||||
|
moduleKeyword: '#cfa2ff',
|
||||||
|
operator: '#a277ff',
|
||||||
|
derefOperator: '#c59bff',
|
||||||
|
arithmeticOperator: '#c78df5',
|
||||||
|
logicOperator: '#c088ff',
|
||||||
|
bitwiseOperator: '#ce8cff',
|
||||||
|
compareOperator: '#c786ff',
|
||||||
|
updateOperator: '#bb7cff',
|
||||||
|
definitionOperator: '#b070ff',
|
||||||
|
typeOperator: '#b98aff',
|
||||||
|
controlOperator: '#a867ff',
|
||||||
|
punctuation: '#d1a6ff',
|
||||||
|
separator: '#ceb1ff',
|
||||||
|
bracket: '#adabff',
|
||||||
|
angleBracket: '#ffc3ff',
|
||||||
|
squareBracket: '#ff9ddd',
|
||||||
|
paren: '#f39ddf',
|
||||||
|
brace: '#f589d6',
|
||||||
|
content: '#edecee',
|
||||||
|
heading: '#a277ff',
|
||||||
|
heading1: '#caa0ff',
|
||||||
|
heading2: '#c192ff',
|
||||||
|
heading3: '#b684ff',
|
||||||
|
heading4: '#aa76ff',
|
||||||
|
heading5: '#9f68ff',
|
||||||
|
heading6: '#955aff',
|
||||||
|
contentSeparator: '#a277ff',
|
||||||
|
list: '#c0c0c0',
|
||||||
|
quote: '#9280a3',
|
||||||
|
emphasis: '#edecee',
|
||||||
|
strong: '#f4f3f5',
|
||||||
|
link: '#79d3ff',
|
||||||
|
monospace: '#d5d0d8',
|
||||||
|
strikethrough: '#b9b3c0',
|
||||||
|
inserted: '#61ffca',
|
||||||
|
deleted: '#ff6767',
|
||||||
|
changed: '#ffca85',
|
||||||
|
invalid: '#ff6767',
|
||||||
|
meta: '#807d8c',
|
||||||
|
documentMeta: '#7b7886',
|
||||||
|
annotation: '#7df5d9',
|
||||||
|
processingInstruction: '#7b7490',
|
||||||
|
definition: '#d0cfe4',
|
||||||
|
constant: '#61ffca',
|
||||||
|
function: '#ffca85',
|
||||||
|
standard: '#c1c0cf',
|
||||||
|
local: '#c9c8d7',
|
||||||
|
special: '#ffd9a8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aura: Extension = createBaseTheme(config);
|
||||||
|
|||||||
@@ -1,63 +1,114 @@
|
|||||||
import {createBaseTheme} from '../base';
|
import {createBaseTheme} from '../base';
|
||||||
import type {ThemeColors} from '../types';
|
import type {ThemeColors} from '../types';
|
||||||
|
|
||||||
// 默认深色主题颜色
|
|
||||||
export const defaultDarkColors: ThemeColors = {
|
export const defaultDarkColors: ThemeColors = {
|
||||||
// 主题信息
|
themeName: 'default-dark',
|
||||||
name: 'default-dark',
|
|
||||||
dark: true,
|
dark: true,
|
||||||
|
|
||||||
// 基础色调
|
// 基础色调
|
||||||
background: '#252B37', // 主背景色
|
background: '#252B37',
|
||||||
backgroundSecondary: '#213644', // 次要背景色
|
backgroundSecondary: '#213644',
|
||||||
surface: '#474747', // 面板背景
|
|
||||||
dropdownBackground: '#252B37', // 下拉菜单背景
|
|
||||||
dropdownBorder: '#ffffff19', // 下拉菜单边框
|
|
||||||
|
|
||||||
// 文本颜色
|
// 文本与界面色
|
||||||
foreground: '#9BB586', // 主文本色
|
foreground: '#ffffff',
|
||||||
foregroundSecondary: '#9c9c9c', // 次要文本色
|
cursor: '#ffffff',
|
||||||
comment: '#6272a4', // 注释色
|
selection: '#0865a9',
|
||||||
|
activeLine: '#ffffff0a',
|
||||||
|
lineNumber: '#ffffff26',
|
||||||
|
activeLineNumber: '#ffffff99',
|
||||||
|
diffInserted: '#64d189',
|
||||||
|
diffDeleted: '#ff6b6b',
|
||||||
|
diffChanged: '#ffb86c',
|
||||||
|
borderColor: '#1e222a',
|
||||||
|
matchingBracket: '#ffffff19',
|
||||||
|
|
||||||
// 语法高亮色 - 核心
|
// 语法标签色值
|
||||||
keyword: '#ff79c6', // 关键字
|
comment: '#6272a4',
|
||||||
string: '#f1fa8c', // 字符串
|
lineComment: '#5c6b99',
|
||||||
function: '#50fa7b', // 函数名
|
blockComment: '#596492',
|
||||||
number: '#bd93f9', // 数字
|
docComment: '#6e7bb5',
|
||||||
operator: '#ff79c6', // 操作符
|
name: '#dfe8ce',
|
||||||
variable: '#8fbcbb', // 变量
|
variableName: '#8fbcbb',
|
||||||
type: '#8be9fd', // 类型
|
typeName: '#8be9fd',
|
||||||
|
tagName: '#77d7f4',
|
||||||
// 语法高亮色 - 扩展
|
propertyName: '#c9e3b0',
|
||||||
constant: '#bd93f9', // 常量
|
attributeName: '#e1c8ff',
|
||||||
storage: '#ff79c6', // 存储类型
|
className: '#a5e0ff',
|
||||||
parameter: '#8fbcbb', // 参数
|
labelName: '#f7b267',
|
||||||
class: '#8be9fd', // 类名
|
namespace: '#5cd1ff',
|
||||||
heading: '#ff79c6', // 标题
|
macroName: '#ffcf8b',
|
||||||
invalid: '#d30102', // 无效内容
|
literal: '#c3b5ff',
|
||||||
regexp: '#f1fa8c', // 正则表达式
|
string: '#f1fa8c',
|
||||||
|
docString: '#e9f28a',
|
||||||
// 界面元素
|
character: '#ffd684',
|
||||||
cursor: '#ffffff', // 光标
|
attributeValue: '#ffe099',
|
||||||
selection: '#0865a9', // 选中背景
|
number: '#bd93f9',
|
||||||
selectionBlur: '#225377', // 失焦选中背景
|
integer: '#c6a5ff',
|
||||||
activeLine: '#ffffff0a', // 当前行高亮
|
float: '#b68afd',
|
||||||
lineNumber: '#ffffff26', // 行号
|
bool: '#7dd4cc',
|
||||||
activeLineNumber: '#ffffff99', // 活动行号
|
regexp: '#9cf0f1',
|
||||||
|
escape: '#85dedd',
|
||||||
// 边框和分割线
|
color: '#ffd38d',
|
||||||
borderColor: '#1e222a', // 边框色
|
url: '#8de0ff',
|
||||||
borderLight: '#ffffff19', // 浅色边框
|
keyword: '#ff79c6',
|
||||||
|
self: '#ff94d6',
|
||||||
// 搜索和匹配
|
null: '#ff9fe2',
|
||||||
searchMatch: '#8fbcbb', // 搜索匹配
|
atom: '#cba6ff',
|
||||||
matchingBracket: '#ffffff19', // 匹配括号
|
unit: '#a8dbd2',
|
||||||
|
modifier: '#f78cc8',
|
||||||
|
operatorKeyword: '#ff84cf',
|
||||||
|
controlKeyword: '#ff6fb6',
|
||||||
|
definitionKeyword: '#ff92d6',
|
||||||
|
moduleKeyword: '#ff8aca',
|
||||||
|
operator: '#ff79c6',
|
||||||
|
derefOperator: '#ff9bd6',
|
||||||
|
arithmeticOperator: '#ff7fc4',
|
||||||
|
logicOperator: '#ff9fcf',
|
||||||
|
bitwiseOperator: '#ff6fb8',
|
||||||
|
compareOperator: '#ff85c7',
|
||||||
|
updateOperator: '#ff76bd',
|
||||||
|
definitionOperator: '#ff6db7',
|
||||||
|
typeOperator: '#ff9bdd',
|
||||||
|
controlOperator: '#ff69ad',
|
||||||
|
punctuation: '#f5a6d9',
|
||||||
|
separator: '#f0a3d7',
|
||||||
|
bracket: '#cda0ff',
|
||||||
|
angleBracket: '#ffc0f1',
|
||||||
|
squareBracket: '#ff8db5',
|
||||||
|
paren: '#ff9ec8',
|
||||||
|
brace: '#fe7ab1',
|
||||||
|
content: '#dfeed0',
|
||||||
|
heading: '#ff9b6b',
|
||||||
|
heading1: '#ffb75f',
|
||||||
|
heading2: '#ffad57',
|
||||||
|
heading3: '#ffa14e',
|
||||||
|
heading4: '#ff9447',
|
||||||
|
heading5: '#ff8842',
|
||||||
|
heading6: '#ff7b3c',
|
||||||
|
contentSeparator: '#ff79c6',
|
||||||
|
list: '#acd1a2',
|
||||||
|
quote: '#7c8fb5',
|
||||||
|
emphasis: '#d9f7c1',
|
||||||
|
strong: '#fdf1c1',
|
||||||
|
link: '#6ac8ff',
|
||||||
|
monospace: '#d1dbc0',
|
||||||
|
strikethrough: '#b7c3a5',
|
||||||
|
inserted: '#64d189',
|
||||||
|
deleted: '#ff6b6b',
|
||||||
|
changed: '#ffb86c',
|
||||||
|
invalid: '#d30102',
|
||||||
|
meta: '#7285bb',
|
||||||
|
documentMeta: '#6a7caa',
|
||||||
|
annotation: '#9bf0ff',
|
||||||
|
processingInstruction: '#7685bd',
|
||||||
|
definition: '#9ec9c3',
|
||||||
|
constant: '#bd93f9',
|
||||||
|
function: '#50fa7b',
|
||||||
|
standard: '#8ab0a8',
|
||||||
|
local: '#92c7bb',
|
||||||
|
special: '#f4d67a',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建深色主题
|
|
||||||
export function createDarkTheme(colors: ThemeColors = defaultDarkColors) {
|
export function createDarkTheme(colors: ThemeColors = defaultDarkColors) {
|
||||||
return createBaseTheme({...colors, dark: true});
|
return createBaseTheme({...colors, dark: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认深色主题
|
|
||||||
export const defaultDark = createDarkTheme(defaultDarkColors);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user