Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c81c878e | |||
| 3660d13d7d | |||
| 281f53c049 | |||
| 71ca541f78 | |||
| 91f4f4afac | |||
| fc5639d7bd | |||
|
|
6668c11846 | ||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e | |||
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f |
@@ -415,25 +415,48 @@ export enum ExtensionID {
|
||||
* 颜色选择器
|
||||
*/
|
||||
ExtensionColorSelector = "colorSelector",
|
||||
ExtensionFold = "fold",
|
||||
ExtensionTextHighlight = "textHighlight",
|
||||
|
||||
/**
|
||||
* 选择框
|
||||
* 代码折叠
|
||||
*/
|
||||
ExtensionCheckbox = "checkbox",
|
||||
ExtensionFold = "fold",
|
||||
|
||||
/**
|
||||
* 划词翻译
|
||||
*/
|
||||
ExtensionTranslator = "translator",
|
||||
|
||||
/**
|
||||
* Markdown渲染
|
||||
*/
|
||||
ExtensionMarkdown = "markdown",
|
||||
|
||||
/**
|
||||
* 显示空白字符
|
||||
*/
|
||||
ExtensionHighlightWhitespace = "highlightWhitespace",
|
||||
|
||||
/**
|
||||
* 高亮行尾空白
|
||||
*/
|
||||
ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace",
|
||||
|
||||
/**
|
||||
* UI增强扩展
|
||||
* 小地图
|
||||
*/
|
||||
ExtensionMinimap = "minimap",
|
||||
|
||||
/**
|
||||
* 行号显示
|
||||
*/
|
||||
ExtensionLineNumbers = "lineNumbers",
|
||||
|
||||
/**
|
||||
* 上下文菜单
|
||||
*/
|
||||
ExtensionContextMenu = "contextMenu",
|
||||
|
||||
/**
|
||||
* 工具扩展
|
||||
* 搜索功能
|
||||
@@ -810,31 +833,6 @@ export enum KeyBindingCommand {
|
||||
*/
|
||||
HideSearchCommand = "hideSearch",
|
||||
|
||||
/**
|
||||
* 搜索切换大小写
|
||||
*/
|
||||
SearchToggleCaseCommand = "searchToggleCase",
|
||||
|
||||
/**
|
||||
* 搜索切换整词
|
||||
*/
|
||||
SearchToggleWordCommand = "searchToggleWord",
|
||||
|
||||
/**
|
||||
* 搜索切换正则
|
||||
*/
|
||||
SearchToggleRegexCommand = "searchToggleRegex",
|
||||
|
||||
/**
|
||||
* 显示替换
|
||||
*/
|
||||
SearchShowReplaceCommand = "searchShowReplace",
|
||||
|
||||
/**
|
||||
* 替换全部
|
||||
*/
|
||||
SearchReplaceAllCommand = "searchReplaceAll",
|
||||
|
||||
/**
|
||||
* 代码块扩展相关
|
||||
* 块内选择全部
|
||||
@@ -1073,12 +1071,6 @@ export enum KeyBindingCommand {
|
||||
* 重做选择
|
||||
*/
|
||||
HistoryRedoSelectionCommand = "historyRedoSelection",
|
||||
|
||||
/**
|
||||
* 文本高亮扩展相关
|
||||
* 切换文本高亮
|
||||
*/
|
||||
TextHighlightToggleCommand = "textHighlightToggle",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1170,7 +1162,7 @@ export class Theme {
|
||||
this["type"] = ("" as ThemeType);
|
||||
}
|
||||
if (!("colors" in $$source)) {
|
||||
this["colors"] = (new ThemeColorConfig());
|
||||
this["colors"] = ({} as ThemeColorConfig);
|
||||
}
|
||||
if (!("isDefault" in $$source)) {
|
||||
this["isDefault"] = false;
|
||||
@@ -1199,303 +1191,9 @@ export class Theme {
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
|
||||
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
|
||||
*/
|
||||
export class ThemeColorConfig {
|
||||
/**
|
||||
* 主题基本信息
|
||||
* 主题名称
|
||||
*/
|
||||
"name": string;
|
||||
|
||||
/**
|
||||
* 是否为深色主题
|
||||
*/
|
||||
"dark": boolean;
|
||||
|
||||
/**
|
||||
* 基础色调
|
||||
* 主背景色
|
||||
*/
|
||||
"background": string;
|
||||
|
||||
/**
|
||||
* 次要背景色(用于代码块交替背景)
|
||||
*/
|
||||
"backgroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 面板背景
|
||||
*/
|
||||
"surface": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单背景
|
||||
*/
|
||||
"dropdownBackground": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单边框
|
||||
*/
|
||||
"dropdownBorder": string;
|
||||
|
||||
/**
|
||||
* 文本颜色
|
||||
* 主文本色
|
||||
*/
|
||||
"foreground": string;
|
||||
|
||||
/**
|
||||
* 次要文本色
|
||||
*/
|
||||
"foregroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 注释色
|
||||
*/
|
||||
"comment": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 核心
|
||||
* 关键字
|
||||
*/
|
||||
"keyword": string;
|
||||
|
||||
/**
|
||||
* 字符串
|
||||
*/
|
||||
"string": string;
|
||||
|
||||
/**
|
||||
* 函数名
|
||||
*/
|
||||
"function": string;
|
||||
|
||||
/**
|
||||
* 数字
|
||||
*/
|
||||
"number": string;
|
||||
|
||||
/**
|
||||
* 操作符
|
||||
*/
|
||||
"operator": string;
|
||||
|
||||
/**
|
||||
* 变量
|
||||
*/
|
||||
"variable": string;
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
"type": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 扩展
|
||||
* 常量
|
||||
*/
|
||||
"constant": string;
|
||||
|
||||
/**
|
||||
* 存储类型(如 static, const)
|
||||
*/
|
||||
"storage": string;
|
||||
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
"parameter": string;
|
||||
|
||||
/**
|
||||
* 类名
|
||||
*/
|
||||
"class": string;
|
||||
|
||||
/**
|
||||
* 标题(Markdown等)
|
||||
*/
|
||||
"heading": string;
|
||||
|
||||
/**
|
||||
* 无效内容/错误
|
||||
*/
|
||||
"invalid": string;
|
||||
|
||||
/**
|
||||
* 正则表达式
|
||||
*/
|
||||
"regexp": string;
|
||||
|
||||
/**
|
||||
* 界面元素
|
||||
* 光标
|
||||
*/
|
||||
"cursor": string;
|
||||
|
||||
/**
|
||||
* 选中背景
|
||||
*/
|
||||
"selection": string;
|
||||
|
||||
/**
|
||||
* 失焦选中背景
|
||||
*/
|
||||
"selectionBlur": string;
|
||||
|
||||
/**
|
||||
* 当前行高亮
|
||||
*/
|
||||
"activeLine": string;
|
||||
|
||||
/**
|
||||
* 行号
|
||||
*/
|
||||
"lineNumber": string;
|
||||
|
||||
/**
|
||||
* 活动行号颜色
|
||||
*/
|
||||
"activeLineNumber": string;
|
||||
|
||||
/**
|
||||
* 边框和分割线
|
||||
* 边框色
|
||||
*/
|
||||
"borderColor": string;
|
||||
|
||||
/**
|
||||
* 浅色边框
|
||||
*/
|
||||
"borderLight": string;
|
||||
|
||||
/**
|
||||
* 搜索和匹配
|
||||
* 搜索匹配
|
||||
*/
|
||||
"searchMatch": string;
|
||||
|
||||
/**
|
||||
* 匹配括号
|
||||
*/
|
||||
"matchingBracket": string;
|
||||
|
||||
/** Creates a new ThemeColorConfig instance. */
|
||||
constructor($$source: Partial<ThemeColorConfig> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("dark" in $$source)) {
|
||||
this["dark"] = false;
|
||||
}
|
||||
if (!("background" in $$source)) {
|
||||
this["background"] = "";
|
||||
}
|
||||
if (!("backgroundSecondary" in $$source)) {
|
||||
this["backgroundSecondary"] = "";
|
||||
}
|
||||
if (!("surface" in $$source)) {
|
||||
this["surface"] = "";
|
||||
}
|
||||
if (!("dropdownBackground" in $$source)) {
|
||||
this["dropdownBackground"] = "";
|
||||
}
|
||||
if (!("dropdownBorder" in $$source)) {
|
||||
this["dropdownBorder"] = "";
|
||||
}
|
||||
if (!("foreground" in $$source)) {
|
||||
this["foreground"] = "";
|
||||
}
|
||||
if (!("foregroundSecondary" in $$source)) {
|
||||
this["foregroundSecondary"] = "";
|
||||
}
|
||||
if (!("comment" in $$source)) {
|
||||
this["comment"] = "";
|
||||
}
|
||||
if (!("keyword" in $$source)) {
|
||||
this["keyword"] = "";
|
||||
}
|
||||
if (!("string" in $$source)) {
|
||||
this["string"] = "";
|
||||
}
|
||||
if (!("function" in $$source)) {
|
||||
this["function"] = "";
|
||||
}
|
||||
if (!("number" in $$source)) {
|
||||
this["number"] = "";
|
||||
}
|
||||
if (!("operator" in $$source)) {
|
||||
this["operator"] = "";
|
||||
}
|
||||
if (!("variable" in $$source)) {
|
||||
this["variable"] = "";
|
||||
}
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("constant" in $$source)) {
|
||||
this["constant"] = "";
|
||||
}
|
||||
if (!("storage" in $$source)) {
|
||||
this["storage"] = "";
|
||||
}
|
||||
if (!("parameter" in $$source)) {
|
||||
this["parameter"] = "";
|
||||
}
|
||||
if (!("class" in $$source)) {
|
||||
this["class"] = "";
|
||||
}
|
||||
if (!("heading" in $$source)) {
|
||||
this["heading"] = "";
|
||||
}
|
||||
if (!("invalid" in $$source)) {
|
||||
this["invalid"] = "";
|
||||
}
|
||||
if (!("regexp" in $$source)) {
|
||||
this["regexp"] = "";
|
||||
}
|
||||
if (!("cursor" in $$source)) {
|
||||
this["cursor"] = "";
|
||||
}
|
||||
if (!("selection" in $$source)) {
|
||||
this["selection"] = "";
|
||||
}
|
||||
if (!("selectionBlur" in $$source)) {
|
||||
this["selectionBlur"] = "";
|
||||
}
|
||||
if (!("activeLine" in $$source)) {
|
||||
this["activeLine"] = "";
|
||||
}
|
||||
if (!("lineNumber" in $$source)) {
|
||||
this["lineNumber"] = "";
|
||||
}
|
||||
if (!("activeLineNumber" in $$source)) {
|
||||
this["activeLineNumber"] = "";
|
||||
}
|
||||
if (!("borderColor" in $$source)) {
|
||||
this["borderColor"] = "";
|
||||
}
|
||||
if (!("borderLight" in $$source)) {
|
||||
this["borderLight"] = "";
|
||||
}
|
||||
if (!("searchMatch" in $$source)) {
|
||||
this["searchMatch"] = "";
|
||||
}
|
||||
if (!("matchingBracket" in $$source)) {
|
||||
this["matchingBracket"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ThemeColorConfig instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): ThemeColorConfig {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
|
||||
}
|
||||
}
|
||||
export type ThemeColorConfig = { [_: string]: any };
|
||||
|
||||
/**
|
||||
* ThemeType 主题类型枚举
|
||||
@@ -1636,6 +1334,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
||||
});
|
||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType8 = HotkeyCombo.createFrom;
|
||||
const $$createType9 = ThemeColorConfig.createFrom;
|
||||
var $$createType9 = (function $$initCreateType9(...args): any {
|
||||
if ($$createType9 === $$initCreateType9) {
|
||||
$$createType9 = $$createType7;
|
||||
}
|
||||
return $$createType9(...args);
|
||||
});
|
||||
const $$createType10 = GithubConfig.createFrom;
|
||||
const $$createType11 = GiteaConfig.createFrom;
|
||||
|
||||
@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
|
||||
import * as models$0 from "../models/models.js";
|
||||
|
||||
/**
|
||||
* GetAllThemes 获取所有主题
|
||||
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
|
||||
*/
|
||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetThemeByID 根据ID或名称获取主题
|
||||
* 如果 id > 0,按ID查询;如果 id = 0,按名称查询
|
||||
*/
|
||||
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(127385338, id, name) as any;
|
||||
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1938954770, name) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -43,10 +30,10 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetTheme 重置主题为预设配置
|
||||
* ResetTheme 删除指定主题的覆盖配置
|
||||
*/
|
||||
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
||||
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, name) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
@@ -59,7 +46,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时初始化
|
||||
* ServiceStartup 服务启动
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
||||
@@ -67,14 +54,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateTheme 更新主题
|
||||
* UpdateTheme 保存或更新主题覆盖
|
||||
*/
|
||||
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
||||
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = models$0.Theme.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
@@ -50,59 +50,56 @@
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
"@mdit/plugin-katex": "^0.23.2",
|
||||
"@mdit/plugin-tasklist": "^0.22.2",
|
||||
"@lezer/lr": "^1.4.4",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"prettier": "^3.7.2",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"happy-dom": "^20.0.11",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.8",
|
||||
"vitest": "^4.0.14",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
"vue-tsc": "^3.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
@import "monocraft_fonts.css";
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import 'styles.css';
|
||||
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,266 @@
|
||||
:root {
|
||||
/* 编辑器区域 */
|
||||
--text-primary: #9BB586; /* 内容区域字体颜色 */
|
||||
|
||||
/* 深色主题颜色变量 */
|
||||
--dark-toolbar-bg: #2d2d2d;
|
||||
--dark-toolbar-border: #404040;
|
||||
--dark-toolbar-text: #ffffff;
|
||||
--dark-toolbar-text-secondary: #cccccc;
|
||||
--dark-toolbar-button-hover: #404040;
|
||||
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--dark-bg-secondary: #0E1217;
|
||||
--dark-text-secondary: #a0aec0;
|
||||
--dark-text-muted: #666;
|
||||
--dark-border-color: #2d3748;
|
||||
--dark-settings-bg: #2a2a2a;
|
||||
--dark-settings-card-bg: #333333;
|
||||
--dark-settings-text: #ffffff;
|
||||
--dark-settings-text-secondary: #cccccc;
|
||||
--dark-settings-border: #444444;
|
||||
--dark-settings-input-bg: #3a3a3a;
|
||||
--dark-settings-input-border: #555555;
|
||||
--dark-settings-hover: #404040;
|
||||
--dark-scrollbar-track: #2a2a2a;
|
||||
--dark-scrollbar-thumb: #555555;
|
||||
--dark-scrollbar-thumb-hover: #666666;
|
||||
--dark-selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--dark-selection-text: #b5cea8;
|
||||
--dark-danger-color: #ff6b6b;
|
||||
--dark-bg-primary: #1a1a1a;
|
||||
--dark-bg-hover: #2a2a2a;
|
||||
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--dark-loading-color: #fff;
|
||||
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--dark-loading-done-color: #6f6;
|
||||
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* 浅色主题颜色变量 */
|
||||
--light-toolbar-bg: #f8f9fa;
|
||||
--light-toolbar-border: #e9ecef;
|
||||
--light-toolbar-text: #212529;
|
||||
--light-toolbar-text-secondary: #495057;
|
||||
--light-toolbar-button-hover: #e9ecef;
|
||||
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--light-bg-secondary: #f7fef7;
|
||||
--light-text-secondary: #374151;
|
||||
--light-text-muted: #6b7280;
|
||||
--light-border-color: #e5e7eb;
|
||||
--light-settings-bg: #ffffff;
|
||||
--light-settings-card-bg: #f8f9fa;
|
||||
--light-settings-text: #212529;
|
||||
--light-settings-text-secondary: #6c757d;
|
||||
--light-settings-border: #dee2e6;
|
||||
--light-settings-input-bg: #ffffff;
|
||||
--light-settings-input-border: #ced4da;
|
||||
--light-settings-hover: #e9ecef;
|
||||
--light-scrollbar-track: #f1f3f4;
|
||||
--light-scrollbar-thumb: #c1c1c1;
|
||||
--light-scrollbar-thumb-hover: #a8a8a8;
|
||||
--light-selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--light-selection-text: #2563eb;
|
||||
--light-danger-color: #dc3545;
|
||||
--light-bg-primary: #ffffff;
|
||||
--light-bg-hover: #f1f3f4;
|
||||
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--light-loading-color: #1a3c1a;
|
||||
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--light-loading-done-color: #008800;
|
||||
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* 默认使用深色主题 */
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
|
||||
|
||||
color-scheme: light dark;
|
||||
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* 监听系统深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
/* 默认/暗色主题 */
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--toolbar-bg: #2d2d2d;
|
||||
--toolbar-border: #404040;
|
||||
--toolbar-text: #ffffff;
|
||||
--toolbar-text-secondary: #cccccc;
|
||||
--toolbar-button-hover: #404040;
|
||||
--toolbar-separator: #404040;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--bg-secondary: #0e1217;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-hover: #2a2a2a;
|
||||
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #666666;
|
||||
--text-danger: #ff6b6b;
|
||||
|
||||
--border-color: #2d3748;
|
||||
|
||||
--settings-bg: #2a2a2a;
|
||||
--settings-card-bg: #333333;
|
||||
--settings-text: #ffffff;
|
||||
--settings-text-secondary: #cccccc;
|
||||
--settings-border: #444444;
|
||||
--settings-input-bg: #3a3a3a;
|
||||
--settings-input-border: #555555;
|
||||
--settings-hover: #404040;
|
||||
|
||||
--scrollbar-track: #2a2a2a;
|
||||
--scrollbar-thumb: #555555;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
|
||||
--selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--selection-text: #b5cea8;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--voidraft-loading-color: #ffffff;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--voidraft-loading-done-color: #66ff66;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 暗色主题 */
|
||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||
|
||||
/* Markdown 表格样式 - 暗色主题 */
|
||||
--cm-table-bg: rgba(35, 40, 52, 0.5);
|
||||
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
|
||||
--cm-table-border: rgba(75, 85, 99, 0.35);
|
||||
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
|
||||
|
||||
/* Search Panel - Dark Theme */
|
||||
--search-panel-bg: #252526;
|
||||
--search-panel-text: #cccccc;
|
||||
--search-panel-border: #454545;
|
||||
--search-input-bg: #3c3c3c;
|
||||
--search-input-text: #cccccc;
|
||||
--search-input-border: #3c3c3c;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(255, 255, 255, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.4);
|
||||
--search-btn-active-text: #ffffff;
|
||||
--search-error-border: #f14c4c;
|
||||
--search-error-bg: #5a1d1d;
|
||||
|
||||
/* Search Match Highlight - Dark Theme (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(81, 175, 255, 0.5);
|
||||
--search-match-selected-border: #74b0f4;
|
||||
}
|
||||
|
||||
/* 监听系统浅色主题 */
|
||||
/* 亮色主题 */
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: #f3f3f3;
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||
--cm-table-border: oklch(88% 0.008 255);
|
||||
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||
|
||||
/* Search Panel - Light Theme */
|
||||
--search-panel-bg: #f3f3f3;
|
||||
--search-panel-text: #616161;
|
||||
--search-panel-border: #c8c8c8;
|
||||
--search-input-bg: #ffffff;
|
||||
--search-input-text: #616161;
|
||||
--search-input-border: #cecece;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(0, 0, 0, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
|
||||
--search-btn-active-text: #0078d4;
|
||||
--search-error-border: #e51400;
|
||||
--search-error-bg: #fdeceb;
|
||||
|
||||
/* Search Match Highlight - Light Theme (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
|
||||
--search-match-selected-border: #268fff;
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* Markdown 表格样式 - 亮色主题 */
|
||||
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||
--cm-table-border: oklch(88% 0.008 255);
|
||||
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||
|
||||
/* Search Panel - Light Theme (auto) */
|
||||
--search-panel-bg: #f3f3f3;
|
||||
--search-panel-text: #616161;
|
||||
--search-panel-border: #c8c8c8;
|
||||
--search-input-bg: #ffffff;
|
||||
--search-input-text: #616161;
|
||||
--search-input-border: #cecece;
|
||||
--search-focus-border: #0078d4;
|
||||
--search-btn-hover: rgba(0, 0, 0, 0.1);
|
||||
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
|
||||
--search-btn-active-text: #0078d4;
|
||||
--search-error-border: #e51400;
|
||||
--search-error-bg: #fdeceb;
|
||||
|
||||
/* Search Match Highlight - Light Theme auto (VSCode style) */
|
||||
--search-match-bg: rgba(250, 220, 81, 0.85);
|
||||
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
|
||||
--search-match-selected-border: #268fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动选择浅色主题 */
|
||||
:root[data-theme="light"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
}
|
||||
|
||||
/* 手动选择深色主题 */
|
||||
:root[data-theme="dark"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
@@ -1,43 +1,19 @@
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
AuthMethod,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig,
|
||||
UpdateSourceType,
|
||||
GitBackupConfig,
|
||||
AuthMethod
|
||||
UpdateSourceType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {FONT_OPTIONS} from './fonts';
|
||||
|
||||
// 配置键映射和限制的类型定义
|
||||
export type GeneralConfigKeyMap = {
|
||||
readonly [K in keyof GeneralConfig]: string;
|
||||
};
|
||||
|
||||
export type EditingConfigKeyMap = {
|
||||
readonly [K in keyof EditingConfig]: string;
|
||||
};
|
||||
|
||||
export type AppearanceConfigKeyMap = {
|
||||
readonly [K in keyof AppearanceConfig]: string;
|
||||
};
|
||||
|
||||
export type UpdatesConfigKeyMap = {
|
||||
readonly [K in keyof UpdatesConfig]: string;
|
||||
};
|
||||
|
||||
export type BackupConfigKeyMap = {
|
||||
readonly [K in keyof GitBackupConfig]: string;
|
||||
};
|
||||
|
||||
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
||||
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
|
||||
|
||||
// 配置键映射
|
||||
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
// 统一配置键映射(平级展开)
|
||||
export const CONFIG_KEY_MAP = {
|
||||
// general
|
||||
alwaysOnTop: 'general.alwaysOnTop',
|
||||
dataPath: 'general.dataPath',
|
||||
enableSystemTray: 'general.enableSystemTray',
|
||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
enableWindowSnap: 'general.enableWindowSnap',
|
||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||
enableTabs: 'general.enableTabs',
|
||||
} as const;
|
||||
|
||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
// editing
|
||||
fontSize: 'editing.fontSize',
|
||||
fontFamily: 'editing.fontFamily',
|
||||
fontWeight: 'editing.fontWeight',
|
||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
enableTabIndent: 'editing.enableTabIndent',
|
||||
tabSize: 'editing.tabSize',
|
||||
tabType: 'editing.tabType',
|
||||
autoSaveDelay: 'editing.autoSaveDelay'
|
||||
} as const;
|
||||
|
||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
||||
autoSaveDelay: 'editing.autoSaveDelay',
|
||||
// appearance
|
||||
language: 'appearance.language',
|
||||
systemTheme: 'appearance.systemTheme',
|
||||
currentTheme: 'appearance.currentTheme'
|
||||
} as const;
|
||||
|
||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
currentTheme: 'appearance.currentTheme',
|
||||
// updates
|
||||
version: 'updates.version',
|
||||
autoUpdate: 'updates.autoUpdate',
|
||||
primarySource: 'updates.primarySource',
|
||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||
updateTimeout: 'updates.updateTimeout',
|
||||
github: 'updates.github',
|
||||
gitea: 'updates.gitea'
|
||||
} as const;
|
||||
|
||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
gitea: 'updates.gitea',
|
||||
// backup
|
||||
enabled: 'backup.enabled',
|
||||
repo_url: 'backup.repo_url',
|
||||
auth_method: 'backup.auth_method',
|
||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
auto_backup: 'backup.auto_backup',
|
||||
} as const;
|
||||
|
||||
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||
|
||||
// 配置限制
|
||||
export const CONFIG_LIMITS = {
|
||||
fontSize: {min: 12, max: 28, default: 13},
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
||||
font-family: var(--voidraft-font-mono),serif;
|
||||
}
|
||||
|
||||
.loading-word {
|
||||
@@ -175,4 +175,4 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.menu-item:hover & {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||
|
||||
const { t } = useI18n();
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
// 组件状态
|
||||
const showLanguageMenu = shallowRef(false);
|
||||
|
||||
@@ -13,20 +13,16 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {usePanelStore} from '@/stores/panelStore';
|
||||
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const configStore = readonly(useConfigStore());
|
||||
const updateStore = readonly(useUpdateStore());
|
||||
const windowStore = readonly(useWindowStore());
|
||||
const systemStore = readonly(useSystemStore());
|
||||
const panelStore = readonly(usePanelStore());
|
||||
const editorStore = useEditorStore();
|
||||
const configStore = useConfigStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const windowStore = useWindowStore();
|
||||
const systemStore = useSystemStore();
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const canFormatCurrentBlock = ref(false);
|
||||
const canPreviewMarkdown = ref(false);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
const { documentStats } = toRefs(editorStore);
|
||||
@@ -37,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||
});
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
const toggleAlwaysOnTop = async () => {
|
||||
@@ -69,22 +61,12 @@ const formatCurrentBlock = () => {
|
||||
formatBlockContent(editorStore.editorView);
|
||||
};
|
||||
|
||||
// 切换 Markdown 预览
|
||||
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
|
||||
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
|
||||
toggleMarkdownPreview(editorStore.editorView as any);
|
||||
}, { delay: 200 });
|
||||
|
||||
const togglePreview = () => {
|
||||
debouncedTogglePreview();
|
||||
};
|
||||
|
||||
// 统一更新按钮状态
|
||||
const updateButtonStates = () => {
|
||||
const view: any = editorStore.editorView;
|
||||
if (!view) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +77,6 @@ const updateButtonStates = () => {
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,11 +84,9 @@ const updateButtonStates = () => {
|
||||
const language = getLanguage(languageName as any);
|
||||
|
||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
||||
} catch (error) {
|
||||
console.warn('Error checking block capabilities:', error);
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,7 +140,6 @@ watch(
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -255,21 +233,6 @@ const statsData = computed(() => ({
|
||||
<!-- 块语言选择器 -->
|
||||
<BlockLanguageSelector/>
|
||||
|
||||
<!-- Markdown预览按钮 -->
|
||||
<div
|
||||
v-if="canPreviewMarkdown"
|
||||
class="preview-button"
|
||||
:class="{ 'active': isCurrentBlockPreviewing }"
|
||||
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 格式化按钮 - 支持点击操作 -->
|
||||
<div
|
||||
v-if="canFormatCurrentBlock"
|
||||
|
||||
@@ -111,7 +111,6 @@ export default {
|
||||
deleteCharForward: 'Delete character forward',
|
||||
deleteGroupBackward: 'Delete group backward',
|
||||
deleteGroupForward: 'Delete group forward',
|
||||
textHighlightToggle: 'Toggle text highlight',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -161,53 +160,6 @@ export default {
|
||||
customThemeColors: 'Custom Theme Colors',
|
||||
resetToDefault: 'Reset to Default',
|
||||
colorValue: 'Color Value',
|
||||
themeColors: {
|
||||
basic: 'Basic Colors',
|
||||
text: 'Text Colors',
|
||||
syntax: 'Syntax Highlighting',
|
||||
interface: 'Interface Elements',
|
||||
border: 'Borders & Dividers',
|
||||
search: 'Search & Matching',
|
||||
// Base Colors
|
||||
background: 'Main Background',
|
||||
backgroundSecondary: 'Secondary Background',
|
||||
surface: 'Panel Background',
|
||||
dropdownBackground: 'Dropdown Background',
|
||||
dropdownBorder: 'Dropdown Border',
|
||||
// Text Colors
|
||||
foreground: 'Primary Text',
|
||||
foregroundSecondary: 'Secondary Text',
|
||||
comment: 'Comments',
|
||||
// Syntax Highlighting - Core
|
||||
keyword: 'Keywords',
|
||||
string: 'Strings',
|
||||
function: 'Functions',
|
||||
number: 'Numbers',
|
||||
operator: 'Operators',
|
||||
variable: 'Variables',
|
||||
type: 'Types',
|
||||
// Syntax Highlighting - Extended
|
||||
constant: 'Constants',
|
||||
storage: 'Storage Type',
|
||||
parameter: 'Parameters',
|
||||
class: 'Class Names',
|
||||
heading: 'Headings',
|
||||
invalid: 'Invalid/Error',
|
||||
regexp: 'Regular Expressions',
|
||||
// Interface Elements
|
||||
cursor: 'Cursor',
|
||||
selection: 'Selection Background',
|
||||
selectionBlur: 'Unfocused Selection',
|
||||
activeLine: 'Active Line Highlight',
|
||||
lineNumber: 'Line Numbers',
|
||||
activeLineNumber: 'Active Line Number',
|
||||
// Borders & Dividers
|
||||
borderColor: 'Border Color',
|
||||
borderLight: 'Light Border',
|
||||
// Search & Matching
|
||||
searchMatch: 'Search Match',
|
||||
matchingBracket: 'Matching Bracket'
|
||||
},
|
||||
lineHeight: 'Line Height',
|
||||
tabSettings: 'Tab Settings',
|
||||
tabSize: 'Tab Size',
|
||||
@@ -304,7 +256,7 @@ export default {
|
||||
},
|
||||
colorSelector: {
|
||||
name: 'Color Selector',
|
||||
description: 'Visual color picker and color value display'
|
||||
description: 'CSS code block visual color picker and color value display'
|
||||
},
|
||||
translator: {
|
||||
name: 'Text Translator',
|
||||
@@ -322,23 +274,33 @@ export default {
|
||||
name: 'Code Folding',
|
||||
description: 'Collapse and expand code sections for better readability'
|
||||
},
|
||||
textHighlight: {
|
||||
name: 'Text Highlight',
|
||||
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)',
|
||||
backgroundColor: 'Background Color',
|
||||
opacity: 'Opacity'
|
||||
},
|
||||
checkbox: {
|
||||
name: 'Checkbox',
|
||||
description: 'Render [x] and [ ] as interactive checkboxes'
|
||||
markdown: {
|
||||
name: 'Markdown Renderer',
|
||||
description: 'Render Markdown elements, "what you see is what you get"'
|
||||
},
|
||||
codeblock: {
|
||||
name: 'Code Block',
|
||||
description: 'Code block related functionality'
|
||||
},
|
||||
lineNumbers: {
|
||||
name: 'Line Numbers',
|
||||
description: 'Display line numbers on the left side of the editor and highlight the current line'
|
||||
},
|
||||
contextMenu: {
|
||||
name: 'Context Menu',
|
||||
description: 'Show context menu when right-clicking in the editor'
|
||||
},
|
||||
highlightWhitespace: {
|
||||
name: 'Highlight Whitespace',
|
||||
description: 'Display whitespace characters such as spaces and tabs in the editor'
|
||||
},
|
||||
highlightTrailingWhitespace: {
|
||||
name: 'Highlight Trailing Whitespace',
|
||||
description: 'Highlight trailing whitespace at the end of lines'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
memory: 'Memory',
|
||||
clickToClean: 'Click to clean memory'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,7 +111,6 @@ export default {
|
||||
deleteCharForward: '向前删除字符',
|
||||
deleteGroupBackward: '向后删除组',
|
||||
deleteGroupForward: '向前删除组',
|
||||
textHighlightToggle: '切换文本高亮',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -202,54 +201,6 @@ export default {
|
||||
customThemeColors: '自定义主题颜色',
|
||||
resetToDefault: '重置为默认',
|
||||
colorValue: '颜色值',
|
||||
themeColors: {
|
||||
basic: '基础色调',
|
||||
text: '文本颜色',
|
||||
syntax: '语法高亮',
|
||||
interface: '界面元素',
|
||||
border: '边框分割线',
|
||||
search: '搜索匹配',
|
||||
// 基础色调
|
||||
background: '主背景色',
|
||||
backgroundSecondary: '次要背景色',
|
||||
surface: '面板背景',
|
||||
dropdownBackground: '下拉菜单背景',
|
||||
dropdownBorder: '下拉菜单边框',
|
||||
// 文本颜色
|
||||
foreground: '主文本色',
|
||||
foregroundSecondary: '次要文本色',
|
||||
comment: '注释色',
|
||||
// 语法高亮 - 核心
|
||||
keyword: '关键字',
|
||||
string: '字符串',
|
||||
function: '函数名',
|
||||
number: '数字',
|
||||
operator: '操作符',
|
||||
variable: '变量',
|
||||
type: '类型',
|
||||
// 语法高亮 - 扩展
|
||||
constant: '常量',
|
||||
storage: '存储类型',
|
||||
parameter: '参数',
|
||||
class: '类名',
|
||||
heading: '标题',
|
||||
invalid: '无效内容',
|
||||
regexp: '正则表达式',
|
||||
// 界面元素
|
||||
cursor: '光标',
|
||||
selection: '选中背景',
|
||||
selectionBlur: '失焦选中背景',
|
||||
activeLine: '当前行高亮',
|
||||
lineNumber: '行号',
|
||||
activeLineNumber: '活动行号',
|
||||
// 边框和分割线
|
||||
borderColor: '边框色',
|
||||
borderLight: '浅色边框',
|
||||
// 搜索和匹配
|
||||
searchMatch: '搜索匹配',
|
||||
matchingBracket: '匹配括号'
|
||||
},
|
||||
|
||||
hotkeyPreview: '预览:',
|
||||
none: '无',
|
||||
backup: {
|
||||
@@ -307,7 +258,7 @@ export default {
|
||||
},
|
||||
colorSelector: {
|
||||
name: '颜色选择器',
|
||||
description: '颜色值的可视化和选择'
|
||||
description: 'CSS代码块颜色值的可视化和选择'
|
||||
},
|
||||
translator: {
|
||||
name: '划词翻译',
|
||||
@@ -325,23 +276,33 @@ export default {
|
||||
name: '代码折叠',
|
||||
description: '折叠和展开代码段以提高代码可读性'
|
||||
},
|
||||
textHighlight: {
|
||||
name: '文本高亮',
|
||||
description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)',
|
||||
backgroundColor: '背景颜色',
|
||||
opacity: '透明度'
|
||||
},
|
||||
checkbox: {
|
||||
name: '选择框',
|
||||
description: '将 [x] 和 [ ] 渲染为可交互的选择框'
|
||||
markdown: {
|
||||
name: 'Markdown 渲染',
|
||||
description: '渲染 Markdown 元素,“所见即所得”'
|
||||
},
|
||||
codeblock: {
|
||||
name: '代码块',
|
||||
description: '代码块相关功能'
|
||||
},
|
||||
lineNumbers: {
|
||||
name: '行号显示',
|
||||
description: '在编辑器左侧显示行号,并高亮当前行'
|
||||
},
|
||||
contextMenu: {
|
||||
name: '上下文菜单',
|
||||
description: '在编辑器中右键点击时显示上下文菜单'
|
||||
},
|
||||
highlightWhitespace: {
|
||||
name: '显示空白字符',
|
||||
description: '在编辑器中显示空格和制表符等空白字符'
|
||||
},
|
||||
highlightTrailingWhitespace: {
|
||||
name: '高亮行尾空白',
|
||||
description: '高亮显示行尾的多余空白字符'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
memory: '内存',
|
||||
clickToClean: '点击清理内存'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
AuthMethod,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
GitBackupConfig,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig
|
||||
TabType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {
|
||||
APPEARANCE_CONFIG_KEY_MAP,
|
||||
BACKUP_CONFIG_KEY_MAP,
|
||||
CONFIG_KEY_MAP,
|
||||
CONFIG_LIMITS,
|
||||
ConfigKey,
|
||||
ConfigSection,
|
||||
DEFAULT_CONFIG,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
NumberConfigKey,
|
||||
UPDATES_CONFIG_KEY_MAP
|
||||
NumberConfigKey
|
||||
} from '@/common/constant/config';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// Font options (no longer localized)
|
||||
const fontOptions = computed(() => FONT_OPTIONS);
|
||||
|
||||
// 计算属性 - 使用工厂函数简化
|
||||
// 计算属性
|
||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||
const limits = Object.fromEntries(
|
||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
||||
|
||||
// 通用配置更新方法
|
||||
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
// 统一配置更新方法
|
||||
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for general.${key.toString()}`);
|
||||
throw new Error(`No backend key mapping found for ${String(key)}`);
|
||||
}
|
||||
|
||||
// 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general')
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.general[key] = value;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = EDITING_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.editing[key] = value;
|
||||
// 只更新本地状态,不保存到后端
|
||||
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.appearance[key] = value;
|
||||
};
|
||||
|
||||
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.updates[key] = value;
|
||||
};
|
||||
|
||||
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.backup[key] = value;
|
||||
// 保存指定配置到后端
|
||||
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||
|
||||
return {
|
||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
||||
reset: async () => await updateEditingConfig(key, limit.default)
|
||||
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||
reset: async () => await updateConfig(key, limit.default),
|
||||
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
|
||||
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
|
||||
};
|
||||
};
|
||||
|
||||
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
||||
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
|
||||
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
|
||||
|
||||
// 枚举值切换器
|
||||
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||
async () => {
|
||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||
const nextIndex = (currentIndex + 1) % values.length;
|
||||
return await updateEditingConfig(key, values[nextIndex]);
|
||||
return await updateConfig(key, values[nextIndex]);
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
@@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 语言设置方法
|
||||
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||
await updateAppearanceConfig('language', language);
|
||||
|
||||
// 同步更新前端语言
|
||||
await updateConfig('language', language);
|
||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||
locale.value = frontendLocale as any;
|
||||
};
|
||||
|
||||
// 系统主题设置方法
|
||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
||||
await updateConfig('systemTheme', systemTheme);
|
||||
};
|
||||
|
||||
// 当前主题设置方法
|
||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||
await updateAppearanceConfig('currentTheme', themeName);
|
||||
await updateConfig('currentTheme', themeName);
|
||||
};
|
||||
|
||||
|
||||
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const togglers = {
|
||||
tabIndent: createEditingToggler('enableTabIndent'),
|
||||
alwaysOnTop: async () => {
|
||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
// 立即应用窗口置顶状态
|
||||
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||
},
|
||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
||||
};
|
||||
|
||||
// 字符串配置设置器
|
||||
const setters = {
|
||||
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
|
||||
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
|
||||
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
|
||||
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
config: computed(() => state.config),
|
||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
||||
decreaseFontSize: adjusters.fontSize.decrease,
|
||||
resetFontSize: adjusters.fontSize.reset,
|
||||
setFontSize: adjusters.fontSize.set,
|
||||
// 字体大小操作
|
||||
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||
saveFontSize: () => saveConfig('fontSize'),
|
||||
|
||||
// Tab操作
|
||||
toggleTabIndent: togglers.tabIndent,
|
||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
||||
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||
...adjusters.tabSize,
|
||||
increaseTabSize: adjusters.tabSize.increase,
|
||||
decreaseTabSize: adjusters.tabSize.decrease,
|
||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 窗口操作
|
||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
||||
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||
|
||||
// 字体操作
|
||||
setFontFamily: setters.fontFamily,
|
||||
setFontWeight: setters.fontWeight,
|
||||
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||
setFontWeight: (value: string) => updateConfig('fontWeight', value),
|
||||
|
||||
// 路径操作
|
||||
setDataPath: setters.dataPath,
|
||||
setDataPath: (value: string) => updateConfig('dataPath', value),
|
||||
|
||||
// 保存配置相关方法
|
||||
setAutoSaveDelay: setters.autoSaveDelay,
|
||||
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
|
||||
|
||||
// 热键配置相关方法
|
||||
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
||||
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||
|
||||
// 系统托盘配置相关方法
|
||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
||||
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||
|
||||
// 开机启动配置相关方法
|
||||
setStartAtLogin: async (value: boolean) => {
|
||||
// 先更新配置文件
|
||||
await updateGeneralConfig('startAtLogin', value);
|
||||
// 再调用系统设置API
|
||||
await updateConfig('startAtLogin', value);
|
||||
await StartupService.SetEnabled(value);
|
||||
},
|
||||
|
||||
// 窗口吸附配置相关方法
|
||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
||||
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
|
||||
|
||||
// 加载动画配置相关方法
|
||||
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
|
||||
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
|
||||
|
||||
// 标签页配置相关方法
|
||||
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
|
||||
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
|
||||
|
||||
// 更新配置相关方法
|
||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
||||
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
|
||||
|
||||
// 备份配置相关方法
|
||||
setEnableBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('enabled', value);
|
||||
},
|
||||
setAutoBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('auto_backup', value);
|
||||
},
|
||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
||||
setToken: async (value: string) => await updateBackupConfig('token', value),
|
||||
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
|
||||
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||
setUsername: (value: string) => updateConfig('username', value),
|
||||
setPassword: (value: string) => updateConfig('password', value),
|
||||
setToken: (value: string) => updateConfig('token', value),
|
||||
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
|
||||
};
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
|
||||
import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {usePanelStore} from './panelStore';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language";
|
||||
@@ -14,6 +13,7 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
|
||||
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||
import {
|
||||
createDynamicExtensions,
|
||||
@@ -29,7 +29,6 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
@@ -242,6 +241,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension({
|
||||
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||
onSave: () => configStore.saveFontSize(),
|
||||
saveDelay: 500
|
||||
});
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
|
||||
@@ -256,8 +262,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// Markdown预览扩展
|
||||
const previewExtension = markdownPreviewExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
@@ -287,12 +291,12 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
themeExtension,
|
||||
...tabExtensions,
|
||||
fontExtension,
|
||||
wheelZoomExtension,
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions,
|
||||
...httpExtension,
|
||||
previewExtension
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
@@ -635,6 +639,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
@@ -687,10 +692,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
||||
const panelStore = usePanelStore();
|
||||
panelStore.reset();
|
||||
|
||||
currentEditor.value = null;
|
||||
};
|
||||
|
||||
@@ -707,12 +708,15 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 更新前端编辑器扩展 - 应用于所有实例
|
||||
const manager = getExtensionManager();
|
||||
if (manager) {
|
||||
// 使用立即更新模式,跳过防抖
|
||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
||||
// 直接更新前端扩展至所有视图
|
||||
manager.updateExtension(id, enabled, config);
|
||||
}
|
||||
|
||||
// 重新加载扩展配置
|
||||
await extensionStore.loadExtensions();
|
||||
if (manager) {
|
||||
manager.initExtensions(extensionStore.extensions);
|
||||
}
|
||||
|
||||
await applyKeymapSettings();
|
||||
};
|
||||
@@ -781,4 +785,4 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
editorView: currentEditor,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { computed, ref } from 'vue';
|
||||
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
|
||||
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { ThemeService } from '@/../bindings/voidraft/internal/services';
|
||||
import { useConfigStore } from './configStore';
|
||||
import { useEditorStore } from './editorStore';
|
||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
||||
|
||||
type ThemeOption = { name: string; type: ThemeType };
|
||||
|
||||
const resolveThemeName = (name?: string) =>
|
||||
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
|
||||
|
||||
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
|
||||
themePresetList
|
||||
.filter(preset => preset.type === type)
|
||||
.map(preset => ({ name: preset.name, type: preset.type }));
|
||||
|
||||
const darkThemeOptions = createThemeOptions(ThemeType.ThemeTypeDark);
|
||||
const lightThemeOptions = createThemeOptions(ThemeType.ThemeTypeLight);
|
||||
|
||||
const cloneColors = (colors: ThemeColorConfig): ThemeColors =>
|
||||
JSON.parse(JSON.stringify(colors)) as ThemeColors;
|
||||
|
||||
const getPresetColors = (name: string): ThemeColors => {
|
||||
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
|
||||
const colors = cloneThemeColors(preset.colors);
|
||||
colors.themeName = name;
|
||||
return colors;
|
||||
};
|
||||
|
||||
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
|
||||
const safeName = resolveThemeName(themeName);
|
||||
try {
|
||||
const theme = await ThemeService.GetThemeByName(safeName);
|
||||
if (theme?.colors) {
|
||||
const colors = cloneColors(theme.colors);
|
||||
colors.themeName = safeName;
|
||||
return colors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme override:', error);
|
||||
}
|
||||
return getPresetColors(safeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题管理 Store
|
||||
* 职责:管理主题状态、颜色配置和预设主题列表
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 所有主题列表
|
||||
const allThemes = ref<Theme[]>([]);
|
||||
|
||||
// 当前主题的颜色配置
|
||||
const currentColors = ref<ThemeColors | null>(null);
|
||||
|
||||
// 计算属性:当前系统主题模式
|
||||
const currentTheme = computed(() =>
|
||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
|
||||
const currentTheme = computed(
|
||||
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
);
|
||||
|
||||
// 计算属性:当前是否为深色模式
|
||||
const isDarkMode = computed(() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const isDarkMode = computed(
|
||||
() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
);
|
||||
|
||||
// 计算属性:根据类型获取主题列表
|
||||
const darkThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
|
||||
);
|
||||
|
||||
const lightThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
||||
);
|
||||
|
||||
// 计算属性:当前可用的主题列表
|
||||
const availableThemes = computed(() =>
|
||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
||||
const availableThemes = computed<ThemeOption[]>(() =>
|
||||
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||
);
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||
const themeMap = {
|
||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||
[SystemThemeType.SystemThemeLight]: 'light'
|
||||
[SystemThemeType.SystemThemeLight]: 'light',
|
||||
};
|
||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||
};
|
||||
|
||||
// 从数据库加载所有主题
|
||||
const loadAllThemes = async () => {
|
||||
try {
|
||||
const themes = await ThemeService.GetAllThemes();
|
||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
||||
return allThemes.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to load themes from database:', error);
|
||||
allThemes.value = [];
|
||||
return [];
|
||||
}
|
||||
const loadThemeColors = async (themeName?: string) => {
|
||||
const targetName = resolveThemeName(
|
||||
themeName || configStore.config?.appearance?.currentTheme
|
||||
);
|
||||
currentColors.value = await fetchThemeColors(targetName);
|
||||
};
|
||||
|
||||
// 初始化主题颜色
|
||||
const initializeThemeColors = async () => {
|
||||
// 加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
// 从配置获取当前主题名称并加载
|
||||
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentThemeName);
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Theme not found: ${currentThemeName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
const initializeTheme = async () => {
|
||||
const theme = currentTheme.value;
|
||||
applyThemeToDOM(theme);
|
||||
await initializeThemeColors();
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
await loadThemeColors();
|
||||
};
|
||||
|
||||
// 设置系统主题模式(深色/浅色/自动)
|
||||
const setTheme = async (theme: SystemThemeType) => {
|
||||
await configStore.setSystemTheme(theme);
|
||||
applyThemeToDOM(theme);
|
||||
refreshEditorTheme();
|
||||
};
|
||||
|
||||
// 切换到指定的预设主题
|
||||
|
||||
const switchToTheme = async (themeName: string) => {
|
||||
const theme = allThemes.value.find(t => t.name === themeName);
|
||||
if (!theme) {
|
||||
if (!themePresetMap[themeName]) {
|
||||
console.error('Theme not found:', themeName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
|
||||
// 持久化到配置
|
||||
await loadThemeColors(themeName);
|
||||
await configStore.setCurrentTheme(themeName);
|
||||
|
||||
// 刷新编辑器
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 更新当前主题的颜色配置
|
||||
|
||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||
if (!currentColors.value) return;
|
||||
Object.assign(currentColors.value, colors);
|
||||
};
|
||||
|
||||
// 保存当前主题颜色到数据库
|
||||
|
||||
const saveCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
if (!theme) {
|
||||
throw new Error('Theme not found');
|
||||
}
|
||||
|
||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
currentColors.value.themeName = themeName;
|
||||
|
||||
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 重置当前主题为预设配置
|
||||
|
||||
const resetCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
// 调用后端重置
|
||||
await ThemeService.ResetTheme(0, currentColors.value.name);
|
||||
|
||||
// 重新加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
|
||||
if (updatedTheme) {
|
||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
||||
}
|
||||
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
await ThemeService.ResetTheme(themeName);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 刷新编辑器主题
|
||||
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
availableThemes,
|
||||
currentTheme,
|
||||
currentColors,
|
||||
isDarkMode,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
switchToTheme,
|
||||
initializeTheme,
|
||||
loadAllThemes,
|
||||
updateCurrentColors,
|
||||
saveCurrentTheme,
|
||||
resetCurrentTheme,
|
||||
refreshEditorTheme,
|
||||
applyThemeToDOM,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {ref} from 'vue';
|
||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
// 基础状态
|
||||
|
||||
@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||
import {useWindowStore} from "@/stores/windowStore";
|
||||
import {useWindowStore} from '@/stores/windowStore';
|
||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import ContextMenu from '@/views/editor/extensions/contextMenu/ContextMenu.vue';
|
||||
import {contextMenuManager} from '@/views/editor/extensions/contextMenu/manager';
|
||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||
import {translatorManager} from './extensions/translator/manager';
|
||||
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||
|
||||
// 创建滚轮缩放处理器
|
||||
const wheelHandler = createWheelZoomHandler(
|
||||
configStore.increaseFontSize,
|
||||
configStore.decreaseFontSize
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorElement.value) return;
|
||||
|
||||
// 从URL查询参数中获取documentId
|
||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||
|
||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
||||
await documentStore.initialize(urlDocumentId);
|
||||
|
||||
// 设置编辑器容器
|
||||
editorStore.setEditorContainer(editorElement.value);
|
||||
|
||||
await tabStore.initializeTab();
|
||||
|
||||
// 添加滚轮事件监听
|
||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 移除滚轮事件监听
|
||||
if (editorElement.value) {
|
||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
||||
}
|
||||
editorStore.clearAllEditors();
|
||||
|
||||
contextMenuManager.destroy();
|
||||
translatorManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<Toolbar/>
|
||||
<!-- 加载动画 -->
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
</transition>
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar/>
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu :portal-target="editorElement"/>
|
||||
<!-- 翻译器弹窗 -->
|
||||
<TranslatorDialog :portal-target="editorElement"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载动画过渡效果
|
||||
.loading-fade-enter-active,
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
@@ -98,4 +94,4 @@ onBeforeUnmount(() => {
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,30 +5,20 @@ import {
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
scrollPastEnd
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import {bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting,} from '@codemirror/language';
|
||||
import {history} from '@codemirror/commands';
|
||||
import {highlightSelectionMatches} from '@codemirror/search';
|
||||
import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
|
||||
import createEditorContextMenu from '../contextMenu';
|
||||
import {closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
|
||||
|
||||
// 基本编辑器设置
|
||||
export const createBasicSetup = (): Extension[] => {
|
||||
return [
|
||||
// 基础UI
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
dropCursor(),
|
||||
EditorView.lineWrapping,
|
||||
@@ -36,9 +26,6 @@ export const createBasicSetup = (): Extension[] => {
|
||||
// 历史记录
|
||||
history(),
|
||||
|
||||
// 代码折叠
|
||||
foldGutter(),
|
||||
|
||||
// 选择与高亮
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
@@ -52,11 +39,7 @@ export const createBasicSetup = (): Extension[] => {
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
|
||||
// 自动完成
|
||||
autocompletion(),
|
||||
|
||||
// 上下文菜单
|
||||
createEditorContextMenu(),
|
||||
scrollPastEnd(),
|
||||
|
||||
// 键盘映射
|
||||
keymap.of([
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
|
||||
import type {Text} from '@codemirror/state';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
|
||||
/**
|
||||
* 内容变化监听插件 - 集成文档和编辑器管理
|
||||
*/
|
||||
export function createContentChangePlugin() {
|
||||
return ViewPlugin.fromClass(
|
||||
class ContentChangePlugin {
|
||||
private editorStore = useEditorStore();
|
||||
private lastContent = '';
|
||||
private readonly editorStore = useEditorStore();
|
||||
private lastDoc: Text;
|
||||
private rafId: number | null = null;
|
||||
private pendingNotification = false;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.lastContent = view.state.doc.toString();
|
||||
this.lastDoc = view.state.doc;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = this.view.state.doc.toString();
|
||||
if (newContent === this.lastContent) return;
|
||||
|
||||
this.lastContent = newContent;
|
||||
|
||||
this.editorStore.onContentChange();
|
||||
|
||||
this.lastDoc = update.state.doc;
|
||||
this.scheduleNotification();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.pendingNotification = false;
|
||||
}
|
||||
|
||||
private scheduleNotification() {
|
||||
if (this.pendingNotification) return;
|
||||
|
||||
this.pendingNotification = true;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.pendingNotification = false;
|
||||
this.rafId = null;
|
||||
this.editorStore.onContentChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,61 @@
|
||||
// 处理滚轮缩放字体的事件处理函数
|
||||
export const createWheelZoomHandler = (
|
||||
increaseFontSize: () => void,
|
||||
decreaseFontSize: () => void
|
||||
) => {
|
||||
return (event: WheelEvent) => {
|
||||
// 检查是否按住了Ctrl键
|
||||
if (event.ctrlKey) {
|
||||
// 阻止默认行为(防止页面缩放)
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import type {Extension} from '@codemirror/state';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
type FontAdjuster = () => void;
|
||||
type SaveCallback = () => Promise<void> | void;
|
||||
|
||||
export interface WheelZoomOptions {
|
||||
/** 增加字体大小的回调(立即执行) */
|
||||
increaseFontSize: FontAdjuster;
|
||||
/** 减少字体大小的回调(立即执行) */
|
||||
decreaseFontSize: FontAdjuster;
|
||||
/** 保存回调(防抖执行),在滚动结束后调用 */
|
||||
onSave?: SaveCallback;
|
||||
/** 保存防抖延迟(毫秒),默认 300ms */
|
||||
saveDelay?: number;
|
||||
}
|
||||
|
||||
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
|
||||
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
|
||||
|
||||
// 如果有 onSave 回调,创建防抖版本
|
||||
const {debouncedFn: debouncedSave} = onSave
|
||||
? createDebounce(() => {
|
||||
try {
|
||||
const result = onSave();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to save font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save font size:', error);
|
||||
}
|
||||
}, {delay: saveDelay})
|
||||
: {debouncedFn: null};
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 根据滚轮方向增大或减小字体
|
||||
|
||||
// 立即更新字体大小
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动,增大字体
|
||||
increaseFontSize();
|
||||
} else {
|
||||
// 向下滚动,减小字体
|
||||
} else if (event.deltaY > 0) {
|
||||
decreaseFontSize();
|
||||
}
|
||||
|
||||
// 防抖保存
|
||||
if (debouncedSave) {
|
||||
debouncedSave();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
/**
|
||||
* 编辑器上下文菜单实现
|
||||
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
|
||||
*/
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
|
||||
import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
|
||||
import { useKeybindingStore } from "@/stores/keybindingStore";
|
||||
import {
|
||||
undo, redo
|
||||
} from "@codemirror/commands";
|
||||
import i18n from "@/i18n";
|
||||
import {useSystemStore} from "@/stores/systemStore";
|
||||
|
||||
/**
|
||||
* 菜单项类型定义
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** 菜单项显示文本 */
|
||||
label: string;
|
||||
|
||||
/** 点击时执行的命令 (如果有子菜单,可以为null) */
|
||||
command?: (view: EditorView) => boolean;
|
||||
|
||||
/** 快捷键提示文本 (可选) */
|
||||
shortcut?: string;
|
||||
|
||||
/** 子菜单项 (可选) */
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
|
||||
// 导入相关功能
|
||||
import { showContextMenu } from "./contextMenuView";
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param key 翻译键
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
function t(key: string): string {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快捷键显示文本
|
||||
* @param command 命令ID
|
||||
* @returns 快捷键显示文本
|
||||
*/
|
||||
function getShortcutText(command: KeyBindingCommand): string {
|
||||
try {
|
||||
const keybindingStore = useKeybindingStore();
|
||||
|
||||
// 如果找到该命令的快捷键配置
|
||||
const binding = keybindingStore.keyBindings.find(kb =>
|
||||
kb.command === command && kb.enabled
|
||||
);
|
||||
|
||||
if (binding && binding.key) {
|
||||
// 格式化快捷键显示
|
||||
return formatKeyBinding(binding.key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("An error occurred while getting the shortcut:", error);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化快捷键显示
|
||||
* @param keyBinding 快捷键字符串
|
||||
* @returns 格式化后的显示文本
|
||||
*/
|
||||
function formatKeyBinding(keyBinding: string): string {
|
||||
// 获取系统信息
|
||||
const systemStore = useSystemStore();
|
||||
const isMac = systemStore.isMacOS;
|
||||
|
||||
// 替换修饰键名称为更友好的显示
|
||||
return keyBinding
|
||||
.replace("Mod", isMac ? "⌘" : "Ctrl")
|
||||
.replace("Shift", isMac ? "⇧" : "Shift")
|
||||
.replace("Alt", isMac ? "⌥" : "Alt")
|
||||
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建编辑菜单项
|
||||
*/
|
||||
function createEditItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.blockCopy"),
|
||||
command: copyCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockCut"),
|
||||
command: cutCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockPaste"),
|
||||
command: pasteCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建历史操作菜单项
|
||||
*/
|
||||
function createHistoryItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.historyUndo"),
|
||||
command: undo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.historyRedo"),
|
||||
command: redo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建主菜单项
|
||||
*/
|
||||
function createMainMenuItems(): MenuItem[] {
|
||||
// 基本编辑操作放在主菜单
|
||||
const basicItems = createEditItems();
|
||||
|
||||
// 历史操作放在主菜单
|
||||
const historyItems = createHistoryItems();
|
||||
|
||||
// 构建主菜单
|
||||
return [
|
||||
...basicItems,
|
||||
...historyItems
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建编辑器上下文菜单
|
||||
*/
|
||||
export function createEditorContextMenu(): Extension {
|
||||
// 为编辑器添加右键事件处理
|
||||
return EditorView.domEventHandlers({
|
||||
contextmenu: (event, view) => {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
|
||||
// 获取菜单项
|
||||
const menuItems = createMainMenuItems();
|
||||
|
||||
// 显示上下文菜单
|
||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出
|
||||
*/
|
||||
export default createEditorContextMenu;
|
||||
@@ -1,194 +0,0 @@
|
||||
import { EditorView, Decoration } from "@codemirror/view";
|
||||
import { WidgetType } from "@codemirror/view";
|
||||
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view";
|
||||
import { Extension, StateEffect } from "@codemirror/state";
|
||||
|
||||
// 创建字体变化效果
|
||||
const fontChangeEffect = StateEffect.define<void>();
|
||||
|
||||
/**
|
||||
* 复选框小部件类
|
||||
*/
|
||||
class CheckboxWidget extends WidgetType {
|
||||
constructor(readonly checked: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: CheckboxWidget) {
|
||||
return other.checked == this.checked;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.setAttribute("aria-hidden", "true");
|
||||
wrap.className = "cm-checkbox-toggle";
|
||||
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = this.checked;
|
||||
box.tabIndex = -1;
|
||||
box.style.margin = "0";
|
||||
box.style.padding = "0";
|
||||
box.style.cursor = "pointer";
|
||||
box.style.position = "relative";
|
||||
box.style.top = "0.1em";
|
||||
box.style.marginRight = "0.5em";
|
||||
// 设置相对单位,让复选框跟随字体大小变化
|
||||
box.style.width = "1em";
|
||||
box.style.height = "1em";
|
||||
|
||||
wrap.appendChild(box);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找并创建复选框装饰
|
||||
*/
|
||||
function findCheckboxes(view: EditorView) {
|
||||
const widgets: any = [];
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
// 使用正则表达式查找 [x] 或 [ ] 模式
|
||||
const text = doc.sliceString(from, to);
|
||||
const checkboxRegex = /\[([ x])\]/gi;
|
||||
let match;
|
||||
|
||||
while ((match = checkboxRegex.exec(text)) !== null) {
|
||||
const matchPos = from + match.index;
|
||||
const matchEnd = matchPos + match[0].length;
|
||||
|
||||
// 检查前面是否有 "- " 模式
|
||||
const beforeTwoChars = matchPos >= 2 ? doc.sliceString(matchPos - 2, matchPos) : "";
|
||||
const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : "";
|
||||
|
||||
// 只有当前面是 "- " 且后面跟空格或行尾时才渲染
|
||||
if (beforeTwoChars === "- " &&
|
||||
(afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) {
|
||||
|
||||
const isChecked = match[1].toLowerCase() === "x";
|
||||
const deco = Decoration.replace({
|
||||
widget: new CheckboxWidget(isChecked),
|
||||
inclusive: false,
|
||||
});
|
||||
// 替换整个 "- [ ]" 或 "- [x]" 模式,包括前面的 "- "
|
||||
widgets.push(deco.range(matchPos - 2, matchEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换复选框状态
|
||||
*/
|
||||
function toggleCheckbox(view: EditorView, pos: number) {
|
||||
const doc = view.state.doc;
|
||||
|
||||
// 查找当前位置附近的复选框模式(需要前面有 "- ")
|
||||
for (let offset = -5; offset <= 0; offset++) {
|
||||
const checkPos = pos + offset;
|
||||
if (checkPos >= 2 && checkPos + 3 <= doc.length) {
|
||||
// 检查是否有 "- " 前缀
|
||||
const prefix = doc.sliceString(checkPos - 2, checkPos);
|
||||
const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase();
|
||||
|
||||
if (prefix === "- ") {
|
||||
let change;
|
||||
|
||||
if (text === "[x]") {
|
||||
// 替换整个 "- [x]" 为 "- [ ]"
|
||||
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [ ]" };
|
||||
} else if (text === "[ ]") {
|
||||
// 替换整个 "- [ ]" 为 "- [x]"
|
||||
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [x]" };
|
||||
}
|
||||
|
||||
if (change) {
|
||||
view.dispatch({ changes: change });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建字体变化效果的便捷函数
|
||||
export const triggerFontChange = (view: EditorView) => {
|
||||
view.dispatch({
|
||||
effects: fontChangeEffect.of(undefined)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建复选框扩展
|
||||
*/
|
||||
export function createCheckboxExtension(): Extension {
|
||||
return [
|
||||
// 主要的复选框插件
|
||||
ViewPlugin.fromClass(class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = findCheckboxes(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// 检查是否需要重新渲染复选框
|
||||
const shouldUpdate = update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
update.geometryChanged ||
|
||||
update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect)));
|
||||
|
||||
if (shouldUpdate) {
|
||||
this.decorations = findCheckboxes(update.view);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
|
||||
eventHandlers: {
|
||||
mousedown: (e, view) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) {
|
||||
const pos = view.posAtDOM(target);
|
||||
return toggleCheckbox(view, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 复选框样式
|
||||
EditorView.theme({
|
||||
".cm-checkbox-toggle": {
|
||||
display: "inline-block",
|
||||
verticalAlign: "baseline",
|
||||
},
|
||||
".cm-checkbox-toggle input[type=checkbox]": {
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
verticalAlign: "baseline",
|
||||
cursor: "pointer",
|
||||
// 确保复选框大小跟随字体
|
||||
fontSize: "inherit",
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
// 默认导出
|
||||
export const checkboxExtension = createCheckboxExtension();
|
||||
|
||||
// 导出类型和工具函数
|
||||
export {
|
||||
CheckboxWidget,
|
||||
toggleCheckbox,
|
||||
findCheckboxes
|
||||
};
|
||||
@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
||||
|
||||
/**
|
||||
* 块背景层 - 修复高度计算问题
|
||||
*
|
||||
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||
* 行的坐标也不会受影响,边界线位置正确。
|
||||
*/
|
||||
const blockLayer = layer({
|
||||
above: false,
|
||||
@@ -135,14 +139,17 @@ const blockLayer = layer({
|
||||
return;
|
||||
}
|
||||
|
||||
// view.coordsAtPos 如果编辑器不可见则返回 null
|
||||
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
|
||||
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
|
||||
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||
|
||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
||||
idx++;
|
||||
return;
|
||||
}
|
||||
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||
const toLineBlock = view.lineBlockAt(toPos);
|
||||
|
||||
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||
// 转换为视口坐标进行后续计算
|
||||
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||
|
||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||
if (idx === blocks.length - 1) {
|
||||
@@ -151,7 +158,7 @@ const blockLayer = layer({
|
||||
|
||||
// 让最后一个块直接延伸到编辑器底部
|
||||
if (contentBottom < editorHeight) {
|
||||
const extraHeight = editorHeight - contentBottom-10;
|
||||
const extraHeight = editorHeight - contentBottom - 10;
|
||||
toCoordsBottom += extraHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ const blockLineNumbers = lineNumbers({
|
||||
|
||||
/**
|
||||
* 创建代码块扩展
|
||||
* 注意:blockLineNumbers 已移至动态扩展管理,通过 ExtensionLineNumbers 控制
|
||||
*/
|
||||
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
|
||||
const {
|
||||
@@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
|
||||
// 核心状态管理
|
||||
blockState,
|
||||
|
||||
// 块内行号
|
||||
blockLineNumbers,
|
||||
|
||||
// 语言解析支持
|
||||
...getCodeBlockLanguageExtension(),
|
||||
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||
import {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
import {cssLanguage} from "@codemirror/lang-css";
|
||||
@@ -22,9 +28,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -64,6 +70,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
import {languages} from "@codemirror/language-data";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
@@ -110,7 +117,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
matchClosingTags: true,
|
||||
autoCloseTags: true
|
||||
}),
|
||||
addKeymap: true,
|
||||
codeLanguages: languages,
|
||||
|
||||
}).language.parser, ["md"], {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
|
||||
181
frontend/src/views/editor/extensions/contextMenu/ContextMenu.vue
Normal file
181
frontend/src/views/editor/extensions/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>
|
||||
141
frontend/src/views/editor/extensions/contextMenu/index.ts
Normal file
141
frontend/src/views/editor/extensions/contextMenu/index.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../codeblock/copyPaste';
|
||||
import { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
import i18n from '@/i18n';
|
||||
import { useSystemStore } from '@/stores/systemStore';
|
||||
import { showContextMenu } from './manager';
|
||||
import {
|
||||
buildRegisteredMenu,
|
||||
createMenuContext,
|
||||
registerMenuNodes
|
||||
} from './menuSchema';
|
||||
import type { MenuSchemaNode } from './menuSchema';
|
||||
|
||||
|
||||
function t(key: string): string {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
|
||||
function formatKeyBinding(keyBinding: string): string {
|
||||
const systemStore = useSystemStore();
|
||||
const isMac = systemStore.isMacOS;
|
||||
|
||||
return keyBinding
|
||||
.replace("Mod", isMac ? "Cmd" : "Ctrl")
|
||||
.replace("Shift", "Shift")
|
||||
.replace("Alt", isMac ? "Option" : "Alt")
|
||||
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
const shortcutCache = new Map<KeyBindingCommand, string>();
|
||||
|
||||
|
||||
function getShortcutText(command?: KeyBindingCommand): string {
|
||||
if (command === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cached = shortcutCache.get(command);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const keybindingStore = useKeybindingStore();
|
||||
const binding = keybindingStore.keyBindings.find(
|
||||
(kb) => kb.command === command && kb.enabled
|
||||
);
|
||||
|
||||
if (binding?.key) {
|
||||
const formatted = formatKeyBinding(binding.key);
|
||||
shortcutCache.set(command, formatted);
|
||||
return formatted;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("An error occurred while getting the shortcut:", error);
|
||||
}
|
||||
|
||||
shortcutCache.set(command, "");
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
function getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||
return [
|
||||
{
|
||||
id: "copy",
|
||||
labelKey: "keybindings.commands.blockCopy",
|
||||
command: copyCommand,
|
||||
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
|
||||
enabled: (context) => context.hasSelection
|
||||
},
|
||||
{
|
||||
id: "cut",
|
||||
labelKey: "keybindings.commands.blockCut",
|
||||
command: cutCommand,
|
||||
shortcutCommand: KeyBindingCommand.BlockCutCommand,
|
||||
visible: (context) => context.isEditable,
|
||||
enabled: (context) => context.hasSelection && context.isEditable
|
||||
},
|
||||
{
|
||||
id: "paste",
|
||||
labelKey: "keybindings.commands.blockPaste",
|
||||
command: pasteCommand,
|
||||
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
labelKey: "keybindings.commands.historyUndo",
|
||||
command: undo,
|
||||
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
labelKey: "keybindings.commands.historyRedo",
|
||||
command: redo,
|
||||
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
let builtinMenuRegistered = false;
|
||||
|
||||
function ensureBuiltinMenuRegistered(): void {
|
||||
if (builtinMenuRegistered) return;
|
||||
registerMenuNodes(getBuiltinMenuNodes());
|
||||
builtinMenuRegistered = true;
|
||||
}
|
||||
|
||||
|
||||
export function createEditorContextMenu(): Extension {
|
||||
ensureBuiltinMenuRegistered();
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
contextmenu: (event, view) => {
|
||||
event.preventDefault();
|
||||
|
||||
const context = createMenuContext(view, event as MouseEvent);
|
||||
const menuItems = buildRegisteredMenu(context, {
|
||||
translate: t,
|
||||
formatShortcut: getShortcutText
|
||||
});
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default createEditorContextMenu;
|
||||
108
frontend/src/views/editor/extensions/contextMenu/manager.ts
Normal file
108
frontend/src/views/editor/extensions/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/extensions/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/extensions/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
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import {foldService} from '@codemirror/language';
|
||||
|
||||
export const foldingOnIndent = foldService.of((state, from, to) => {
|
||||
const line = state.doc.lineAt(from); // First line
|
||||
const lines = state.doc.lines; // Number of lines in the document
|
||||
const indent = line.text.search(/\S|$/); // Indent level of the first line
|
||||
let foldStart = from; // Start of the fold
|
||||
let foldEnd = to; // End of the fold
|
||||
|
||||
// Check the next line if it is on a deeper indent level
|
||||
// If it is, check the next line and so on
|
||||
// If it is not, go on with the foldEnd
|
||||
let nextLine = line;
|
||||
while (nextLine.number < lines) {
|
||||
nextLine = state.doc.line(nextLine.number + 1); // Next line
|
||||
const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line
|
||||
|
||||
// If the next line is on a deeper indent level, add it to the fold
|
||||
if (nextIndent > indent) {
|
||||
foldEnd = nextLine.to; // Set the fold end to the end of the next line
|
||||
} else {
|
||||
break; // If the next line is not on a deeper indent level, stop
|
||||
}
|
||||
}
|
||||
|
||||
// If the fold is only one line, don't fold it
|
||||
if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the fold start to the end of the first line
|
||||
// With this, the fold will not include the first line
|
||||
foldStart = line.to;
|
||||
|
||||
// Return a fold that covers the entire indent level
|
||||
return {from: foldStart, to: foldEnd};
|
||||
});
|
||||
@@ -3,15 +3,106 @@ import {
|
||||
EditorView,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
MatchDecorator,
|
||||
WidgetType,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { Extension, ChangeSet } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
import { getNoteBlockFromPos } from '../codeblock/state';
|
||||
|
||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||
const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/g;
|
||||
|
||||
/** Stored hyperlink info for incremental updates */
|
||||
interface HyperLinkInfo {
|
||||
url: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document changes affect any of the given link regions.
|
||||
*/
|
||||
function changesAffectLinks(changes: ChangeSet, links: HyperLinkInfo[]): boolean {
|
||||
if (links.length === 0) return true;
|
||||
|
||||
let affected = false;
|
||||
changes.iterChanges((fromA, toA) => {
|
||||
if (affected) return;
|
||||
for (const link of links) {
|
||||
// Check if change overlaps with link region (with some buffer for insertions)
|
||||
if (fromA <= link.to && toA >= link.from) {
|
||||
affected = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return affected;
|
||||
}
|
||||
|
||||
// Markdown link parent nodes that should be excluded from hyperlink decoration
|
||||
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
|
||||
|
||||
/**
|
||||
* Check if a position is inside a markdown link syntax node.
|
||||
* This prevents hyperlink decorations from conflicting with markdown rendering.
|
||||
*/
|
||||
function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
|
||||
const tree = syntaxTree(view.state);
|
||||
let inLink = false;
|
||||
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (MARKDOWN_LINK_PARENTS.has(node.name)) {
|
||||
inLink = true;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return inLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hyperlinks from visible ranges only.
|
||||
* This is the key optimization - we only scan what's visible.
|
||||
*/
|
||||
function extractVisibleLinks(view: EditorView): HyperLinkInfo[] {
|
||||
const result: HyperLinkInfo[] = [];
|
||||
const seen = new Set<string>(); // Dedupe by position key
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
// Get the text for this visible range
|
||||
const rangeText = view.state.sliceDoc(from, to);
|
||||
|
||||
// Reset regex lastIndex for each range
|
||||
const regex = new RegExp(defaultRegexp.source, 'gi');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(rangeText)) !== null) {
|
||||
const linkFrom = from + match.index;
|
||||
const linkTo = linkFrom + match[0].length;
|
||||
const key = `${linkFrom}:${linkTo}`;
|
||||
|
||||
// Skip duplicates
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// Skip URLs inside markdown link syntax
|
||||
if (isInMarkdownLink(view, linkFrom, linkTo)) continue;
|
||||
|
||||
result.push({
|
||||
url: match[0],
|
||||
from: linkFrom,
|
||||
to: linkTo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface HyperLinkState {
|
||||
at: number;
|
||||
@@ -45,106 +136,80 @@ class HyperLinkIcon extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) {
|
||||
const widgets: Array<Range<Decoration>> = [];
|
||||
const doc = view.state.doc.toString();
|
||||
let match;
|
||||
|
||||
while ((match = defaultRegexp.exec(doc)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
/**
|
||||
* Build decorations from extracted link info.
|
||||
*/
|
||||
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
|
||||
const decorations: ReturnType<Decoration['range']>[] = [];
|
||||
|
||||
for (const link of links) {
|
||||
// Add text decoration
|
||||
decorations.push(Decoration.mark({
|
||||
class: 'cm-hyper-link-text'
|
||||
}).range(link.from, link.to));
|
||||
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
class: 'cm-hyper-link-text',
|
||||
attributes: {
|
||||
'data-url': match[0]
|
||||
}
|
||||
});
|
||||
widgets.push(linkMark.range(from, to));
|
||||
|
||||
const widget = Decoration.widget({
|
||||
// Add icon widget
|
||||
decorations.push(Decoration.widget({
|
||||
widget: new HyperLinkIcon({
|
||||
at: to,
|
||||
url: match[0],
|
||||
at: link.to,
|
||||
url: link.url,
|
||||
anchor,
|
||||
}),
|
||||
side: 1,
|
||||
});
|
||||
widgets.push(widget.range(to));
|
||||
}).range(link.to));
|
||||
}
|
||||
|
||||
return Decoration.set(widgets);
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
const linkDecorator = (
|
||||
regexp?: RegExp,
|
||||
matchData?: Record<string, string>,
|
||||
matchFn?: (str: string, input: string, from: number, to: number) => string,
|
||||
anchor?: HyperLinkExtensionOptions['anchor'],
|
||||
) =>
|
||||
new MatchDecorator({
|
||||
regexp: regexp || defaultRegexp,
|
||||
decorate: (add, from, to, match, view) => {
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
return;
|
||||
}
|
||||
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
urlStr = matchData[url];
|
||||
}
|
||||
const start = to,
|
||||
end = to;
|
||||
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
||||
|
||||
add(from, to, Decoration.mark({
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline',
|
||||
attributes: {
|
||||
'data-url': urlStr
|
||||
}
|
||||
}));
|
||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||
},
|
||||
});
|
||||
|
||||
export type HyperLinkExtensionOptions = {
|
||||
regexp?: RegExp;
|
||||
match?: Record<string, string>;
|
||||
handle?: (value: string, input: string, from: number, to: number) => string;
|
||||
/** Custom anchor element transformer */
|
||||
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
|
||||
/**
|
||||
* Optimized hyperlink extension with visible-range-only scanning.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* 1. Only scans visible ranges (not the entire document)
|
||||
* 2. Incremental updates: maps positions when changes don't affect links
|
||||
* 3. Caches link info to avoid redundant re-extraction
|
||||
*/
|
||||
export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) {
|
||||
return ViewPlugin.fromClass(
|
||||
class HyperLinkView {
|
||||
decorator?: MatchDecorator;
|
||||
decorations: DecorationSet;
|
||||
links: HyperLinkInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
if (regexp) {
|
||||
this.decorator = linkDecorator(regexp, match, handle, anchor);
|
||||
this.decorations = this.decorator.createDeco(view);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(view, anchor);
|
||||
}
|
||||
this.links = extractVisibleLinks(view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
if (regexp && this.decorator) {
|
||||
this.decorations = this.decorator.updateDeco(update, this.decorations);
|
||||
// Always rebuild on viewport change (new content visible)
|
||||
if (update.viewportChanged) {
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
// For document changes, check if they affect link regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectLinks(update.changes, this.links);
|
||||
|
||||
if (needsRebuild) {
|
||||
// Changes affect links, full rebuild
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(update.view, anchor);
|
||||
// Just update positions of existing decorations
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
this.links = this.links.map(link => ({
|
||||
...link,
|
||||
from: update.changes.mapPos(link.from),
|
||||
to: update.changes.mapPos(link.to)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +223,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
|
||||
export const hyperLinkStyle = EditorView.baseTheme({
|
||||
'.cm-hyper-link-text': {
|
||||
color: '#0969da',
|
||||
cursor: 'pointer',
|
||||
cursor: 'text',
|
||||
transition: 'color 0.2s ease',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#0969da',
|
||||
@@ -192,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
|
||||
'.cm-hyper-link-icon svg': {
|
||||
display: 'block',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
width: 'inherit',
|
||||
height: 'inherit',
|
||||
},
|
||||
|
||||
'.cm-editor.cm-focused .cm-hyper-link-text': {
|
||||
@@ -216,24 +281,19 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
});
|
||||
|
||||
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
||||
click: (event, view) => {
|
||||
const target = event.target as HTMLElement;
|
||||
let urlElement = target;
|
||||
|
||||
while (urlElement && !urlElement.hasAttribute('data-url')) {
|
||||
urlElement = urlElement.parentElement as HTMLElement;
|
||||
if (!urlElement || urlElement === document.body) break;
|
||||
}
|
||||
|
||||
if (urlElement && urlElement.hasAttribute('data-url')) {
|
||||
const url = urlElement.getAttribute('data-url');
|
||||
click: (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
|
||||
|
||||
if (iconElement && iconElement.hasAttribute('data-url')) {
|
||||
const url = iconElement.getAttribute('data-url');
|
||||
if (url) {
|
||||
runtime.Browser.OpenURL(url);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -242,4 +302,4 @@ export const hyperLink: Extension = [
|
||||
hyperLinkExtension(),
|
||||
hyperLinkStyle,
|
||||
hyperLinkClickHandler
|
||||
];
|
||||
];
|
||||
|
||||
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { image } from './plugins/image';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
import {html} from './plugins/html';
|
||||
import { render } from './plugins/render';
|
||||
import { Theme } from './plugins/theme';
|
||||
|
||||
/**
|
||||
* Markdown extensions.
|
||||
*/
|
||||
export const markdownExtensions: Extension = [
|
||||
headingSlugField,
|
||||
render(),
|
||||
Theme,
|
||||
image(),
|
||||
html()
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Blockquote handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
|
||||
|
||||
/**
|
||||
* Handle Blockquote node.
|
||||
*/
|
||||
export function handleBlockquote(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): boolean {
|
||||
if (ctx.seen.has(nf)) return false;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return false;
|
||||
|
||||
const startLine = ctx.view.state.doc.lineAt(nf).number;
|
||||
const endLine = ctx.view.state.doc.lineAt(nt).number;
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!ctx.processedLines.has(i)) {
|
||||
ctx.processedLines.add(i);
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
|
||||
}
|
||||
}
|
||||
|
||||
// Use TreeCursor to traverse all descendant QuoteMarks
|
||||
// getChildren() only returns direct children, but QuoteMarks may be nested
|
||||
// deeper in the syntax tree (e.g., in nested blockquotes for empty lines)
|
||||
// cursor.next() is the official Lezer API for depth-first tree traversal
|
||||
const cursor = node.cursor();
|
||||
while (cursor.next() && cursor.to <= nt) {
|
||||
if (cursor.name === 'QuoteMark') {
|
||||
ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for blockquotes.
|
||||
*/
|
||||
export const blockquoteTheme = EditorView.baseTheme({
|
||||
'.cm-blockquote': {
|
||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||
color: 'var(--cm-blockquote-color, #666)'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Code block handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' });
|
||||
const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
|
||||
const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
|
||||
const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
|
||||
|
||||
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
|
||||
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const container = document.createElement('span');
|
||||
container.className = 'cm-code-block-info';
|
||||
if (this.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'cm-code-block-lang';
|
||||
lang.textContent = this.language;
|
||||
container.append(lang);
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cm-code-block-copy-btn';
|
||||
btn.title = 'Copy';
|
||||
btn.innerHTML = ICON_COPY;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = view.state.doc.sliceString(this.from, this.to);
|
||||
const lines = text.split('\n');
|
||||
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
btn.innerHTML = ICON_CHECK;
|
||||
setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
|
||||
container.append(btn);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FencedCode / CodeBlock node.
|
||||
*/
|
||||
export function handleCodeBlock(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = ctx.view.state.doc.line(num);
|
||||
let deco = DECO_CODEBLOCK_LINE;
|
||||
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
|
||||
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
|
||||
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
|
||||
ctx.items.push({ from: line.from, to: line.from, deco });
|
||||
}
|
||||
if (!inCursor) {
|
||||
const codeInfo = node.getChild('CodeInfo');
|
||||
const codeMarks = node.getChildren('CodeMark');
|
||||
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
|
||||
ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
|
||||
if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
|
||||
for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for code blocks.
|
||||
*/
|
||||
export const codeBlockTheme = EditorView.baseTheme({
|
||||
'.cm-codeblock': {
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
fontFamily: 'inherit'
|
||||
},
|
||||
'.cm-codeblock-begin': {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative'
|
||||
},
|
||||
'.cm-codeblock-end': {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
},
|
||||
'.cm-code-block-info': {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
zIndex: '5',
|
||||
opacity: '0.5',
|
||||
transition: 'opacity 0.15s'
|
||||
},
|
||||
'.cm-code-block-info:hover': { opacity: '1' },
|
||||
'.cm-code-block-lang': {
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
textTransform: 'lowercase',
|
||||
userSelect: 'none'
|
||||
},
|
||||
'.cm-code-block-copy-btn': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15em',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s, background 0.15s'
|
||||
},
|
||||
'.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' },
|
||||
'.cm-code-block-copy-btn svg': { width: '1em', height: '1em' }
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Emoji handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import { emojies } from '@/common/constant/emojies';
|
||||
|
||||
class EmojiWidget extends WidgetType {
|
||||
constructor(readonly emoji: string, readonly name: string) { super(); }
|
||||
eq(other: EmojiWidget) { return other.emoji === this.emoji; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-emoji';
|
||||
span.textContent = this.emoji;
|
||||
span.title = `:${this.name}:`;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Emoji node (:emoji:).
|
||||
*/
|
||||
export function handleEmoji(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const nameNode = node.getChild('EmojiName');
|
||||
if (!nameNode) return;
|
||||
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
|
||||
const emojiChar = emojies[name];
|
||||
if (emojiChar) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for emoji.
|
||||
*/
|
||||
export const emojiTheme = EditorView.baseTheme({
|
||||
'.cm-emoji': {
|
||||
cursor: 'default',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Footnote handlers and theme.
|
||||
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
/** Extended context for footnotes */
|
||||
export interface FootnoteContext extends BuildContext {
|
||||
definitionIds: Set<string>;
|
||||
pendingRefs: { from: number; to: number; id: string; index: number }[];
|
||||
pendingInlines: { from: number; to: number; index: number }[];
|
||||
seenIds: Map<string, number>;
|
||||
inlineFootnoteIdx: number;
|
||||
}
|
||||
|
||||
class FootnoteRefWidget extends WidgetType {
|
||||
constructor(readonly index: number, readonly hasDefinition: boolean) { super(); }
|
||||
eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined');
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class InlineFootnoteWidget extends WidgetType {
|
||||
constructor(readonly index: number) { super(); }
|
||||
eq(other: InlineFootnoteWidget) { return this.index === other.index; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class FootnoteDefLabelWidget extends WidgetType {
|
||||
constructor(readonly id: string) { super(); }
|
||||
eq(other: FootnoteDefLabelWidget) { return this.id === other.id; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-def-label';
|
||||
span.textContent = `[${this.id}]`;
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FootnoteDefinition node.
|
||||
*/
|
||||
export function handleFootnoteDefinition(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
if (marks.length >= 2 && labelNode) {
|
||||
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
ctx.definitionIds.add(id);
|
||||
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FootnoteReference node.
|
||||
*/
|
||||
export function handleFootnoteReference(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
const marks = node.getChildren('FootnoteReferenceMark');
|
||||
if (labelNode && marks.length >= 2) {
|
||||
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
|
||||
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle InlineFootnote node.
|
||||
*/
|
||||
export function handleInlineFootnote(
|
||||
ctx: FootnoteContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
const marks = node.getChildren('InlineFootnoteMark');
|
||||
if (contentNode && marks.length >= 2) {
|
||||
ctx.inlineFootnoteIdx++;
|
||||
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending footnote refs after all definitions are collected.
|
||||
*/
|
||||
export function processPendingFootnotes(ctx: FootnoteContext): void {
|
||||
for (const ref of ctx.pendingRefs) {
|
||||
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
|
||||
}
|
||||
for (const inline of ctx.pendingInlines) {
|
||||
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for footnotes.
|
||||
*/
|
||||
export const footnoteTheme = EditorView.baseTheme({
|
||||
'.cm-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
||||
borderRadius: '3px'
|
||||
},
|
||||
'.cm-footnote-ref-undefined': {
|
||||
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
||||
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))'
|
||||
},
|
||||
'.cm-inline-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
||||
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
||||
borderRadius: '3px'
|
||||
},
|
||||
'.cm-footnote-def-label': {
|
||||
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||
fontWeight: '600'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Heading handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
|
||||
|
||||
/**
|
||||
* Handle ATXHeading node (# Heading).
|
||||
*/
|
||||
export function handleATXHeading(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const header = node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SetextHeading node (underline style).
|
||||
*/
|
||||
export function handleSetextHeading(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const headerMarks = node.getChildren('HeaderMark');
|
||||
for (const mark of headerMarks) {
|
||||
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for headings.
|
||||
*/
|
||||
export const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
fontSize: '0'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Horizontal rule handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
class HorizontalRuleWidget extends WidgetType {
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-horizontal-rule-widget';
|
||||
const hr = document.createElement('hr');
|
||||
hr.className = 'cm-horizontal-rule';
|
||||
span.appendChild(hr);
|
||||
return span;
|
||||
}
|
||||
eq() { return true; }
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
const hrWidget = new HorizontalRuleWidget();
|
||||
|
||||
/**
|
||||
* Handle HorizontalRule node.
|
||||
*/
|
||||
export function handleHorizontalRule(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (!inCursor) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for horizontal rules.
|
||||
*/
|
||||
export const horizontalRuleTheme = EditorView.baseTheme({
|
||||
'.cm-horizontal-rule-widget': {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
verticalAlign: 'middle'
|
||||
},
|
||||
'.cm-horizontal-rule': {
|
||||
width: '100%',
|
||||
height: '0',
|
||||
border: 'none',
|
||||
borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))',
|
||||
margin: '0.5em 0'
|
||||
}
|
||||
});
|
||||
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 html = (): 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,181 @@
|
||||
/**
|
||||
* Inline styles handlers and theme.
|
||||
* Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' });
|
||||
const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' });
|
||||
const DECO_INSERT = Decoration.mark({ class: 'cm-insert' });
|
||||
const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' });
|
||||
const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' });
|
||||
|
||||
const MARK_TYPES: Record<string, string> = {
|
||||
'Emphasis': 'EmphasisMark',
|
||||
'StrongEmphasis': 'EmphasisMark',
|
||||
'Strikethrough': 'StrikethroughMark'
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Highlight node (==text==).
|
||||
*/
|
||||
export function handleHighlight(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('HighlightMark');
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle InlineCode node (`code`).
|
||||
*/
|
||||
export function handleInlineCode(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const text = ctx.view.state.doc.sliceString(nf, nt);
|
||||
let i = 0; while (i < text.length && text[i] === '`') i++;
|
||||
let j = text.length - 1; while (j >= 0 && text[j] === '`') j--;
|
||||
const codeStart = nf + i, codeEnd = nf + j + 1;
|
||||
if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration });
|
||||
if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE });
|
||||
if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Emphasis, StrongEmphasis, Strikethrough nodes.
|
||||
*/
|
||||
export function handleEmphasis(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
typeName: string,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const markType = MARK_TYPES[typeName];
|
||||
if (markType) {
|
||||
const marks = node.getChildren(markType);
|
||||
for (const mark of marks) {
|
||||
ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Insert node (++text++).
|
||||
*/
|
||||
export function handleInsert(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('InsertMark');
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Superscript / Subscript nodes.
|
||||
*/
|
||||
export function handleScript(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
typeName: string,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const isSuper = typeName === 'Superscript';
|
||||
const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark';
|
||||
const marks = node.getChildren(markName);
|
||||
if (marks.length >= 2) {
|
||||
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||
if (marks[0].to < marks[marks.length - 1].from) {
|
||||
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT });
|
||||
}
|
||||
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for inline styles.
|
||||
*/
|
||||
export const inlineStylesTheme = EditorView.baseTheme({
|
||||
'.cm-highlight': {
|
||||
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.cm-inline-code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.1rem 0.3rem',
|
||||
fontFamily: 'var(--voidraft-font-mono)'
|
||||
},
|
||||
'.cm-insert': {
|
||||
textDecoration: 'underline'
|
||||
},
|
||||
'.cm-superscript': {
|
||||
verticalAlign: 'super',
|
||||
fontSize: '0.75em',
|
||||
color: 'inherit'
|
||||
},
|
||||
'.cm-subscript': {
|
||||
verticalAlign: 'sub',
|
||||
fontSize: '0.75em',
|
||||
color: 'inherit'
|
||||
}
|
||||
});
|
||||
111
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
111
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Link handler with underline and clickable icon.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
|
||||
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
|
||||
|
||||
/** Link text decoration with underline */
|
||||
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
|
||||
|
||||
/** Link icon widget - clickable to open URL */
|
||||
class LinkIconWidget extends WidgetType {
|
||||
constructor(readonly url: string) { super(); }
|
||||
eq(other: LinkIconWidget) { return this.url === other.url; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-md-link-icon';
|
||||
span.textContent = '🔗';
|
||||
span.title = this.url;
|
||||
span.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
runtime.Browser.OpenURL(this.url);
|
||||
};
|
||||
return span;
|
||||
}
|
||||
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle URL node (within Link).
|
||||
*/
|
||||
export function handleURL(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
|
||||
if (ctx.seen.has(parent.from)) return;
|
||||
ctx.seen.add(parent.from);
|
||||
ranges.push([parent.from, parent.to]);
|
||||
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
|
||||
|
||||
// Get link text node (content between first [ and ])
|
||||
const linkText = parent.getChild('LinkLabel');
|
||||
const marks = parent.getChildren('LinkMark');
|
||||
const linkTitle = parent.getChild('LinkTitle');
|
||||
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
|
||||
|
||||
if (closeBracket && nf < closeBracket.from) return;
|
||||
|
||||
// Get URL for the icon
|
||||
const url = ctx.view.state.sliceDoc(nf, nt);
|
||||
|
||||
// Add underline decoration to link text
|
||||
if (linkText) {
|
||||
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
|
||||
}
|
||||
|
||||
// Hide markdown syntax marks
|
||||
for (const m of marks) {
|
||||
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// Hide URL
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
|
||||
// Hide link title if present
|
||||
if (linkTitle) {
|
||||
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
|
||||
}
|
||||
|
||||
// Add clickable icon widget after link text (at close bracket position)
|
||||
if (closeBracket) {
|
||||
ctx.items.push({
|
||||
from: closeBracket.from,
|
||||
to: closeBracket.from,
|
||||
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
|
||||
priority: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for markdown links.
|
||||
*/
|
||||
export const linkTheme = EditorView.baseTheme({
|
||||
'.cm-md-link-text': {
|
||||
color: 'var(--cm-link-color, #0969da)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
cursor: 'text'
|
||||
},
|
||||
'.cm-md-link-icon': {
|
||||
cursor: 'pointer',
|
||||
marginLeft: '0.2em',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s ease',
|
||||
'&:hover': {
|
||||
opacity: '1'
|
||||
}
|
||||
}
|
||||
});
|
||||
121
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
121
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* List handlers and theme.
|
||||
* Handles: ListMark (bullets), Task (checkboxes)
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
|
||||
const BULLET_RE = /^[-+*]$/;
|
||||
|
||||
class ListBulletWidget extends WidgetType {
|
||||
constructor(readonly bullet: string) { super(); }
|
||||
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-list-bullet';
|
||||
span.textContent = '•';
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskCheckboxWidget extends WidgetType {
|
||||
constructor(readonly checked: boolean, readonly pos: number) { super(); }
|
||||
eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; }
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.className = 'cm-task-checkbox';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checked;
|
||||
checkbox.tabIndex = -1;
|
||||
checkbox.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
|
||||
});
|
||||
wrap.appendChild(checkbox);
|
||||
return wrap;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ListMark node (bullet markers).
|
||||
*/
|
||||
export function handleListMark(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const parent = node.parent;
|
||||
if (parent?.getChild('Task')) return;
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const bullet = ctx.view.state.sliceDoc(nf, nt);
|
||||
if (BULLET_RE.test(bullet)) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Task node (checkboxes).
|
||||
*/
|
||||
export function handleTask(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
const listItem = node.parent;
|
||||
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||
const listMark = listItem.getChild('ListMark');
|
||||
const taskMarker = node.getChild('TaskMarker');
|
||||
if (!listMark || !taskMarker) return;
|
||||
if (ctx.seen.has(listMark.from)) return;
|
||||
ctx.seen.add(listMark.from);
|
||||
ranges.push([listMark.from, taskMarker.to]);
|
||||
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
|
||||
|
||||
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||
if (isChecked) {
|
||||
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
|
||||
}
|
||||
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for lists.
|
||||
*/
|
||||
export const listTheme = EditorView.baseTheme({
|
||||
'.cm-list-bullet': {
|
||||
color: 'var(--cm-list-bullet-color, inherit)'
|
||||
},
|
||||
'.cm-task-checked': {
|
||||
textDecoration: 'line-through',
|
||||
opacity: '0.6'
|
||||
},
|
||||
'.cm-task-checkbox': {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'baseline'
|
||||
},
|
||||
'.cm-task-checkbox input': {
|
||||
cursor: 'pointer',
|
||||
margin: '0',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
position: 'relative',
|
||||
top: '0.1em'
|
||||
}
|
||||
});
|
||||
163
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
163
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Math handlers and theme.
|
||||
* Handles: InlineMath, BlockMath
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { invisibleDecoration, RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
class InlineMathWidget extends WidgetType {
|
||||
constructor(readonly latex: string) { super(); }
|
||||
eq(other: InlineMathWidget) { return this.latex === other.latex; }
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-math';
|
||||
try {
|
||||
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
|
||||
} catch (e) {
|
||||
span.textContent = this.latex;
|
||||
span.title = e instanceof Error ? e.message : 'Render error';
|
||||
}
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
class BlockMathWidget extends WidgetType {
|
||||
constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
|
||||
eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-block-math-container';
|
||||
container.style.height = `${this.lineCount * this.lineHeight}px`;
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'cm-block-math';
|
||||
try {
|
||||
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
|
||||
} catch (e) {
|
||||
inner.textContent = this.latex;
|
||||
inner.title = e instanceof Error ? e.message : 'Render error';
|
||||
}
|
||||
container.appendChild(inner);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
|
||||
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
|
||||
|
||||
/**
|
||||
* Handle InlineMath node ($...$).
|
||||
*/
|
||||
export function handleInlineMath(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle BlockMath node ($$...$$).
|
||||
*/
|
||||
export function handleBlockMath(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (inCursor) return;
|
||||
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
if (marks.length >= 2) {
|
||||
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
if (lineCount > 1) {
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
|
||||
}
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
} else {
|
||||
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
|
||||
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for math.
|
||||
*/
|
||||
export const mathTheme = EditorView.baseTheme({
|
||||
'.cm-inline-math': {
|
||||
display: 'inline',
|
||||
verticalAlign: 'baseline'
|
||||
},
|
||||
'.cm-inline-math .katex': {
|
||||
fontSize: 'inherit'
|
||||
},
|
||||
'.cm-block-math-container': {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1'
|
||||
},
|
||||
'.cm-block-math': {
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
'.cm-block-math .katex-display': {
|
||||
margin: '0'
|
||||
},
|
||||
'.cm-block-math .katex': {
|
||||
fontSize: '1.1em'
|
||||
},
|
||||
'.cm-line.cm-block-math-line': {
|
||||
color: 'transparent !important',
|
||||
caretColor: 'transparent'
|
||||
},
|
||||
'.cm-line.cm-block-math-line span': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden span': {
|
||||
color: 'transparent !important'
|
||||
},
|
||||
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important'
|
||||
}
|
||||
});
|
||||
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||
import { DecoItem } from './types';
|
||||
import { blockState } from '@/views/editor/extensions/codeblock/state';
|
||||
import { Block } from '@/views/editor/extensions/codeblock/types';
|
||||
|
||||
import { handleBlockquote } from './blockquote';
|
||||
import { handleCodeBlock } from './code-block';
|
||||
import { handleATXHeading, handleSetextHeading } from './heading';
|
||||
import { handleHorizontalRule } from './horizontal-rule';
|
||||
import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles';
|
||||
import { handleURL } from './link';
|
||||
import { handleListMark, handleTask } from './list';
|
||||
import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote';
|
||||
import { handleInlineMath, handleBlockMath } from './math';
|
||||
import { handleEmoji } from './emoji';
|
||||
import { handleTable } from './table';
|
||||
|
||||
|
||||
interface BuildResult {
|
||||
decorations: DecorationSet;
|
||||
trackedRanges: RangeTuple[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get markdown block ranges from visible ranges.
|
||||
* Only returns ranges that are within 'md' language blocks.
|
||||
*/
|
||||
function getMdBlockRanges(view: EditorView): { from: number; to: number }[] {
|
||||
const blocks = view.state.field(blockState, false);
|
||||
if (!blocks || blocks.length === 0) {
|
||||
// No blocks, treat entire document as md
|
||||
return view.visibleRanges.map(r => ({ from: r.from, to: r.to }));
|
||||
}
|
||||
|
||||
// Filter md blocks
|
||||
const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md');
|
||||
if (mdBlocks.length === 0) return [];
|
||||
|
||||
// Intersect visible ranges with md block content ranges
|
||||
const result: { from: number; to: number }[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (const block of mdBlocks) {
|
||||
const intersectFrom = Math.max(from, block.content.from);
|
||||
const intersectTo = Math.min(to, block.content.to);
|
||||
if (intersectFrom < intersectTo) {
|
||||
result.push({ from: intersectFrom, to: intersectTo });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
||||
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||
|
||||
// Create context with footnote extensions
|
||||
const ctx: FootnoteContext = {
|
||||
view,
|
||||
items: [],
|
||||
selRange: [selFrom, selTo],
|
||||
seen: new Set(),
|
||||
processedLines: new Set(),
|
||||
contentWidth: view.contentDOM.clientWidth - 10,
|
||||
lineHeight: view.defaultLineHeight,
|
||||
// Footnote state
|
||||
definitionIds: new Set(),
|
||||
pendingRefs: [],
|
||||
pendingInlines: [],
|
||||
seenIds: new Map(),
|
||||
inlineFootnoteIdx: 0
|
||||
};
|
||||
|
||||
const trackedRanges: RangeTuple[] = [];
|
||||
|
||||
// Only traverse md blocks (not other language blocks like js, py, etc.)
|
||||
const mdRanges = getMdBlockRanges(view);
|
||||
|
||||
// Single traversal - dispatch to all handlers
|
||||
for (const { from, to } of mdRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from, to,
|
||||
enter: (nodeRef: SyntaxNodeRef) => {
|
||||
const { type, from: nf, to: nt, node } = nodeRef;
|
||||
const typeName = type.name;
|
||||
const inCursor = checkRangeOverlap([nf, nt], ctx.selRange);
|
||||
|
||||
// Dispatch to handlers
|
||||
if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges);
|
||||
if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges);
|
||||
if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||
if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||
if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges);
|
||||
if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges);
|
||||
if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process pending footnotes
|
||||
processPendingFootnotes(ctx);
|
||||
|
||||
// Sort and filter
|
||||
ctx.items.sort((a, b) => {
|
||||
if (a.from !== b.from) return a.from - b.from;
|
||||
if (a.to !== b.to) return a.to - b.to;
|
||||
return (a.priority || 0) - (b.priority || 0);
|
||||
});
|
||||
|
||||
const result: DecoItem[] = [];
|
||||
let replaceMaxTo = -1;
|
||||
for (const item of ctx.items) {
|
||||
const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration;
|
||||
if (item.from === item.to) {
|
||||
result.push(item);
|
||||
} else if (isReplace) {
|
||||
if (item.from >= replaceMaxTo) {
|
||||
result.push(item);
|
||||
replaceMaxTo = item.to;
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true),
|
||||
trackedRanges
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
class MarkdownRenderPlugin {
|
||||
decorations: DecorationSet;
|
||||
private trackedRanges: RangeTuple[] = [];
|
||||
private lastSelFrom = -1;
|
||||
private lastSelTo = -1;
|
||||
private lastWidth = 0;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
const result = buildDecorationsAndRanges(view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
const { from, to } = view.state.selection.main;
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
this.lastWidth = view.contentDOM.clientWidth;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const { docChanged, viewportChanged, selectionSet, geometryChanged } = update;
|
||||
const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1;
|
||||
if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth;
|
||||
|
||||
// Full rebuild for structural changes
|
||||
if (docChanged || viewportChanged || geometryChanged || widthChanged) {
|
||||
const result = buildDecorationsAndRanges(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
const { from, to } = update.state.selection.main;
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection change handling with fine-grained detection
|
||||
if (selectionSet) {
|
||||
const { from, to } = update.state.selection.main;
|
||||
const isPointCursor = from === to;
|
||||
const wasPointCursor = this.lastSelFrom === this.lastSelTo;
|
||||
|
||||
// Optimization: Point cursor moving within same tracked range - no rebuild needed
|
||||
if (isPointCursor && wasPointCursor) {
|
||||
const oldRange = this.findContainingRange(this.lastSelFrom);
|
||||
const newRange = this.findContainingRange(from);
|
||||
|
||||
if (this.rangeSame(oldRange, newRange)) {
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if overlapping ranges changed
|
||||
const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo);
|
||||
const newOverlaps = this.getOverlappingRanges(from, to);
|
||||
|
||||
this.lastSelFrom = from;
|
||||
this.lastSelTo = to;
|
||||
|
||||
if (!this.rangesSame(oldOverlaps, newOverlaps)) {
|
||||
const result = buildDecorationsAndRanges(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.trackedRanges = result.trackedRanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findContainingRange(pos: number): RangeTuple | null {
|
||||
for (const range of this.trackedRanges) {
|
||||
if (pos >= range[0] && pos <= range[1]) return range;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return a[0] === b[0] && a[1] === b[1];
|
||||
}
|
||||
|
||||
private getOverlappingRanges(from: number, to: number): RangeTuple[] {
|
||||
const selRange: RangeTuple = [from, to];
|
||||
return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange));
|
||||
}
|
||||
|
||||
private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
export const render = (): Extension => [renderPlugin];
|
||||
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
251
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Table handler and theme.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { foldedRanges } from '@codemirror/language';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { BuildContext } from './types';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
type CellAlign = 'left' | 'center' | 'right';
|
||||
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
|
||||
|
||||
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||
|
||||
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
||||
const CODE_RE = /`([^`]+)`/g;
|
||||
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const STRIKE_RE = /~~(.+?)~~/g;
|
||||
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
let html = text;
|
||||
if (HTML_TAG_RE.test(text)) {
|
||||
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||
if (!html.includes('<code>')) html = html.replace(CODE_RE, '<code>$1</code>');
|
||||
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
|
||||
} else {
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseRowText(rowText: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let start = 0, end = rowText.length;
|
||||
while (start < end && rowText.charCodeAt(start) <= 32) start++;
|
||||
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
|
||||
if (start < end && rowText.charCodeAt(start) === 124) start++;
|
||||
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
|
||||
let cellStart = start;
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i === end || rowText.charCodeAt(i) === 124) {
|
||||
let cs = cellStart, ce = i;
|
||||
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
|
||||
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
|
||||
cells.push(rowText.substring(cs, ce));
|
||||
cellStart = i + 1;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseAlignment(text: string): CellAlign {
|
||||
const len = text.length;
|
||||
if (len === 0) return 'left';
|
||||
let start = 0, end = len - 1;
|
||||
while (start < len && text.charCodeAt(start) === 32) start++;
|
||||
while (end > start && text.charCodeAt(end) === 32) end--;
|
||||
if (start > end) return 'left';
|
||||
const hasLeft = text.charCodeAt(start) === 58;
|
||||
const hasRight = text.charCodeAt(end) === 58;
|
||||
if (hasLeft && hasRight) return 'center';
|
||||
if (hasRight) return 'right';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
class TableWidget extends WidgetType {
|
||||
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
|
||||
eq(other: TableWidget) {
|
||||
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
|
||||
if (this.data === other.data) return true;
|
||||
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
|
||||
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
|
||||
for (let i = 0; i < this.data.rows.length; i++) {
|
||||
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
|
||||
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-table-container';
|
||||
container.style.height = `${this.visualHeight}px`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'cm-table-wrapper';
|
||||
wrapper.style.maxWidth = `${this.contentWidth}px`;
|
||||
wrapper.style.maxHeight = `${this.visualHeight}px`;
|
||||
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
|
||||
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
|
||||
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '"')}">${renderInlineMarkdown(h)}</th>`).join('');
|
||||
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '"')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
|
||||
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
||||
container.appendChild(wrapper);
|
||||
return container;
|
||||
}
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||
const folded = foldedRanges(view.state);
|
||||
const cursor = folded.iter();
|
||||
while (cursor.value) {
|
||||
if (cursor.from < to && cursor.to > from) return true;
|
||||
cursor.next();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Table node.
|
||||
*/
|
||||
export function handleTable(
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
): void {
|
||||
if (ctx.seen.has(nf)) return;
|
||||
ctx.seen.add(nf);
|
||||
ranges.push([nf, nt]);
|
||||
if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
|
||||
|
||||
const headerNode = node.getChild('TableHeader');
|
||||
if (!headerNode) return;
|
||||
const headers: string[] = [];
|
||||
const alignments: CellAlign[] = [];
|
||||
const rows: string[][] = [];
|
||||
const headerCells = headerNode.getChildren('TableCell');
|
||||
if (headerCells.length > 0) {
|
||||
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
|
||||
} else {
|
||||
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
|
||||
}
|
||||
if (headers.length === 0) return;
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
if (child.type.name === 'TableDelimiter') {
|
||||
const delimText = ctx.view.state.sliceDoc(child.from, child.to);
|
||||
if (delimText.includes('-')) {
|
||||
for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
|
||||
break;
|
||||
}
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
while (alignments.length < headers.length) alignments.push('left');
|
||||
for (const rowNode of node.getChildren('TableRow')) {
|
||||
const rowData: string[] = [];
|
||||
const cells = rowNode.getChildren('TableCell');
|
||||
if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
|
||||
else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
|
||||
while (rowData.length < headers.length) rowData.push('');
|
||||
rows.push(rowData);
|
||||
}
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
const startBlock = ctx.view.lineBlockAt(nf);
|
||||
const endBlock = ctx.view.lineBlockAt(nt);
|
||||
const visualHeight = endBlock.bottom - startBlock.top;
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
|
||||
}
|
||||
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for tables.
|
||||
*/
|
||||
export const tableTheme = EditorView.baseTheme({
|
||||
'.cm-table-container': {
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
'.cm-table-wrapper': {
|
||||
display: 'inline-block',
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
'.cm-table': {
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
backgroundColor: 'var(--cm-table-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
||||
color: 'var(--text-primary) !important'
|
||||
},
|
||||
'.cm-table th, .cm-table td': {
|
||||
padding: '0 8px',
|
||||
border: 'none',
|
||||
color: 'inherit !important',
|
||||
verticalAlign: 'middle',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '300px'
|
||||
},
|
||||
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
|
||||
'.cm-table th': {
|
||||
backgroundColor: 'var(--cm-table-header-bg)',
|
||||
fontWeight: '600',
|
||||
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
|
||||
},
|
||||
'.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||
'.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
|
||||
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
|
||||
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { textAlign: 'center' },
|
||||
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { textAlign: 'right' },
|
||||
'.cm-table code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'var(--voidraft-font-mono)'
|
||||
},
|
||||
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
|
||||
'.cm-table a:hover': { textDecoration: 'underline' },
|
||||
'.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
|
||||
'.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
|
||||
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { color: 'transparent !important' }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Unified theme - combines all markdown plugin themes.
|
||||
*/
|
||||
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { blockquoteTheme } from './blockquote';
|
||||
import { codeBlockTheme } from './code-block';
|
||||
import { headingTheme } from './heading';
|
||||
import { horizontalRuleTheme } from './horizontal-rule';
|
||||
import { inlineStylesTheme } from './inline-styles';
|
||||
import { linkTheme } from './link';
|
||||
import { listTheme } from './list';
|
||||
import { footnoteTheme } from './footnote';
|
||||
import { mathTheme } from './math';
|
||||
import { emojiTheme } from './emoji';
|
||||
import { tableTheme } from './table';
|
||||
|
||||
/**
|
||||
* All markdown themes combined.
|
||||
*/
|
||||
export const Theme: Extension = [
|
||||
blockquoteTheme,
|
||||
codeBlockTheme,
|
||||
headingTheme,
|
||||
horizontalRuleTheme,
|
||||
inlineStylesTheme,
|
||||
linkTheme,
|
||||
listTheme,
|
||||
footnoteTheme,
|
||||
mathTheme,
|
||||
emojiTheme,
|
||||
tableTheme
|
||||
];
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared types for unified markdown plugin handlers.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { RangeTuple } from '../util';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
|
||||
/** Decoration item to be added */
|
||||
export interface DecoItem {
|
||||
from: number;
|
||||
to: number;
|
||||
deco: Decoration;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** Shared build context passed to all handlers */
|
||||
export interface BuildContext {
|
||||
view: EditorView;
|
||||
items: DecoItem[];
|
||||
selRange: RangeTuple;
|
||||
seen: Set<number>;
|
||||
processedLines: Set<number>;
|
||||
contentWidth: number;
|
||||
lineHeight: number;
|
||||
}
|
||||
|
||||
/** Handler function type */
|
||||
export type NodeHandler = (
|
||||
ctx: BuildContext,
|
||||
nf: number,
|
||||
nt: number,
|
||||
node: SyntaxNode,
|
||||
inCursor: boolean,
|
||||
ranges: RangeTuple[]
|
||||
) => void | boolean;
|
||||
@@ -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;
|
||||
}
|
||||
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Emoji extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses :emoji_name: syntax for emoji shortcodes.
|
||||
*
|
||||
* Syntax: :emoji_name: → renders as actual emoji character
|
||||
*
|
||||
* Examples:
|
||||
* - :smile: → 😄
|
||||
* - :heart: → ❤️
|
||||
* - :+1: → 👍
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode } from '../util';
|
||||
import { emojies } from '@/common/constant/emojies';
|
||||
|
||||
/**
|
||||
* Pre-computed lookup table for emoji name characters.
|
||||
* Valid characters: a-z, 0-9, _, +, -
|
||||
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||
*/
|
||||
const EMOJI_NAME_CHARS = new Uint8Array(128);
|
||||
// Initialize lookup table
|
||||
for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
|
||||
for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
|
||||
EMOJI_NAME_CHARS[95] = 1; // _
|
||||
EMOJI_NAME_CHARS[43] = 1; // +
|
||||
EMOJI_NAME_CHARS[45] = 1; // -
|
||||
|
||||
/**
|
||||
* O(1) check if a character is valid for emoji name.
|
||||
* @param code - ASCII character code
|
||||
* @returns True if valid emoji name character
|
||||
*/
|
||||
function isEmojiNameChar(code: number): boolean {
|
||||
return code < 128 && EMOJI_NAME_CHARS[code] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse emoji :name: syntax.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at :)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseEmoji(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: : + name + : = at least 3 chars, name must be non-empty
|
||||
if (end < pos + 2) return -1;
|
||||
|
||||
// Track content for validation
|
||||
let hasContent = false;
|
||||
const contentStart = pos + 1;
|
||||
|
||||
// Search for closing :
|
||||
for (let i = contentStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Found closing :
|
||||
if (char === CharCode.Colon) {
|
||||
// Must have content
|
||||
if (!hasContent) return -1;
|
||||
|
||||
// Extract and validate emoji name
|
||||
const name = cx.slice(contentStart, i).toLowerCase();
|
||||
|
||||
// Check if this is a valid emoji
|
||||
if (!emojies[name]) return -1;
|
||||
|
||||
// Create element with marks and name
|
||||
return cx.addElement(cx.elt('Emoji', pos, i + 1, [
|
||||
cx.elt('EmojiMark', pos, contentStart),
|
||||
cx.elt('EmojiName', contentStart, i),
|
||||
cx.elt('EmojiMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
|
||||
// Newline not allowed in emoji
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Space not allowed in emoji name
|
||||
if (char === CharCode.Space || char === CharCode.Tab) return -1;
|
||||
|
||||
// Validate name character using O(1) lookup table
|
||||
// Also check for uppercase A-Z (65-90) and convert mentally
|
||||
const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
|
||||
if (isEmojiNameChar(lowerChar)) {
|
||||
hasContent = true;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - Emoji: The container node for emoji shortcode
|
||||
* - EmojiMark: The : delimiter marks
|
||||
* - EmojiName: The emoji name part
|
||||
*/
|
||||
export const Emoji: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Emoji' },
|
||||
{ name: 'EmojiMark' },
|
||||
{ name: 'EmojiName' }
|
||||
],
|
||||
parseInline: [
|
||||
{
|
||||
name: 'Emoji',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with :
|
||||
if (next !== CharCode.Colon) return -1;
|
||||
return parseEmoji(cx, pos);
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts with other syntax
|
||||
after: 'Emphasis'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default Emoji;
|
||||
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; // 标记面板正在关闭
|
||||
}
|
||||
|
||||
107
frontend/src/views/editor/extensions/rainbowBracket/index.ts
Normal file
107
frontend/src/views/editor/extensions/rainbowBracket/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Range } from '@codemirror/state';
|
||||
|
||||
// 彩虹颜色数组
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
|
||||
|
||||
const OPEN_BRACKETS = new Set(['(', '[', '{']);
|
||||
const CLOSE_BRACKETS = new Set([')', ']', '}']);
|
||||
const BRACKET_PAIRS: Record<string, string> = { ')': '(', ']': '[', '}': '{' };
|
||||
|
||||
/**
|
||||
* 彩虹括号插件
|
||||
*/
|
||||
class RainbowBracketsView {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
private buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const doc = view.state.doc;
|
||||
|
||||
const visibleRanges = view.visibleRanges;
|
||||
if (visibleRanges.length === 0) {
|
||||
return Decoration.set([]);
|
||||
}
|
||||
|
||||
const visibleFrom = visibleRanges[0].from;
|
||||
const visibleTo = visibleRanges[visibleRanges.length - 1].to;
|
||||
|
||||
// 阶段1: 预计算到可视范围开始位置的栈状态(只维护栈,不创建装饰)
|
||||
const stack: { char: string; from: number }[] = [];
|
||||
|
||||
for (let pos = 0; pos < visibleFrom && pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
stack.push({ char, from: pos });
|
||||
} else if (CLOSE_BRACKETS.has(char)) {
|
||||
const open = stack.pop();
|
||||
if (open && open.char !== BRACKET_PAIRS[char]) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段2: 处理可视范围内的括号(创建装饰)
|
||||
for (let pos = visibleFrom; pos < visibleTo && pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
if (OPEN_BRACKETS.has(char)) {
|
||||
const depth = stack.length;
|
||||
stack.push({ char, from: pos });
|
||||
|
||||
// 添加开括号装饰
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (CLOSE_BRACKETS.has(char)) {
|
||||
const open = stack.pop();
|
||||
|
||||
if (open && open.char === BRACKET_PAIRS[char]) {
|
||||
const depth = stack.length;
|
||||
const color = COLORS[depth % COLORS.length];
|
||||
|
||||
// 添加闭括号装饰
|
||||
decorations.push(
|
||||
Decoration.mark({ class: `cm-rainbow-bracket-${color}` }).range(pos, pos + 1)
|
||||
);
|
||||
} else if (open) {
|
||||
stack.push(open); // 不匹配,放回
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
|
||||
}
|
||||
}
|
||||
|
||||
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function rainbowBrackets() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
// 为每种颜色定义CSS样式
|
||||
'.cm-rainbow-bracket-red': { color: '#FF6B6B' },
|
||||
'.cm-rainbow-bracket-orange': { color: '#FF9E6B' },
|
||||
'.cm-rainbow-bracket-yellow': { color: '#FFD166' },
|
||||
'.cm-rainbow-bracket-green': { color: '#06D6A0' },
|
||||
'.cm-rainbow-bracket-blue': { color: '#118AB2' },
|
||||
'.cm-rainbow-bracket-indigo': { color: '#6B5B95' },
|
||||
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { EditorView, Decoration, ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Range } from '@codemirror/state';
|
||||
|
||||
// 生成彩虹颜色数组
|
||||
function generateColors(): string[] {
|
||||
return ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'
|
||||
];
|
||||
}
|
||||
|
||||
class RainbowBracketsView {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.getBracketDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = this.getBracketDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
private getBracketDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const stack: { type: string; from: number }[] = [];
|
||||
const colors = generateColors();
|
||||
|
||||
// 遍历文档内容
|
||||
for (let pos = 0; pos < doc.length; pos++) {
|
||||
const char = doc.sliceString(pos, pos + 1);
|
||||
|
||||
// 遇到开括号
|
||||
if (char === '(' || char === '[' || char === '{') {
|
||||
stack.push({ type: char, from: pos });
|
||||
}
|
||||
// 遇到闭括号
|
||||
else if (char === ')' || char === ']' || char === '}') {
|
||||
const open = stack.pop();
|
||||
const matchingBracket = this.getMatchingBracket(char);
|
||||
|
||||
if (open && open.type === matchingBracket) {
|
||||
const color = colors[stack.length % colors.length];
|
||||
const className = `cm-rainbow-bracket-${color}`;
|
||||
|
||||
// 为开括号和闭括号添加装饰
|
||||
decorations.push(
|
||||
Decoration.mark({ class: className }).range(open.from, open.from + 1),
|
||||
Decoration.mark({ class: className }).range(pos, pos + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations.sort((a, b) => a.from - b.from));
|
||||
}
|
||||
|
||||
private getMatchingBracket(closingBracket: string): string | null {
|
||||
switch (closingBracket) {
|
||||
case ')': return '(';
|
||||
case ']': return '[';
|
||||
case '}': return '{';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function rainbowBracketsExtension() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
// 为每种颜色定义CSS样式
|
||||
'.cm-rainbow-bracket-red': { color: '#FF6B6B' },
|
||||
'.cm-rainbow-bracket-orange': { color: '#FF9E6B' },
|
||||
'.cm-rainbow-bracket-yellow': { color: '#FFD166' },
|
||||
'.cm-rainbow-bracket-green': { color: '#06D6A0' },
|
||||
'.cm-rainbow-bracket-blue': { color: '#118AB2' },
|
||||
'.cm-rainbow-bracket-indigo': { color: '#6B5B95' },
|
||||
'.cm-rainbow-bracket-violet': { color: '#9B5DE5' },
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { EditorState, StateEffect, StateField, Facet } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||
|
||||
// 高亮配置接口
|
||||
export interface TextHighlightConfig {
|
||||
backgroundColor?: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: Required<TextHighlightConfig> = {
|
||||
backgroundColor: '#FFD700', // 金黄色
|
||||
opacity: 0.3
|
||||
};
|
||||
|
||||
// 定义添加和移除高亮的状态效果
|
||||
const addHighlight = StateEffect.define<{from: number, to: number}>({
|
||||
map: ({from, to}, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to)
|
||||
})
|
||||
});
|
||||
|
||||
const removeHighlight = StateEffect.define<{from: number, to: number}>({
|
||||
map: ({from, to}, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 配置facet
|
||||
const highlightConfigFacet = Facet.define<TextHighlightConfig, Required<TextHighlightConfig>>({
|
||||
combine: (configs) => {
|
||||
const result = { ...DEFAULT_CONFIG };
|
||||
for (const config of configs) {
|
||||
if (config.backgroundColor !== undefined) {
|
||||
result.backgroundColor = config.backgroundColor;
|
||||
}
|
||||
if (config.opacity !== undefined) {
|
||||
result.opacity = config.opacity;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// 创建高亮装饰
|
||||
function createHighlightMark(config: Required<TextHighlightConfig>): Decoration {
|
||||
const { backgroundColor, opacity } = config;
|
||||
const rgbaColor = hexToRgba(backgroundColor, opacity);
|
||||
|
||||
return Decoration.mark({
|
||||
attributes: {
|
||||
style: `background-color: ${rgbaColor}; border-radius: 2px;`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 将十六进制颜色转换为RGBA
|
||||
function hexToRgba(hex: string, opacity: number): string {
|
||||
// 移除 # 符号
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
// 处理短格式 (如 #FFF -> #FFFFFF)
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map(char => char + char).join('');
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
// 存储高亮范围的状态字段 - 支持撤销
|
||||
const highlightState = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, tr) {
|
||||
// 映射现有装饰以适应文档变化
|
||||
decorations = decorations.map(tr.changes);
|
||||
|
||||
// 处理效果
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(addHighlight)) {
|
||||
const { from, to } = effect.value;
|
||||
const config = tr.state.facet(highlightConfigFacet);
|
||||
const highlightMark = createHighlightMark(config);
|
||||
|
||||
decorations = decorations.update({
|
||||
add: [highlightMark.range(from, to)]
|
||||
});
|
||||
}
|
||||
else if (effect.is(removeHighlight)) {
|
||||
const { from, to } = effect.value;
|
||||
decorations = decorations.update({
|
||||
filter: (rangeFrom, rangeTo) => {
|
||||
// 移除与指定范围重叠的装饰
|
||||
return !(rangeFrom < to && rangeTo > from);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
provide: field => EditorView.decorations.from(field)
|
||||
});
|
||||
|
||||
// 查找与给定范围重叠的所有高亮
|
||||
function findHighlightsInRange(state: EditorState, from: number, to: number): Array<{from: number, to: number}> {
|
||||
const highlights: Array<{from: number, to: number}> = [];
|
||||
|
||||
state.field(highlightState).between(from, to, (rangeFrom, rangeTo) => {
|
||||
if (rangeFrom < to && rangeTo > from) {
|
||||
highlights.push({ from: rangeFrom, to: rangeTo });
|
||||
}
|
||||
});
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
// 查找指定位置包含的高亮
|
||||
function findHighlightsAt(state: EditorState, pos: number): Array<{from: number, to: number}> {
|
||||
const highlights: Array<{from: number, to: number}> = [];
|
||||
|
||||
state.field(highlightState).between(pos, pos, (from, to) => {
|
||||
highlights.push({ from, to });
|
||||
});
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
// 添加高亮范围
|
||||
function addHighlightRange(view: EditorView, from: number, to: number): boolean {
|
||||
if (from === to) return false; // 不高亮空选择
|
||||
|
||||
// 检查是否已经完全高亮
|
||||
const overlappingHighlights = findHighlightsInRange(view.state, from, to);
|
||||
const isFullyHighlighted = overlappingHighlights.some(range =>
|
||||
range.from <= from && range.to >= to
|
||||
);
|
||||
|
||||
if (isFullyHighlighted) return false;
|
||||
|
||||
view.dispatch({
|
||||
effects: addHighlight.of({from, to})
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 移除高亮范围
|
||||
function removeHighlightRange(view: EditorView, from: number, to: number): boolean {
|
||||
const highlights = findHighlightsInRange(view.state, from, to);
|
||||
|
||||
if (highlights.length === 0) return false;
|
||||
|
||||
view.dispatch({
|
||||
effects: removeHighlight.of({from, to})
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 切换高亮状态
|
||||
function toggleHighlight(view: EditorView): boolean {
|
||||
const selection = view.state.selection.main;
|
||||
|
||||
// 如果有选择文本
|
||||
if (!selection.empty) {
|
||||
const {from, to} = selection;
|
||||
|
||||
// 检查选择范围内是否已经有高亮
|
||||
const highlights = findHighlightsInRange(view.state, from, to);
|
||||
|
||||
if (highlights.length > 0) {
|
||||
// 如果已有高亮,则移除
|
||||
return removeHighlightRange(view, from, to);
|
||||
} else {
|
||||
// 如果没有高亮,则添加
|
||||
return addHighlightRange(view, from, to);
|
||||
}
|
||||
}
|
||||
// 如果是光标
|
||||
else {
|
||||
const pos = selection.from;
|
||||
const highlightsAtCursor = findHighlightsAt(view.state, pos);
|
||||
|
||||
if (highlightsAtCursor.length > 0) {
|
||||
// 移除光标位置的高亮
|
||||
const highlight = highlightsAtCursor[0];
|
||||
return removeHighlightRange(view, highlight.from, highlight.to);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 导出文本高亮切换命令,供快捷键系统使用
|
||||
export const textHighlightToggleCommand = toggleHighlight;
|
||||
|
||||
// 创建文本高亮扩展
|
||||
export function createTextHighlighter(config: TextHighlightConfig = {}) {
|
||||
return [
|
||||
highlightConfigFacet.of(config),
|
||||
highlightState
|
||||
];
|
||||
}
|
||||
@@ -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="Copy"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</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 { createTranslationTooltip } from './tooltip';
|
||||
import {
|
||||
TranslatorConfig,
|
||||
DEFAULT_TRANSLATION_CONFIG,
|
||||
TRANSLATION_ICON_SVG
|
||||
} from '@/common/constant/translation';
|
||||
import { translatorManager } from './manager';
|
||||
import { 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 {
|
||||
private config: TranslatorConfig;
|
||||
private setTranslationTooltip: StateEffectType<Tooltip | null>;
|
||||
private translationTooltipField: StateField<readonly Tooltip[]>;
|
||||
private translationButtonField: StateField<readonly Tooltip[]>;
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showTranslatorDialog(view);
|
||||
});
|
||||
|
||||
constructor(config?: Partial<TranslatorConfig>) {
|
||||
// 初始化配置
|
||||
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 [];
|
||||
return { dom };
|
||||
}
|
||||
|
||||
// 检查文本长度条件
|
||||
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)" }
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建翻译扩展
|
||||
*/
|
||||
export function createTranslatorExtension(config?: Partial<TranslatorConfig>): Extension {
|
||||
const translatorExtension = new TranslatorExtension(config);
|
||||
return translatorExtension.createExtension();
|
||||
function showTranslatorDialog(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 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;
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
import { getSearchQuery, RegExpCursor, SearchCursor, SearchQuery, setSearchQuery } from "@codemirror/search";
|
||||
import { CharCategory, EditorState, findClusterBreak, Text } from "@codemirror/state";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import crelt from "crelt";
|
||||
|
||||
type Match = { from: number, to: number };
|
||||
|
||||
export class CustomSearchPanel {
|
||||
dom!: HTMLElement;
|
||||
searchField!: HTMLInputElement;
|
||||
replaceField!: HTMLInputElement;
|
||||
matchCountField!: HTMLElement;
|
||||
currentMatch!: number;
|
||||
matches!: Match[];
|
||||
replaceVisibile: boolean = false;
|
||||
matchWord: boolean = false;
|
||||
matchCase: boolean = false;
|
||||
useRegex: boolean = false;
|
||||
|
||||
private totalMatches: number = 0;
|
||||
searchCursor?: SearchCursor;
|
||||
regexCursor?: RegExpCursor;
|
||||
|
||||
|
||||
private codicon: Record<string, string> = {
|
||||
"downChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/></svg>',
|
||||
"rightChevron": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/></svg>',
|
||||
"matchCase": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>',
|
||||
"wholeWord": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>',
|
||||
"regex": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>',
|
||||
"prevMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>',
|
||||
"nextMatch": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>',
|
||||
"close": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>',
|
||||
"replace": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>',
|
||||
"replaceAll": '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>',
|
||||
};
|
||||
|
||||
constructor(readonly view: EditorView) {
|
||||
try {
|
||||
this.view = view;
|
||||
this.commit = this.commit.bind(this);
|
||||
|
||||
// 从现有查询状态初始化匹配选项
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query) {
|
||||
this.matchCase = query.caseSensitive;
|
||||
this.matchWord = query.wholeWord;
|
||||
this.useRegex = query.regexp;
|
||||
}
|
||||
|
||||
this.buildUI();
|
||||
this.setVisibility(false);
|
||||
|
||||
// 挂载到.cm-editor根容器,这样搜索框不会随内容滚动
|
||||
const editor = this.view.dom.closest('.cm-editor') || this.view.dom.querySelector('.cm-editor');
|
||||
if (editor) {
|
||||
editor.appendChild(this.dom);
|
||||
} else {
|
||||
// 如果当前DOM就是.cm-editor或者找不到.cm-editor,直接挂载到view.dom
|
||||
this.view.dom.appendChild(this.dom);
|
||||
}
|
||||
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`ERROR: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateMatchCount(): void {
|
||||
if (this.totalMatches > 0) {
|
||||
this.matchCountField.textContent = `${this.currentMatch + 1} of ${this.totalMatches}`;
|
||||
} else {
|
||||
this.matchCountField.textContent = `0 of 0`;
|
||||
}
|
||||
}
|
||||
|
||||
private setSearchFieldError(hasError: boolean): void {
|
||||
if (hasError) {
|
||||
this.searchField.classList.add('error');
|
||||
} else {
|
||||
this.searchField.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
private charBefore(str: string, index: number) {
|
||||
return str.slice(findClusterBreak(str, index, false), index);
|
||||
}
|
||||
|
||||
private charAfter(str: string, index: number) {
|
||||
return str.slice(index, findClusterBreak(str, index));
|
||||
}
|
||||
|
||||
private stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) {
|
||||
return (from: number, to: number, buf: string, bufPos: number) => {
|
||||
if (bufPos > from || bufPos + buf.length < to) {
|
||||
bufPos = Math.max(0, from - 2);
|
||||
buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2));
|
||||
}
|
||||
return (categorizer(this.charBefore(buf, from - bufPos)) != CharCategory.Word ||
|
||||
categorizer(this.charAfter(buf, from - bufPos)) != CharCategory.Word) &&
|
||||
(categorizer(this.charAfter(buf, to - bufPos)) != CharCategory.Word ||
|
||||
categorizer(this.charBefore(buf, to - bufPos)) != CharCategory.Word);
|
||||
};
|
||||
}
|
||||
|
||||
private regexpWordTest(categorizer: (ch: string) => CharCategory) {
|
||||
return (_from: number, _to: number, match: RegExpExecArray) =>
|
||||
!match[0].length ||
|
||||
(categorizer(this.charBefore(match.input, match.index)) != CharCategory.Word ||
|
||||
categorizer(this.charAfter(match.input, match.index)) != CharCategory.Word) &&
|
||||
(categorizer(this.charAfter(match.input, match.index + match[0].length)) != CharCategory.Word ||
|
||||
categorizer(this.charBefore(match.input, match.index + match[0].length)) != CharCategory.Word);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds all occurrences of a query, logs the total count,
|
||||
* and selects the closest one to the current cursor position.
|
||||
*
|
||||
* @param view - The CodeMirror editor view.
|
||||
* @param query - The search string to look for.
|
||||
*/
|
||||
findMatchesAndSelectClosest(state: EditorState): void {
|
||||
const cursorPos = state.selection.main.head;
|
||||
const query = getSearchQuery(state);
|
||||
|
||||
if (query.regexp) {
|
||||
try {
|
||||
this.regexCursor = new RegExpCursor(state.doc, query.search);
|
||||
this.searchCursor = undefined;
|
||||
} catch (error) {
|
||||
// 如果正则表达式无效,清空匹配结果并显示错误状态
|
||||
console.warn("Invalid regular expression:", query.search, error);
|
||||
this.matches = [];
|
||||
this.currentMatch = 0;
|
||||
this.totalMatches = 0;
|
||||
this.updateMatchCount();
|
||||
this.regexCursor = undefined;
|
||||
this.searchCursor = undefined;
|
||||
this.setSearchFieldError(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const cursor = new SearchCursor(state.doc, query.search);
|
||||
if (cursor !== this.searchCursor) {
|
||||
this.searchCursor = cursor;
|
||||
this.regexCursor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
this.matches = [];
|
||||
|
||||
if (this.searchCursor) {
|
||||
const matchWord = this.stringWordTest(state.doc, state.charCategorizer(state.selection.main.head));
|
||||
|
||||
while (!this.searchCursor.done) {
|
||||
this.searchCursor.next();
|
||||
if (!this.searchCursor.done) {
|
||||
const { from, to } = this.searchCursor.value;
|
||||
|
||||
if (!query.wholeWord || matchWord(from, to, "", 0)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (this.regexCursor) {
|
||||
try {
|
||||
const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head));
|
||||
|
||||
while (!this.regexCursor.done) {
|
||||
this.regexCursor.next();
|
||||
|
||||
if (!this.regexCursor.done) {
|
||||
const { from, to, match } = this.regexCursor.value;
|
||||
|
||||
if (!query.wholeWord || matchWord(from, to, match)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果正则表达式执行时出错,清空匹配结果
|
||||
console.warn("Error executing regular expression:", error);
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.currentMatch = 0;
|
||||
this.totalMatches = this.matches.length;
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
this.updateMatchCount();
|
||||
this.setSearchFieldError(false);
|
||||
return;
|
||||
}
|
||||
// Find the match closest to the current cursor
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (let i = 0; i < this.totalMatches; i++) {
|
||||
const dist = Math.abs(this.matches[i].from - cursorPos);
|
||||
if (dist < closestDistance) {
|
||||
closestDistance = dist;
|
||||
this.currentMatch = i;
|
||||
}
|
||||
}
|
||||
this.updateMatchCount();
|
||||
this.setSearchFieldError(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (!match) return;
|
||||
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
commit() {
|
||||
try {
|
||||
const newQuery = new SearchQuery({
|
||||
search: this.searchField.value,
|
||||
replace: this.replaceField.value,
|
||||
caseSensitive: this.matchCase,
|
||||
regexp: this.useRegex,
|
||||
wholeWord: this.matchWord,
|
||||
});
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (!newQuery.eq(query)) {
|
||||
this.view.dispatch({
|
||||
effects: setSearchQuery.of(newQuery)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果创建SearchQuery时出错(通常是无效的正则表达式),记录错误但不中断程序
|
||||
console.warn("Error creating search query:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private svgIcon(name: keyof CustomSearchPanel['codicon']): HTMLDivElement {
|
||||
const div = crelt("div", {},
|
||||
) as HTMLDivElement;
|
||||
|
||||
div.innerHTML = this.codicon[name];
|
||||
return div;
|
||||
}
|
||||
|
||||
public toggleReplace() {
|
||||
this.replaceVisibile = !this.replaceVisibile;
|
||||
const replaceBar = this.dom.querySelector(".replace-bar") as HTMLElement;
|
||||
const replaceButtons = this.dom.querySelector(".replace-buttons") as HTMLElement;
|
||||
const toggleIcon = this.dom.querySelector(".toggle-replace") as HTMLElement;
|
||||
if (replaceBar && toggleIcon && replaceButtons) {
|
||||
replaceBar.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
replaceButtons.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
toggleIcon.innerHTML = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron").innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
public showReplace() {
|
||||
if (!this.replaceVisibile) {
|
||||
this.toggleReplace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public toggleCase() {
|
||||
this.matchCase = !this.matchCase;
|
||||
const toggleIcon = this.dom.querySelector(".case-sensitive-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public toggleWord() {
|
||||
this.matchWord = !this.matchWord;
|
||||
const toggleIcon = this.dom.querySelector(".whole-word-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public toggleRegex() {
|
||||
this.useRegex = !this.useRegex;
|
||||
const toggleIcon = this.dom.querySelector(".regex-toggle") as HTMLElement;
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle("active");
|
||||
}
|
||||
this.commit();
|
||||
// 重新搜索以应用新的匹配规则
|
||||
setTimeout(() => {
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public matchPrevious() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
this.currentMatch = (this.currentMatch - 1 + this.totalMatches) % this.totalMatches;
|
||||
this.updateMatchCount();
|
||||
|
||||
// 直接跳转到匹配位置,不调用原生函数
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public matchNext() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
this.currentMatch = (this.currentMatch + 1) % this.totalMatches;
|
||||
this.updateMatchCount();
|
||||
|
||||
// 直接跳转到匹配位置,不调用原生函数
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
this.view.dispatch({
|
||||
selection: { anchor: match.from, head: match.to },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public findReplaceMatch() {
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
this.replace();
|
||||
} else {
|
||||
this.matchNext();
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.view.dispatch({ effects: SearchVisibilityEffect.of(false) });
|
||||
}
|
||||
|
||||
public replace() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
const match = this.matches[this.currentMatch];
|
||||
if (match) {
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
// 执行替换
|
||||
this.view.dispatch({
|
||||
changes: { from: match.from, to: match.to, insert: query.replace },
|
||||
selection: { anchor: match.from, head: match.from + query.replace.length }
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public replaceAll() {
|
||||
if (this.totalMatches === 0) return;
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
if (query.replace) {
|
||||
// 从后往前替换,避免位置偏移问题
|
||||
const changes = this.matches
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(match => ({
|
||||
from: match.from,
|
||||
to: match.to,
|
||||
insert: query.replace
|
||||
}));
|
||||
|
||||
this.view.dispatch({
|
||||
changes: changes
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUI(): void {
|
||||
|
||||
const query = getSearchQuery(this.view.state);
|
||||
|
||||
this.searchField = crelt("input", {
|
||||
value: query?.search ?? "",
|
||||
type: "text",
|
||||
placeholder: "Find",
|
||||
class: "find-input",
|
||||
"main-field": "true",
|
||||
onchange: this.commit,
|
||||
onkeyup: this.commit
|
||||
}) as HTMLInputElement;
|
||||
|
||||
this.replaceField = crelt("input", {
|
||||
value: query?.replace ?? "",
|
||||
type: "text",
|
||||
placeholder: "Replace",
|
||||
class: "replace-input",
|
||||
onchange: this.commit,
|
||||
onkeyup: this.commit
|
||||
}) as HTMLInputElement;
|
||||
|
||||
|
||||
const caseField = this.svgIcon("matchCase");
|
||||
caseField.className = "case-sensitive-toggle";
|
||||
caseField.title = "Match Case (Alt+C)";
|
||||
caseField.addEventListener("click", () => {
|
||||
this.toggleCase();
|
||||
});
|
||||
|
||||
const wordField = this.svgIcon("wholeWord");
|
||||
wordField.className = "whole-word-toggle";
|
||||
wordField.title = "Match Whole Word (Alt+W)";
|
||||
wordField.addEventListener("click", () => {
|
||||
this.toggleWord();
|
||||
});
|
||||
|
||||
|
||||
const reField = this.svgIcon("regex");
|
||||
reField.className = "regex-toggle";
|
||||
reField.title = "Use Regular Expression (Alt+R)";
|
||||
reField.addEventListener("click", () => {
|
||||
this.toggleRegex();
|
||||
});
|
||||
|
||||
const toggleReplaceIcon = this.svgIcon(this.replaceVisibile ? "downChevron" : "rightChevron");
|
||||
toggleReplaceIcon.className = "toggle-replace";
|
||||
toggleReplaceIcon.addEventListener("click", () => {
|
||||
this.toggleReplace();
|
||||
});
|
||||
|
||||
this.matchCountField = crelt("span", { class: "match-count" }, "0 of 0");
|
||||
|
||||
const prevMatchButton = this.svgIcon("prevMatch");
|
||||
prevMatchButton.className = "prev-match";
|
||||
prevMatchButton.title = "Previous Match (Shift+Enter)";
|
||||
prevMatchButton.addEventListener("click", () => {
|
||||
this.matchPrevious();
|
||||
});
|
||||
|
||||
const nextMatchButton = this.svgIcon("nextMatch");
|
||||
nextMatchButton.className = "next-match";
|
||||
nextMatchButton.title = "Next Match (Enter)";
|
||||
nextMatchButton.addEventListener("click", () => {
|
||||
this.matchNext();
|
||||
});
|
||||
|
||||
const closeButton = this.svgIcon("close");
|
||||
closeButton.className = "close";
|
||||
closeButton.title = "Close (Escape)";
|
||||
closeButton.addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
const replaceButton = this.svgIcon("replace");
|
||||
replaceButton.className = "replace-button";
|
||||
replaceButton.title = "Replace (Enter)";
|
||||
replaceButton.addEventListener("click", () => {
|
||||
this.replace();
|
||||
});
|
||||
|
||||
const replaceAllButton = this.svgIcon("replaceAll");
|
||||
replaceAllButton.className = "replace-button";
|
||||
replaceAllButton.title = "Replace All (Ctrl+Alt+Enter)";
|
||||
replaceAllButton.addEventListener("click", () => {
|
||||
this.replaceAll();
|
||||
});
|
||||
|
||||
const resizeHandle = crelt("div", { class: "resize-handle" });
|
||||
|
||||
const toggleSection = crelt("div", { class: "toggle-section" },
|
||||
resizeHandle,
|
||||
toggleReplaceIcon
|
||||
);
|
||||
|
||||
|
||||
|
||||
let startX: number;
|
||||
let startWidth: number;
|
||||
|
||||
const startResize = (e: MouseEvent) => {
|
||||
startX = e.clientX;
|
||||
startWidth = this.dom.offsetWidth;
|
||||
document.addEventListener('mousemove', resize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
const resize = (e: MouseEvent) => {
|
||||
const width = startWidth + (startX - e.clientX);
|
||||
const container = this.dom as HTMLDivElement;
|
||||
container.style.width = `${Math.max(420, Math.min(800, width))}px`;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
document.removeEventListener('mousemove', resize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener('mousedown', startResize);
|
||||
|
||||
const searchControls = crelt("div", { class: "search-controls" },
|
||||
caseField,
|
||||
wordField,
|
||||
reField
|
||||
);
|
||||
|
||||
const searchBar = crelt("div", { class: "search-bar" },
|
||||
this.searchField,
|
||||
searchControls
|
||||
);
|
||||
|
||||
const replaceBar = crelt("div", {
|
||||
class: "replace-bar",
|
||||
},
|
||||
this.replaceField
|
||||
);
|
||||
|
||||
replaceBar.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
|
||||
const inputSection = crelt("div", { class: "input-section" },
|
||||
searchBar,
|
||||
replaceBar
|
||||
);
|
||||
|
||||
const searchIcons = crelt("div", { class: "search-icons" },
|
||||
prevMatchButton,
|
||||
nextMatchButton,
|
||||
closeButton
|
||||
);
|
||||
|
||||
const searchButtons = crelt("div", { class: "button-group" },
|
||||
this.matchCountField,
|
||||
searchIcons
|
||||
);
|
||||
|
||||
const replaceButtons = crelt("div", {
|
||||
class: "replace-buttons",
|
||||
},
|
||||
replaceButton,
|
||||
replaceAllButton
|
||||
);
|
||||
|
||||
replaceButtons.style.display = this.replaceVisibile ? "flex" : "none";
|
||||
|
||||
const actionSection = crelt("div", { class: "actions-section" },
|
||||
searchButtons,
|
||||
replaceButtons
|
||||
);
|
||||
|
||||
this.dom = crelt("div", {
|
||||
class: "find-replace-container",
|
||||
"data-keymap-scope": "search"
|
||||
},
|
||||
toggleSection,
|
||||
inputSection,
|
||||
actionSection
|
||||
);
|
||||
|
||||
// 根据当前状态设置按钮的active状态
|
||||
if (this.matchCase) {
|
||||
caseField.classList.add("active");
|
||||
}
|
||||
if (this.matchWord) {
|
||||
wordField.classList.add("active");
|
||||
}
|
||||
if (this.useRegex) {
|
||||
reField.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
setVisibility(visible: boolean) {
|
||||
this.dom.style.display = visible ? "flex" : "none";
|
||||
if (visible) {
|
||||
// 使用 setTimeout 确保DOM已经渲染
|
||||
setTimeout(() => {
|
||||
this.searchField.focus();
|
||||
this.searchField.select();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.searchField.select();
|
||||
}
|
||||
|
||||
destroy?(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get pos() { return 80; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import {
|
||||
getSearchQuery,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceNext,
|
||||
replaceAll,
|
||||
closeSearchPanel,
|
||||
SearchCursor,
|
||||
RegExpCursor
|
||||
} from '@codemirror/search';
|
||||
|
||||
const props = defineProps<{ view: EditorView }>();
|
||||
|
||||
// State - options will be initialized from getSearchQuery (uses config defaults)
|
||||
const replaceVisible = ref(false);
|
||||
const searchText = ref('');
|
||||
const replaceText = ref('');
|
||||
const matchCase = ref(false); // Will be set from query in onMounted
|
||||
const matchWord = ref(false); // Will be set from query in onMounted
|
||||
const useRegex = ref(false); // Will be set from query in onMounted
|
||||
const hasError = ref(false);
|
||||
const totalMatches = ref(0);
|
||||
const currentMatchIndex = ref(0);
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Computed
|
||||
const matchCountText = computed(() => {
|
||||
if (hasError.value) return 'Invalid regex';
|
||||
if (!searchText.value || totalMatches.value === 0) return 'No results';
|
||||
return `${currentMatchIndex.value} of ${totalMatches.value}`;
|
||||
});
|
||||
|
||||
const hasMatches = computed(() => totalMatches.value > 0);
|
||||
const canReplace = computed(() => searchText.value.length > 0 && hasMatches.value);
|
||||
|
||||
// Core functions
|
||||
function commit() {
|
||||
try {
|
||||
const query = new SearchQuery({
|
||||
search: searchText.value,
|
||||
replace: replaceText.value,
|
||||
caseSensitive: matchCase.value,
|
||||
regexp: useRegex.value,
|
||||
wholeWord: matchWord.value,
|
||||
});
|
||||
props.view.dispatch({ effects: setSearchQuery.of(query) });
|
||||
hasError.value = false;
|
||||
updateMatchCount();
|
||||
} catch {
|
||||
hasError.value = true;
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMatchCount() {
|
||||
const query = getSearchQuery(props.view.state);
|
||||
if (!query.search) {
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cursorPos = props.view.state.selection.main.from;
|
||||
let total = 0, current = 0, foundCurrent = false;
|
||||
|
||||
const cursor = query.regexp
|
||||
? new RegExpCursor(props.view.state.doc, query.search, { ignoreCase: !query.caseSensitive })
|
||||
: new SearchCursor(props.view.state.doc, query.search, 0, props.view.state.doc.length,
|
||||
query.caseSensitive ? undefined : (s: string) => s.toLowerCase());
|
||||
|
||||
while (!cursor.next().done) {
|
||||
total++;
|
||||
if (!foundCurrent && cursor.value.from >= cursorPos) {
|
||||
current = total;
|
||||
foundCurrent = true;
|
||||
}
|
||||
if (total >= 9999) break;
|
||||
}
|
||||
|
||||
totalMatches.value = total;
|
||||
currentMatchIndex.value = foundCurrent ? current : Math.min(1, total);
|
||||
} catch {
|
||||
totalMatches.value = currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Actions - scrollToMatch is handled by search config in plugin.ts
|
||||
const doFindNext = () => { findNext(props.view); updateMatchCount(); };
|
||||
const doFindPrevious = () => { findPrevious(props.view); updateMatchCount(); };
|
||||
const doReplace = () => { if (canReplace.value) { replaceNext(props.view); updateMatchCount(); } };
|
||||
const doReplaceAll = () => { if (canReplace.value) { replaceAll(props.view); updateMatchCount(); } };
|
||||
|
||||
const toggleOption = (opt: 'case' | 'word' | 'regex') => {
|
||||
const map = { case: matchCase, word: matchWord, regex: useRegex };
|
||||
map[opt].value = !map[opt].value;
|
||||
commit();
|
||||
};
|
||||
|
||||
const close = () => closeSearchPanel(props.view);
|
||||
|
||||
// Keyboard handlers
|
||||
const onSearchKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? doFindPrevious() : doFindNext(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
};
|
||||
|
||||
const onReplaceKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); (e.ctrlKey || e.metaKey) ? doReplaceAll() : doReplace(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
};
|
||||
|
||||
// Resize
|
||||
let resizeState = { startX: 0, startWidth: 0 };
|
||||
const onResize = (e: MouseEvent) => {
|
||||
if (!containerRef.value) return;
|
||||
containerRef.value.style.width = `${Math.max(411, Math.min(800, resizeState.startWidth + resizeState.startX - e.clientX))}px`;
|
||||
};
|
||||
const stopResize = () => { document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', stopResize); };
|
||||
const startResize = (e: MouseEvent) => {
|
||||
resizeState = { startX: e.clientX, startWidth: containerRef.value?.offsetWidth ?? 411 };
|
||||
document.addEventListener('mousemove', onResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
// Watch for input changes
|
||||
watch([searchText, replaceText], commit);
|
||||
|
||||
// Init - read options from query (defaults from search config), pre-populate search text
|
||||
onMounted(() => {
|
||||
// Always read options from query - this uses defaults from search() config
|
||||
const query = getSearchQuery(props.view.state);
|
||||
matchCase.value = query.caseSensitive;
|
||||
matchWord.value = query.wholeWord;
|
||||
useRegex.value = query.regexp;
|
||||
|
||||
// Pre-populate search/replace text
|
||||
if (query?.search) {
|
||||
searchText.value = query.search;
|
||||
replaceText.value = query.replace;
|
||||
} else {
|
||||
// Pre-populate from selection if no existing search
|
||||
const { main } = props.view.state.selection;
|
||||
if (!main.empty) {
|
||||
const text = props.view.state.doc.sliceString(main.from, main.to);
|
||||
if (!text.includes('\n') && text.length < 200) searchText.value = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus input
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
searchInput.value?.select();
|
||||
if (searchText.value) commit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="search-panel" @keydown.esc="close">
|
||||
<div class="resize-handle" @mousedown="startResize" />
|
||||
|
||||
<div class="toggle-section">
|
||||
<div class="toggle-replace" @click="replaceVisible = !replaceVisible" :title="replaceVisible ? 'Hide Replace' : 'Show Replace'">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path v-if="replaceVisible" fill-rule="evenodd" clip-rule="evenodd" d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"/>
|
||||
<path v-else fill-rule="evenodd" clip-rule="evenodd" d="M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-section">
|
||||
<div class="search-bar">
|
||||
<input ref="searchInput" v-model="searchText" type="text" class="find-input" :class="{ error: hasError }" placeholder="Find" @keydown="onSearchKeydown" />
|
||||
<div class="search-controls">
|
||||
<div class="control-btn" :class="{ active: matchCase }" title="Match Case (Alt+C)" @click="toggleOption('case')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>
|
||||
</div>
|
||||
<div class="control-btn" :class="{ active: matchWord }" title="Match Whole Word (Alt+W)" @click="toggleOption('word')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>
|
||||
</div>
|
||||
<div class="control-btn" :class="{ active: useRegex }" title="Use Regular Expression (Alt+R)" @click="toggleOption('regex')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="replaceVisible" class="replace-bar">
|
||||
<input v-model="replaceText" type="text" class="replace-input" placeholder="Replace" @keydown="onReplaceKeydown" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<div class="button-group">
|
||||
<span class="match-count">{{ matchCountText }}</span>
|
||||
<div class="search-icons">
|
||||
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Previous Match (Shift+Enter)" @click="doFindPrevious">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.854 7l-5-5h-.707l-5 5 .707.707L8 3.561V14h1V3.56l4.146 4.147.708-.707z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" :class="{ disabled: !hasMatches }" title="Next Match (Enter)" @click="doFindNext">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.147 9l5 5h.707l5-5-.707-.707L9 12.439V2H8v10.44L3.854 8.292 3.147 9z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" title="Close (Escape)" @click="close">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="replaceVisible" class="replace-buttons">
|
||||
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace (Enter)" @click="doReplace">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7-1.012 1.007-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467.393 0 .706-.168.94-.503.236-.335.353-.78.353-1.333 0-.511-.1-.913-.301-1.207-.201-.295-.488-.442-.86-.442-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23.49.49 0 0 1 .436.233c.104.154.155.368.155.643 0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27.495.495 0 0 1-.411-.211.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231-.563 0-1.02-.178-1.369-.533-.349-.355-.523-.813-.523-1.374 0-.648.186-1.158.56-1.53.374-.376.875-.563 1.5-.563.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298-.19.196-.283.468-.283.816 0 .338.09.603.272.797.182.191.431.287.749.287.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"/></svg>
|
||||
</div>
|
||||
<div class="icon-btn" :class="{ disabled: !canReplace }" title="Replace All (Ctrl+Enter)" @click="doReplaceAll">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.6 2.677c.147-.31.356-.465.626-.465.248 0 .44.118.573.353.134.236.201.557.201.966 0 .443-.078.798-.235 1.067-.156.268-.365.402-.627.402-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169.136 0 .24-.072.314-.216.075-.145.113-.35.113-.615 0-.22-.035-.39-.104-.514-.067-.124-.164-.187-.29-.187-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662 1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5.949-.944.656.656-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961 0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436.368.368 0 0 1-.313.17.276.276 0 0 1-.22-.095.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63 0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167.165-.049.315-.073.45-.073.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388 0 .1.027.183.08.248a.276.276 0 0 0 .22.095.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598 0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134-.417 0-.751.14-1.001.422-.249.28-.373.662-.373 1.148 0 .42.116.764.349 1.03.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 14px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
min-width: 411px;
|
||||
max-width: calc(100% - 28px);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.36);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--search-panel-bg);
|
||||
color: var(--search-panel-text);
|
||||
border: 1px solid var(--search-panel-border);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
&:hover { background-color: var(--search-focus-border); }
|
||||
}
|
||||
|
||||
.toggle-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 3px 2px 3px 3px;
|
||||
}
|
||||
|
||||
.toggle-replace {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.search-bar { display: flex; position: relative; }
|
||||
|
||||
.find-input, .replace-input {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
padding: 3px 70px 3px 6px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
box-sizing: border-box;
|
||||
background: var(--search-input-bg);
|
||||
color: var(--search-input-text);
|
||||
border: 1px solid var(--search-input-border);
|
||||
&:focus {
|
||||
border-color: var(--search-focus-border);
|
||||
outline: 1px solid var(--search-focus-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
&.error {
|
||||
border-color: var(--search-error-border) !important;
|
||||
background-color: var(--search-error-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.replace-input { padding: 3px 6px; }
|
||||
.replace-bar { display: flex; }
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
svg { width: 16px; height: 16px; }
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
&.active {
|
||||
background-color: var(--search-btn-active-bg);
|
||||
color: var(--search-btn-active-text);
|
||||
border-color: var(--search-focus-border);
|
||||
svg { fill: var(--search-btn-active-text); }
|
||||
}
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.search-icons, .replace-buttons { display: flex; gap: 1px; }
|
||||
.replace-buttons { height: 24px; }
|
||||
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
svg { width: 16px; height: 16px; }
|
||||
&:hover { background-color: var(--search-btn-hover); }
|
||||
&.disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Command } from "@codemirror/view";
|
||||
import { simulateBackspace } from "./utility";
|
||||
import { cursorCharLeft, cursorCharRight, deleteCharBackward, deleteCharForward } from "@codemirror/commands";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { VSCodeSearch } from "./plugin";
|
||||
|
||||
const isSearchActive = () : boolean => {
|
||||
if (document.activeElement){
|
||||
return document.activeElement.classList.contains('find-input');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isReplaceActive = () : boolean => {
|
||||
if (document.activeElement){
|
||||
return document.activeElement.classList.contains('replace-input');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const selectAllCommand: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
(document.activeElement as HTMLInputElement).select();
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
view.dispatch({
|
||||
selection: { anchor: 0, head: view.state.doc.length }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteCharacterBackwards: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
simulateBackspace(document.activeElement as HTMLInputElement);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
deleteCharBackward(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteCharacterFowards: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
simulateBackspace(document.activeElement as HTMLInputElement, "forward");
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
deleteCharForward(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const showSearchVisibilityCommand: Command = (view) => {
|
||||
view.dispatch({
|
||||
effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search
|
||||
});
|
||||
|
||||
// 延迟聚焦,确保DOM已经更新
|
||||
setTimeout(() => {
|
||||
const searchInput = view.dom.querySelector('.find-input') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchMoveCursorLeft: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
const input = document.activeElement as HTMLInputElement;
|
||||
const pos = input.selectionStart ?? 0;
|
||||
if (pos > 0) {
|
||||
input.selectionStart = input.selectionEnd = pos - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
cursorCharLeft(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchMoveCursorRight: Command = (view) => {
|
||||
if (isSearchActive() || isReplaceActive()) {
|
||||
const input = document.activeElement as HTMLInputElement;
|
||||
const pos = input.selectionStart ?? 0;
|
||||
if (pos < input.value.length) {
|
||||
input.selectionStart = input.selectionEnd = pos + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
cursorCharRight(view);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const hideSearchVisibilityCommand: Command = (view) => {
|
||||
view.dispatch({
|
||||
effects: SearchVisibilityEffect.of(false) // Dispatch the effect to hide the search
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleCase: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleCaseInsensitive();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleWholeWord: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleWholeWord();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchToggleRegex: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.toggleRegex();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchShowReplace: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.showReplace();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchFindReplaceMatch: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.findReplaceMatch();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchFindPrevious: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.findPrevious();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const searchReplaceAll: Command = (view) => {
|
||||
const plugin = view.plugin(VSCodeSearch);
|
||||
|
||||
if (!plugin) return false;
|
||||
|
||||
plugin.replaceAll();
|
||||
return true;
|
||||
};
|
||||
@@ -1,4 +1 @@
|
||||
export { VSCodeSearch, vscodeSearch} from "./plugin";
|
||||
export { searchVisibilityField, SearchVisibilityEffect } from "./state";
|
||||
export { searchBaseTheme } from "./theme";
|
||||
export * from "./commands";
|
||||
export { vscodeSearch, VSCodeSearch } from "./plugin";
|
||||
@@ -1,80 +1,64 @@
|
||||
import { getSearchQuery, search, SearchQuery } from "@codemirror/search";
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||
import { CustomSearchPanel } from "./FindReplaceControl";
|
||||
import { SearchVisibilityEffect } from "./state";
|
||||
import { searchBaseTheme } from "./theme";
|
||||
import { search } from "@codemirror/search";
|
||||
import { EditorView, Panel } from "@codemirror/view";
|
||||
import { StateEffect } from "@codemirror/state";
|
||||
import { createApp, App } from "vue";
|
||||
import SearchPanel from "./SearchPanel.vue";
|
||||
|
||||
|
||||
export class SearchPlugin {
|
||||
private searchControl: CustomSearchPanel;
|
||||
private prevQuery: SearchQuery | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.searchControl = new CustomSearchPanel(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const currentQuery = getSearchQuery(update.state);
|
||||
if (!this.prevQuery || !currentQuery.eq(this.prevQuery)) {
|
||||
this.searchControl.findMatchesAndSelectClosest(update.state);
|
||||
/**
|
||||
* Create custom search panel using Vue component
|
||||
* This integrates directly with CodeMirror's search extension
|
||||
*/
|
||||
function createSearchPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "vscode-search-container";
|
||||
|
||||
let app: App | null = null;
|
||||
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
mount() {
|
||||
// Mount Vue component after panel is added to DOM
|
||||
app = createApp(SearchPanel, { view });
|
||||
app.mount(dom);
|
||||
},
|
||||
destroy() {
|
||||
// Cleanup Vue component
|
||||
app?.unmount();
|
||||
app = null;
|
||||
}
|
||||
this.prevQuery = currentQuery;
|
||||
|
||||
for (const tr of update.transactions) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(SearchVisibilityEffect)) {
|
||||
this.searchControl.setVisibility(e.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.searchControl.dom.remove(); // Clean up
|
||||
}
|
||||
|
||||
toggleCaseInsensitive() {
|
||||
this.searchControl.toggleCase();
|
||||
}
|
||||
|
||||
toggleWholeWord() {
|
||||
this.searchControl.toggleWord();
|
||||
}
|
||||
|
||||
toggleRegex() {
|
||||
this.searchControl.toggleRegex();
|
||||
}
|
||||
|
||||
showReplace() {
|
||||
this.searchControl.setVisibility(true);
|
||||
this.searchControl.showReplace();
|
||||
}
|
||||
|
||||
findReplaceMatch() {
|
||||
this.searchControl.findReplaceMatch();
|
||||
}
|
||||
|
||||
findNext() {
|
||||
this.searchControl.matchNext();
|
||||
}
|
||||
|
||||
replace() {
|
||||
this.searchControl.replace();
|
||||
}
|
||||
|
||||
replaceAll() {
|
||||
this.searchControl.replaceAll();
|
||||
}
|
||||
|
||||
findPrevious() {
|
||||
this.searchControl.matchPrevious();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const VSCodeSearch = ViewPlugin.fromClass(SearchPlugin);
|
||||
/**
|
||||
* Custom scroll behavior - scroll match to center of viewport
|
||||
* This is called automatically by findNext/findPrevious
|
||||
*/
|
||||
function scrollMatchToCenter(range: { from: number }, view: EditorView): StateEffect<unknown> {
|
||||
return EditorView.scrollIntoView(range.from, { y: 'center' });
|
||||
}
|
||||
|
||||
/**
|
||||
* VSCode-style search extension
|
||||
* Uses CodeMirror's built-in search with custom Vue UI
|
||||
*
|
||||
* Config options set default values for search query:
|
||||
* - caseSensitive: false (default) - match case
|
||||
* - wholeWord: false (default) - match whole word
|
||||
* - regexp: false (default) - use regular expression
|
||||
* - literal: false (default) - literal string search
|
||||
*/
|
||||
export const vscodeSearch = [
|
||||
search({}),
|
||||
VSCodeSearch,
|
||||
searchBaseTheme
|
||||
];
|
||||
search({
|
||||
createPanel: createSearchPanel,
|
||||
top: true,
|
||||
scrollToMatch: scrollMatchToCenter,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regexp: false,
|
||||
literal: false,
|
||||
}),
|
||||
];
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { vscodeSearch as VSCodeSearch };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user