Compare commits
14 Commits
v1.5.4
...
ad24d3a140
| Author | SHA1 | Date | |
|---|---|---|---|
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f | |||
| 991a89147e | |||
| a08c0d8448 | |||
| 59db8dd177 | |||
| 29693f1baf |
@@ -1170,7 +1170,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 +1199,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 +1342,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);
|
||||
|
||||
852
frontend/package-lock.json
generated
852
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,7 +50,7 @@
|
||||
"@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",
|
||||
@@ -72,37 +72,37 @@
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"npm": "^11.6.3",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-i18n": "^11.2.1",
|
||||
"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.0",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.47.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.13",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
鸿蒙字体压缩工具
|
||||
使用 fonttools 库压缩 TTF 字体文件,减小文件大小
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic"):
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return
|
||||
|
||||
# 查找所有字体文件
|
||||
font_files = find_font_files(font_directory)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" ✓ 成功: {format_file_size(original_size)} → {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" ✗ 失败: 输出文件未生成")
|
||||
else:
|
||||
print(f" ✗ 失败: 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("鸿蒙字体压缩工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取当前脚本所在目录
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 设置默认字体目录
|
||||
font_directory = current_dir
|
||||
|
||||
print(f"字体目录: {font_directory}")
|
||||
|
||||
# 让用户选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
179
frontend/src/assets/fonts/README.md
Normal file
179
frontend/src/assets/fonts/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 字体压缩工具使用指南
|
||||
|
||||
## 📖 简介
|
||||
|
||||
`font_compressor.py` 是一个通用的字体压缩工具,可以:
|
||||
- ✅ 将 TTF、OTF、WOFF 字体文件转换为 WOFF2 格式
|
||||
- ✅ 支持相对路径和绝对路径
|
||||
- ✅ 自动生成 CSS 字体定义文件
|
||||
- ✅ 智能识别字体字重和样式
|
||||
- ✅ 批量处理整个目录(包括子目录)
|
||||
|
||||
## 🚀 前置要求
|
||||
|
||||
安装 Python 依赖包:
|
||||
|
||||
```bash
|
||||
pip install fonttools brotli
|
||||
```
|
||||
|
||||
## 📝 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```bash
|
||||
# 进入 fonts 目录
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 交互式模式处理当前目录
|
||||
python font_compressor.py
|
||||
|
||||
# 处理相对路径的 Monocraft 目录
|
||||
python font_compressor.py Monocraft
|
||||
|
||||
# 处理相对路径并指定压缩级别
|
||||
python font_compressor.py Monocraft -l basic
|
||||
```
|
||||
|
||||
### 生成 CSS 文件
|
||||
|
||||
```bash
|
||||
# 压缩 Monocraft 字体并生成 CSS 文件
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 Hack 字体并生成 CSS
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
|
||||
# 压缩 OpenSans 字体并生成 CSS
|
||||
python font_compressor.py OpenSans -l medium -c ../styles/opensans_fonts.css
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
```bash
|
||||
# 使用绝对路径
|
||||
python font_compressor.py E:\Go_WorkSpace\voidraft\frontend\src\assets\fonts\Monocraft -l basic -c monocraft.css
|
||||
|
||||
# 不同压缩级别
|
||||
python font_compressor.py Monocraft -l basic # 基础压缩,保留所有功能
|
||||
python font_compressor.py Monocraft -l medium # 中等压缩,平衡大小和功能
|
||||
python font_compressor.py Monocraft -l aggressive # 激进压缩,最小文件
|
||||
```
|
||||
|
||||
## ⚙️ 命令行参数
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `directory` | 字体目录(相对/绝对路径) | `Monocraft` 或 `/path/to/fonts` |
|
||||
| `-l, --level` | 压缩级别 (basic/medium/aggressive) | `-l basic` |
|
||||
| `-c, --css` | CSS 输出文件路径 | `-c monocraft.css` |
|
||||
| `--version` | 显示版本信息 | `--version` |
|
||||
| `-h, --help` | 显示帮助信息 | `-h` |
|
||||
|
||||
## 📊 压缩级别说明
|
||||
|
||||
### basic(基础) - 推荐
|
||||
- 保留大部分字体功能
|
||||
- 适合网页使用
|
||||
- 压缩率约 30-40%
|
||||
|
||||
### medium(中等)
|
||||
- 移除一些不常用的功能
|
||||
- 平衡文件大小和功能
|
||||
- 压缩率约 40-50%
|
||||
|
||||
### aggressive(激进)
|
||||
- 最大程度减小文件大小
|
||||
- 可能影响高级排版功能
|
||||
- 压缩率约 50-60%
|
||||
|
||||
## 📁 输出结果
|
||||
|
||||
### 字体文件
|
||||
压缩后的 `.woff2` 文件会保存在原文件相同的目录下,例如:
|
||||
- `Monocraft/ttf/Monocraft-Bold.ttf` → `Monocraft/ttf/Monocraft-Bold.woff2`
|
||||
- `Hack/hack-regular.ttf` → `Hack/hack-regular.woff2`
|
||||
|
||||
### CSS 文件
|
||||
生成的 CSS 文件会包含:
|
||||
- 自动识别的字体家族名称
|
||||
- 正确的字重和样式设置
|
||||
- 使用相对路径的字体引用
|
||||
- 按字重排序的 `@font-face` 定义
|
||||
|
||||
生成的 CSS 示例:
|
||||
|
||||
```css
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 实际使用示例
|
||||
|
||||
### 示例 1: 压缩 Monocraft 字体
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
```
|
||||
|
||||
这将:
|
||||
1. 扫描 `Monocraft/ttf` 和 `Monocraft/otf` 目录
|
||||
2. 将所有字体文件转换为 WOFF2
|
||||
3. 在 `frontend/src/assets/styles/monocraft_fonts.css` 生成 CSS 文件
|
||||
|
||||
### 示例 2: 批量处理多个字体目录
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 压缩 Monocraft
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 OpenSans
|
||||
python font_compressor.py OpenSans -l basic -c ../styles/opensans_fonts.css
|
||||
|
||||
# 压缩 Hack(已有 CSS,只需生成新版本对比)
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
```
|
||||
|
||||
## 🔍 字体信息自动识别
|
||||
|
||||
工具会自动从文件名识别:
|
||||
- **字重**:Thin(100), Light(300), Regular(400), Medium(500), SemiBold(600), Bold(700), Black(900)
|
||||
- **样式**:normal, italic
|
||||
- **字体家族**:自动去除字重和样式后缀
|
||||
|
||||
支持的命名格式:
|
||||
- `FontName-Bold.ttf`
|
||||
- `FontName_Bold.otf`
|
||||
- `FontName-BoldItalic.ttf`
|
||||
- `FontName_SemiBold_Italic.woff`
|
||||
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
```bash
|
||||
python font_compressor.py --help
|
||||
```
|
||||
|
||||
494
frontend/src/assets/fonts/font_compressor.py
Normal file
494
frontend/src/assets/fonts/font_compressor.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用字体压缩工具
|
||||
使用 fonttools 库将字体文件转换为 WOFF2 格式,减小文件大小
|
||||
支持 TTF、OTF、WOFF 等格式的字体文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str, exclude_woff2: bool = False) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
if exclude_woff2:
|
||||
font_extensions = ['.ttf', '.otf', '.woff']
|
||||
else:
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def parse_font_info(filename: str) -> Dict[str, any]:
|
||||
"""
|
||||
从字体文件名解析字体信息(字重、样式等)
|
||||
|
||||
Args:
|
||||
filename: 字体文件名(不含路径)
|
||||
|
||||
Returns:
|
||||
包含字体信息的字典
|
||||
"""
|
||||
# 移除扩展名
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# 字重映射
|
||||
weight_mapping = {
|
||||
'thin': (100, 'Thin'),
|
||||
'extralight': (200, 'ExtraLight'),
|
||||
'light': (300, 'Light'),
|
||||
'regular': (400, 'Regular'),
|
||||
'normal': (400, 'Regular'),
|
||||
'medium': (500, 'Medium'),
|
||||
'semibold': (600, 'SemiBold'),
|
||||
'bold': (700, 'Bold'),
|
||||
'extrabold': (800, 'ExtraBold'),
|
||||
'black': (900, 'Black'),
|
||||
'heavy': (900, 'Heavy'),
|
||||
}
|
||||
|
||||
# 默认值
|
||||
font_weight = 400
|
||||
font_style = 'normal'
|
||||
weight_name = 'Regular'
|
||||
|
||||
# 检查是否为斜体
|
||||
if re.search(r'italic', name_without_ext, re.IGNORECASE):
|
||||
font_style = 'italic'
|
||||
|
||||
# 检查字重
|
||||
name_lower = name_without_ext.lower()
|
||||
for weight_key, (weight_value, weight_label) in weight_mapping.items():
|
||||
if weight_key in name_lower:
|
||||
font_weight = weight_value
|
||||
weight_name = weight_label
|
||||
break
|
||||
|
||||
# 提取字体家族名称(移除字重和样式后缀)
|
||||
family_name = name_without_ext
|
||||
for weight_key, (_, weight_label) in weight_mapping.items():
|
||||
family_name = re.sub(r'[-_]?' + weight_label, '', family_name, flags=re.IGNORECASE)
|
||||
family_name = re.sub(r'[-_]?italic', '', family_name, flags=re.IGNORECASE)
|
||||
family_name = family_name.strip('-_')
|
||||
|
||||
return {
|
||||
'family': family_name,
|
||||
'weight': font_weight,
|
||||
'style': font_style,
|
||||
'weight_name': weight_name,
|
||||
'full_name': name_without_ext
|
||||
}
|
||||
|
||||
def generate_css(font_files: List[str], output_css_path: str, css_base_path: str):
|
||||
"""
|
||||
生成CSS字体文件
|
||||
|
||||
Args:
|
||||
font_files: 字体文件路径列表(woff2文件)
|
||||
output_css_path: 输出CSS文件路径
|
||||
css_base_path: CSS文件相对于字体文件的基础路径
|
||||
"""
|
||||
# 按字体家族分组
|
||||
font_groups: Dict[str, List[Dict]] = {}
|
||||
|
||||
for font_file in font_files:
|
||||
if not font_file.endswith('.woff2'):
|
||||
continue
|
||||
|
||||
filename = os.path.basename(font_file)
|
||||
font_info = parse_font_info(filename)
|
||||
|
||||
# 计算相对路径
|
||||
font_dir = os.path.dirname(font_file)
|
||||
css_dir = os.path.dirname(output_css_path)
|
||||
|
||||
try:
|
||||
# 计算从CSS文件到字体文件的相对路径
|
||||
rel_path = os.path.relpath(font_file, css_dir)
|
||||
# 统一使用正斜杠(适用于Web)
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
except ValueError:
|
||||
# 如果在不同驱动器上,使用绝对路径
|
||||
rel_path = font_file.replace('\\', '/')
|
||||
|
||||
font_info['path'] = rel_path
|
||||
|
||||
family = font_info['family']
|
||||
if family not in font_groups:
|
||||
font_groups[family] = []
|
||||
font_groups[family].append(font_info)
|
||||
|
||||
# 生成CSS内容
|
||||
css_lines = ['/* 自动生成的字体文件 */', '/* 由 font_compressor.py 生成 */', '']
|
||||
|
||||
for family, fonts in sorted(font_groups.items()):
|
||||
css_lines.append(f'/* {family} 字体家族 */')
|
||||
css_lines.append('')
|
||||
|
||||
# 按字重排序
|
||||
fonts.sort(key=lambda x: (x['weight'], x['style']))
|
||||
|
||||
for font in fonts:
|
||||
css_lines.append(f"/* {family} {font['weight_name']}{' Italic' if font['style'] == 'italic' else ''} */")
|
||||
css_lines.append('@font-face {')
|
||||
css_lines.append(f" font-family: '{family}';")
|
||||
css_lines.append(f" src: url('{font['path']}') format('woff2');")
|
||||
css_lines.append(f" font-weight: {font['weight']};")
|
||||
css_lines.append(f" font-style: {font['style']};")
|
||||
css_lines.append(' font-display: swap;')
|
||||
css_lines.append('}')
|
||||
css_lines.append('')
|
||||
|
||||
# 写入CSS文件
|
||||
with open(output_css_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(css_lines))
|
||||
|
||||
print(f"[OK] CSS文件已生成: {output_css_path}")
|
||||
print(f" 包含 {sum(len(fonts) for fonts in font_groups.values())} 个字体定义")
|
||||
print(f" 字体家族: {', '.join(sorted(font_groups.keys()))}")
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic") -> List[str]:
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
|
||||
Returns:
|
||||
生成的woff2文件路径列表
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return []
|
||||
|
||||
# 查找所有字体文件(排除已经是woff2的)
|
||||
font_files = find_font_files(font_directory, exclude_woff2=True)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return []
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
generated_woff2_files = []
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
generated_woff2_files.append(output_file)
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" [OK] 成功: {format_file_size(original_size)} -> {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" [失败] 输出文件未生成")
|
||||
else:
|
||||
print(f" [失败] 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
return generated_woff2_files
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(
|
||||
description='通用字体压缩工具 - 将字体文件转换为 WOFF2 格式并生成CSS',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
使用示例:
|
||||
%(prog)s # 交互式模式,处理当前目录
|
||||
%(prog)s Monocraft # 处理相对路径目录
|
||||
%(prog)s Monocraft -l basic # 使用基础压缩级别
|
||||
%(prog)s Monocraft -l basic -c monocraft.css # 压缩并生成CSS文件
|
||||
%(prog)s /path/to/fonts -l medium -c fonts.css # 使用绝对路径
|
||||
|
||||
压缩级别说明:
|
||||
basic - 基础压缩:保留大部分功能,适合网页使用
|
||||
medium - 中等压缩:平衡文件大小和功能
|
||||
aggressive - 激进压缩:最小文件大小,可能影响显示效果
|
||||
|
||||
CSS生成说明:
|
||||
使用 -c/--css 选项生成CSS文件,自动使用相对路径引用字体文件
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='字体文件目录路径(支持相对/绝对路径,默认为当前脚本所在目录)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-l', '--level',
|
||||
choices=['basic', 'medium', 'aggressive'],
|
||||
default=None,
|
||||
help='压缩级别:basic(基础)、medium(中等)、aggressive(激进)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--css',
|
||||
default=None,
|
||||
help='生成CSS文件路径(相对于脚本位置或绝对路径)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
action='version',
|
||||
version='%(prog)s 2.0'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("通用字体压缩工具 v2.0")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取脚本所在目录
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 确定字体目录
|
||||
if args.directory:
|
||||
# 支持相对路径和绝对路径
|
||||
if os.path.isabs(args.directory):
|
||||
font_directory = args.directory
|
||||
else:
|
||||
font_directory = os.path.join(script_dir, args.directory)
|
||||
font_directory = os.path.abspath(font_directory)
|
||||
else:
|
||||
# 默认使用当前脚本所在目录
|
||||
font_directory = script_dir
|
||||
|
||||
# 检查目录是否存在
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"\n错误: 目录不存在: {font_directory}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n字体目录: {font_directory}")
|
||||
|
||||
# 确定压缩级别
|
||||
compression_level = args.level
|
||||
|
||||
if compression_level is None:
|
||||
# 交互式选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
print()
|
||||
generated_files = compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
# 生成CSS文件
|
||||
if args.css and generated_files:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("生成CSS文件...")
|
||||
print("=" * 60)
|
||||
|
||||
# 确定CSS输出路径
|
||||
if os.path.isabs(args.css):
|
||||
css_path = args.css
|
||||
else:
|
||||
css_path = os.path.join(script_dir, args.css)
|
||||
css_path = os.path.abspath(css_path)
|
||||
|
||||
# 确保输出目录存在
|
||||
css_dir = os.path.dirname(css_path)
|
||||
if css_dir and not os.path.exists(css_dir):
|
||||
os.makedirs(css_dir)
|
||||
|
||||
# 生成CSS
|
||||
generate_css(generated_files, css_path, script_dir)
|
||||
elif args.css and not generated_files:
|
||||
print("\n警告: 没有成功生成WOFF2文件,跳过CSS生成")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("全部完成!")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,9 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'scrollbar.css';
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import 'styles.css';
|
||||
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
@@ -0,0 +1,202 @@
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
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,148 @@
|
||||
: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%);
|
||||
}
|
||||
|
||||
/* 监听系统浅色主题 */
|
||||
/* 亮色主题 */
|
||||
: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%);
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@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%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动选择浅色主题 */
|
||||
: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);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const FONT_OPTIONS = [
|
||||
label: 'Open Sans',
|
||||
value: '"Open Sans"'
|
||||
},
|
||||
{
|
||||
label: 'Monocraft',
|
||||
value: 'Monocraft'
|
||||
},
|
||||
// Common system fonts
|
||||
{
|
||||
label: 'Arial',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -161,53 +161,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',
|
||||
|
||||
@@ -202,54 +202,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: {
|
||||
|
||||
@@ -14,6 +14,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 +30,7 @@ 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 {markdownPreviewExtension, updateMarkdownPreviewTheme} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
@@ -242,6 +243,11 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension(
|
||||
() => configStore.increaseFontSize(),
|
||||
() => configStore.decreaseFontSize()
|
||||
);
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
|
||||
@@ -287,6 +293,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
themeExtension,
|
||||
...tabExtensions,
|
||||
fontExtension,
|
||||
wheelZoomExtension,
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
@@ -635,6 +642,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 应用 Markdown 预览主题
|
||||
const applyPreviewThemeSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
updateMarkdownPreviewTheme(instance.view);
|
||||
});
|
||||
};
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
@@ -707,12 +721,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();
|
||||
};
|
||||
@@ -773,6 +790,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 配置更新方法
|
||||
applyFontSettings,
|
||||
applyThemeSettings,
|
||||
applyPreviewThemeSettings,
|
||||
applyTabSettings,
|
||||
applyKeymapSettings,
|
||||
|
||||
|
||||
@@ -1,191 +1,157 @@
|
||||
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(() =>
|
||||
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 availableThemes = computed<ThemeOption[]>(() =>
|
||||
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||
);
|
||||
|
||||
const lightThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
||||
);
|
||||
|
||||
// 计算属性:当前可用的主题列表
|
||||
const availableThemes = computed(() =>
|
||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
||||
);
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||
const 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');
|
||||
}
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
currentColors.value.themeName = themeName;
|
||||
|
||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
||||
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();
|
||||
editorStore?.applyPreviewThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
availableThemes,
|
||||
currentTheme,
|
||||
currentColors,
|
||||
isDarkMode,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
switchToTheme,
|
||||
initializeTheme,
|
||||
loadAllThemes,
|
||||
updateCurrentColors,
|
||||
saveCurrentTheme,
|
||||
resetCurrentTheme,
|
||||
|
||||
@@ -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', () => {
|
||||
// 基础状态
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
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 { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
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 './contextMenu/ContextMenu.vue';
|
||||
import { contextMenuManager } from './contextMenu/manager';
|
||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||
import { translatorManager } from './extensions/translator/manager';
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -19,47 +22,38 @@ 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"/>
|
||||
<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>
|
||||
|
||||
@@ -76,6 +70,7 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +83,6 @@ onBeforeUnmount(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载动画过渡效果
|
||||
.loading-fade-enter-active,
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
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;
|
||||
|
||||
const newContent = this.view.state.doc.toString();
|
||||
if (newContent === this.lastContent) return;
|
||||
|
||||
this.lastContent = newContent;
|
||||
|
||||
this.editorStore.onContentChange();
|
||||
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,40 @@
|
||||
// 处理滚轮缩放字体的事件处理函数
|
||||
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';
|
||||
|
||||
type FontAdjuster = () => Promise<void> | void;
|
||||
|
||||
const runAdjuster = (adjuster: FontAdjuster) => {
|
||||
try {
|
||||
const result = adjuster();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to adjust font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to adjust font size:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createWheelZoomExtension = (
|
||||
increaseFontSize: FontAdjuster,
|
||||
decreaseFontSize: FontAdjuster
|
||||
): Extension => {
|
||||
return EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 根据滚轮方向增大或减小字体
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动,增大字体
|
||||
increaseFontSize();
|
||||
} else {
|
||||
// 向下滚动,减小字体
|
||||
decreaseFontSize();
|
||||
runAdjuster(increaseFontSize);
|
||||
} else if (event.deltaY > 0) {
|
||||
runAdjuster(decreaseFontSize);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { contextMenuManager } from './manager';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
const props = defineProps<{
|
||||
portalTarget?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const menuState = contextMenuManager.useState();
|
||||
const menuRef = ref<HTMLDivElement | null>(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const isVisible = computed(() => menuState.value.visible);
|
||||
const items = computed(() => menuState.value.items);
|
||||
const position = computed(() => menuState.value.position);
|
||||
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||
|
||||
watch(
|
||||
position,
|
||||
(newPosition) => {
|
||||
adjustedPosition.value = { ...newPosition };
|
||||
if (isVisible.value) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
// 显示时添加 outside 点击监听
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
// 隐藏时移除监听
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
}));
|
||||
|
||||
async function adjustMenuWithinViewport() {
|
||||
await nextTick();
|
||||
const menuEl = menuRef.value;
|
||||
if (!menuEl) return;
|
||||
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
let nextX = adjustedPosition.value.x;
|
||||
let nextY = adjustedPosition.value.y;
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
nextX = Math.max(0, window.innerWidth - rect.width - 8);
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
nextY = Math.max(0, window.innerHeight - rect.height - 8);
|
||||
}
|
||||
|
||||
adjustedPosition.value = { x: nextX, y: nextY };
|
||||
}
|
||||
|
||||
function handleItemClick(item: RenderMenuItem) {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.runCommand(item);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
// 如果点击在菜单内部,不关闭
|
||||
if (menuRef.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="cm-context-menu show"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||
<div
|
||||
v-else
|
||||
class="cm-context-menu-item"
|
||||
:class="{ 'is-disabled': item.disabled }"
|
||||
role="menuitem"
|
||||
:aria-disabled="item.disabled ? 'true' : 'false'"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="cm-context-menu-item-label">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
padding: 4px 0;
|
||||
border-radius: 3px;
|
||||
background-color: var(--settings-card-bg, #1c1c1e);
|
||||
color: var(--settings-text, #f6f6f6);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform-origin: top left;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text, #ffffff);
|
||||
}
|
||||
|
||||
.cm-context-menu-item.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 编辑器上下文菜单样式
|
||||
* 支持系统主题自动适配
|
||||
*/
|
||||
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
/* 优化阴影效果,只在右下角显示自然的阴影 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
||||
overflow: visible; /* 确保子菜单可以显示在外部 */
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text-secondary);
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: var(--selection-bg);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0.5;
|
||||
transform: scale(0);
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 菜单分组标题样式 */
|
||||
.cm-context-menu-group-title {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 菜单分隔线样式 */
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 子菜单样式 */
|
||||
.cm-context-submenu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu::after {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cm-context-submenu {
|
||||
position: fixed; /* 改为fixed定位,避免受父元素影响 */
|
||||
min-width: 180px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 10000;
|
||||
border-radius: 6px;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 0;
|
||||
/* 子菜单也使用相同的阴影效果 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 深色主题下的特殊样式 */
|
||||
:root[data-theme="dark"] .cm-context-menu {
|
||||
/* 深色主题下阴影更深,但仍然只在右下角 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-submenu {
|
||||
/* 深色主题下子菜单阴影 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-menu-divider {
|
||||
background-color: var(--dark-border-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 动画相关类 */
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
/**
|
||||
* 上下文菜单视图实现
|
||||
*/
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { MenuItem } from "../contextMenu";
|
||||
import "./contextMenu.css";
|
||||
|
||||
// 为Window对象添加cmSubmenus属性
|
||||
declare global {
|
||||
interface Window {
|
||||
cmSubmenus?: Map<string, HTMLElement>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项元素池,用于复用DOM元素
|
||||
*/
|
||||
class MenuItemPool {
|
||||
private pool: HTMLElement[] = [];
|
||||
private maxPoolSize = 50; // 最大池大小
|
||||
|
||||
/**
|
||||
* 获取或创建菜单项元素
|
||||
*/
|
||||
get(): HTMLElement {
|
||||
if (this.pool.length > 0) {
|
||||
return this.pool.pop()!;
|
||||
}
|
||||
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.className = "cm-context-menu-item";
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收菜单项元素
|
||||
*/
|
||||
release(element: HTMLElement): void {
|
||||
if (this.pool.length < this.maxPoolSize) {
|
||||
// 清理元素状态
|
||||
element.className = "cm-context-menu-item";
|
||||
element.innerHTML = "";
|
||||
element.style.cssText = "";
|
||||
|
||||
// 移除所有事件监听器(通过克隆节点)
|
||||
const cleanElement = element.cloneNode(false) as HTMLElement;
|
||||
this.pool.push(cleanElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空池
|
||||
*/
|
||||
clear(): void {
|
||||
this.pool.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文菜单管理器
|
||||
*/
|
||||
class ContextMenuManager {
|
||||
private static instance: ContextMenuManager;
|
||||
|
||||
private menuElement: HTMLElement | null = null;
|
||||
private submenuPool: Map<string, HTMLElement> = new Map();
|
||||
private menuItemPool = new MenuItemPool();
|
||||
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private currentView: EditorView | null = null;
|
||||
private activeSubmenus: Set<HTMLElement> = new Set();
|
||||
private ripplePool: HTMLElement[] = [];
|
||||
|
||||
// 事件委托处理器
|
||||
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.initializeEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): ContextMenuManager {
|
||||
if (!ContextMenuManager.instance) {
|
||||
ContextMenuManager.instance = new ContextMenuManager();
|
||||
}
|
||||
return ContextMenuManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理器
|
||||
*/
|
||||
private initializeEventHandlers(): void {
|
||||
// 点击事件委托
|
||||
this.menuClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem && menuItem.dataset.command) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 添加点击动画
|
||||
this.addRippleEffect(menuItem, e);
|
||||
|
||||
// 执行命令
|
||||
const commandName = menuItem.dataset.command;
|
||||
const command = this.getCommandByName(commandName);
|
||||
if (command && this.currentView) {
|
||||
command(this.currentView);
|
||||
}
|
||||
|
||||
// 隐藏菜单
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标事件委托
|
||||
this.menuMouseHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (!menuItem) return;
|
||||
|
||||
if (e.type === 'mouseenter') {
|
||||
this.handleMenuItemMouseEnter(menuItem);
|
||||
} else if (e.type === 'mouseleave') {
|
||||
this.handleMenuItemMouseLeave(menuItem, e);
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘事件处理器
|
||||
this.keyDownHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击外部关闭处理器
|
||||
this.clickOutsideHandler = (e: MouseEvent) => {
|
||||
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建主菜单元素
|
||||
*/
|
||||
private getOrCreateMenuElement(): HTMLElement {
|
||||
if (!this.menuElement) {
|
||||
this.menuElement = document.createElement("div");
|
||||
this.menuElement.className = "cm-context-menu";
|
||||
this.menuElement.style.display = "none";
|
||||
document.body.appendChild(this.menuElement);
|
||||
|
||||
// 阻止菜单内右键点击冒泡
|
||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
this.menuElement.addEventListener('click', this.menuClickHandler!);
|
||||
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.menuElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或获取子菜单元素
|
||||
*/
|
||||
private getOrCreateSubmenu(id: string): HTMLElement {
|
||||
if (!this.submenuPool.has(id)) {
|
||||
const submenu = document.createElement("div");
|
||||
submenu.className = "cm-context-menu cm-context-submenu";
|
||||
submenu.style.display = "none";
|
||||
document.body.appendChild(submenu);
|
||||
this.submenuPool.set(id, submenu);
|
||||
|
||||
// 阻止子菜单点击事件冒泡
|
||||
submenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
submenu.addEventListener('click', this.menuClickHandler!);
|
||||
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.submenuPool.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建菜单项DOM元素
|
||||
*/
|
||||
private createMenuItemElement(item: MenuItem): HTMLElement {
|
||||
const menuItem = this.menuItemPool.get();
|
||||
|
||||
// 如果有子菜单,添加相应类
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
||||
}
|
||||
|
||||
// 创建内容容器
|
||||
const contentContainer = document.createElement("div");
|
||||
contentContainer.className = "cm-context-menu-item-label";
|
||||
|
||||
// 标签文本
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.label;
|
||||
contentContainer.appendChild(label);
|
||||
menuItem.appendChild(contentContainer);
|
||||
|
||||
// 快捷键提示(如果有)
|
||||
if (item.shortcut) {
|
||||
const shortcut = document.createElement("span");
|
||||
shortcut.className = "cm-context-menu-item-shortcut";
|
||||
shortcut.textContent = item.shortcut;
|
||||
menuItem.appendChild(shortcut);
|
||||
}
|
||||
|
||||
// 存储命令信息用于事件委托
|
||||
if (item.command) {
|
||||
menuItem.dataset.command = this.registerCommand(item.command);
|
||||
}
|
||||
|
||||
// 处理子菜单
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
menuItem.dataset.submenuId = submenuId;
|
||||
|
||||
const submenu = this.getOrCreateSubmenu(submenuId);
|
||||
this.populateSubmenu(submenu, item.submenu);
|
||||
|
||||
// 记录子菜单
|
||||
if (!window.cmSubmenus) {
|
||||
window.cmSubmenus = new Map();
|
||||
}
|
||||
window.cmSubmenus.set(submenuId, submenu);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充子菜单内容
|
||||
*/
|
||||
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
|
||||
// 清空现有内容
|
||||
while (submenu.firstChild) {
|
||||
submenu.removeChild(submenu.firstChild);
|
||||
}
|
||||
|
||||
// 添加子菜单项
|
||||
items.forEach(item => {
|
||||
const subMenuItemElement = this.createMenuItemElement(item);
|
||||
submenu.appendChild(subMenuItemElement);
|
||||
});
|
||||
|
||||
// 初始状态设置为隐藏
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令注册和管理
|
||||
*/
|
||||
private commands: Map<string, (view: EditorView) => void> = new Map();
|
||||
private commandCounter = 0;
|
||||
|
||||
private registerCommand(command: (view: EditorView) => void): string {
|
||||
const commandId = `cmd_${this.commandCounter++}`;
|
||||
this.commands.set(commandId, command);
|
||||
return commandId;
|
||||
}
|
||||
|
||||
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
||||
return this.commands.get(commandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标进入事件
|
||||
*/
|
||||
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 检查子菜单是否会超出屏幕
|
||||
requestAnimationFrame(() => {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
if (submenuRect.right > window.innerWidth) {
|
||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||
}
|
||||
|
||||
if (submenuRect.bottom > window.innerHeight) {
|
||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示子菜单
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.pointerEvents = 'auto';
|
||||
submenu.style.visibility = 'visible';
|
||||
submenu.style.transform = 'translateX(0)';
|
||||
|
||||
this.activeSubmenus.add(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标离开事件
|
||||
*/
|
||||
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
// 检查是否移动到子菜单上
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (submenu.contains(toElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideSubmenu(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏子菜单
|
||||
*/
|
||||
private hideSubmenu(submenu: HTMLElement): void {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
|
||||
setTimeout(() => {
|
||||
if (submenu.style.opacity === '0') {
|
||||
submenu.style.visibility = 'hidden';
|
||||
}
|
||||
}, 200);
|
||||
|
||||
this.activeSubmenus.delete(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加点击波纹效果
|
||||
*/
|
||||
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
let ripple: HTMLElement;
|
||||
|
||||
if (this.ripplePool.length > 0) {
|
||||
ripple = this.ripplePool.pop()!;
|
||||
} else {
|
||||
ripple = document.createElement("div");
|
||||
ripple.className = "cm-context-menu-item-ripple";
|
||||
}
|
||||
|
||||
// 计算相对位置
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
ripple.style.left = (x - 50) + "px";
|
||||
ripple.style.top = (y - 50) + "px";
|
||||
ripple.style.transform = "scale(0)";
|
||||
ripple.style.opacity = "1";
|
||||
|
||||
menuItem.appendChild(ripple);
|
||||
|
||||
// 执行动画
|
||||
requestAnimationFrame(() => {
|
||||
ripple.style.transform = "scale(1)";
|
||||
ripple.style.opacity = "0";
|
||||
|
||||
setTimeout(() => {
|
||||
if (ripple.parentNode === menuItem) {
|
||||
menuItem.removeChild(ripple);
|
||||
this.ripplePool.push(ripple);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点击是否在菜单内
|
||||
*/
|
||||
private isClickInsideMenu(target: Node): boolean {
|
||||
if (this.menuElement && this.menuElement.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否在子菜单内
|
||||
for (const submenu of this.activeSubmenus) {
|
||||
if (submenu.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位菜单元素
|
||||
*/
|
||||
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let left = clientX;
|
||||
let top = clientY;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const menuHeight = menu.offsetHeight;
|
||||
|
||||
if (left + menuWidth > windowWidth) {
|
||||
left = windowWidth - menuWidth - 5;
|
||||
}
|
||||
|
||||
if (top + menuHeight > windowHeight) {
|
||||
top = windowHeight - menuHeight - 5;
|
||||
}
|
||||
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
this.currentView = view;
|
||||
|
||||
// 获取或创建菜单元素
|
||||
const menu = this.getOrCreateMenuElement();
|
||||
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
// 清空现有菜单项并回收到池中
|
||||
while (menu.firstChild) {
|
||||
const child = menu.firstChild as HTMLElement;
|
||||
if (child.classList.contains('cm-context-menu-item')) {
|
||||
this.menuItemPool.release(child);
|
||||
}
|
||||
menu.removeChild(child);
|
||||
}
|
||||
|
||||
// 清空命令注册
|
||||
this.commands.clear();
|
||||
this.commandCounter = 0;
|
||||
|
||||
// 添加主菜单项
|
||||
items.forEach(item => {
|
||||
const menuItemElement = this.createMenuItemElement(item);
|
||||
menu.appendChild(menuItemElement);
|
||||
});
|
||||
|
||||
// 显示菜单
|
||||
menu.style.display = "block";
|
||||
|
||||
// 定位菜单
|
||||
this.positionMenu(menu, clientX, clientY);
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener("click", this.clickOutsideHandler!, true);
|
||||
document.addEventListener("keydown", this.keyDownHandler!);
|
||||
|
||||
// 触发显示动画
|
||||
requestAnimationFrame(() => {
|
||||
if (menu) {
|
||||
menu.classList.add("show");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏所有子菜单
|
||||
*/
|
||||
private hideAllSubmenus(): void {
|
||||
this.activeSubmenus.forEach(submenu => {
|
||||
this.hideSubmenu(submenu);
|
||||
});
|
||||
this.activeSubmenus.clear();
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.forEach((submenu) => {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏上下文菜单
|
||||
*/
|
||||
hide(): void {
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
if (this.menuElement) {
|
||||
// 添加淡出动画
|
||||
this.menuElement.classList.remove("show");
|
||||
this.menuElement.classList.add("hide");
|
||||
|
||||
// 等待动画完成后隐藏
|
||||
setTimeout(() => {
|
||||
if (this.menuElement) {
|
||||
this.menuElement.style.display = "none";
|
||||
this.menuElement.classList.remove("hide");
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// 移除全局事件监听器
|
||||
if (this.clickOutsideHandler) {
|
||||
document.removeEventListener("click", this.clickOutsideHandler, true);
|
||||
}
|
||||
|
||||
if (this.keyDownHandler) {
|
||||
document.removeEventListener("keydown", this.keyDownHandler);
|
||||
}
|
||||
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.hide();
|
||||
|
||||
if (this.menuElement) {
|
||||
document.body.removeChild(this.menuElement);
|
||||
this.menuElement = null;
|
||||
}
|
||||
|
||||
this.submenuPool.forEach(submenu => {
|
||||
if (submenu.parentNode) {
|
||||
document.body.removeChild(submenu);
|
||||
}
|
||||
});
|
||||
this.submenuPool.clear();
|
||||
|
||||
this.menuItemPool.clear();
|
||||
this.commands.clear();
|
||||
this.activeSubmenus.clear();
|
||||
this.ripplePool.length = 0;
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单例实例
|
||||
const contextMenuManager = ContextMenuManager.getInstance();
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
@@ -1,174 +1,141 @@
|
||||
/**
|
||||
* 编辑器上下文菜单实现
|
||||
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
|
||||
*/
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
|
||||
import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
|
||||
import { useKeybindingStore } from "@/stores/keybindingStore";
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
|
||||
import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
import i18n from '@/i18n';
|
||||
import { useSystemStore } from '@/stores/systemStore';
|
||||
import { showContextMenu } from './manager';
|
||||
import {
|
||||
undo, redo
|
||||
} from "@codemirror/commands";
|
||||
import i18n from "@/i18n";
|
||||
import {useSystemStore} from "@/stores/systemStore";
|
||||
buildRegisteredMenu,
|
||||
createMenuContext,
|
||||
registerMenuNodes
|
||||
} from './menuSchema';
|
||||
import type { MenuSchemaNode } from './menuSchema';
|
||||
|
||||
/**
|
||||
* 菜单项类型定义
|
||||
*/
|
||||
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 {
|
||||
|
||||
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
|
||||
const binding = keybindingStore.keyBindings.find(
|
||||
(kb) => kb.command === command && kb.enabled
|
||||
);
|
||||
|
||||
if (binding && binding.key) {
|
||||
// 格式化快捷键显示
|
||||
return formatKeyBinding(binding.key);
|
||||
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 "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化快捷键显示
|
||||
* @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[] {
|
||||
function getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.blockCopy"),
|
||||
id: "copy",
|
||||
labelKey: "keybindings.commands.blockCopy",
|
||||
command: copyCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
|
||||
enabled: (context) => context.hasSelection
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockCut"),
|
||||
id: "cut",
|
||||
labelKey: "keybindings.commands.blockCut",
|
||||
command: cutCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockCutCommand,
|
||||
visible: (context) => context.isEditable,
|
||||
enabled: (context) => context.hasSelection && context.isEditable
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockPaste"),
|
||||
id: "paste",
|
||||
labelKey: "keybindings.commands.blockPaste",
|
||||
command: pasteCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建历史操作菜单项
|
||||
*/
|
||||
function createHistoryItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.historyUndo"),
|
||||
command: undo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.historyRedo"),
|
||||
id: "undo",
|
||||
labelKey: "keybindings.commands.historyUndo",
|
||||
command: undo,
|
||||
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
labelKey: "keybindings.commands.historyRedo",
|
||||
command: redo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
||||
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
let builtinMenuRegistered = false;
|
||||
|
||||
/**
|
||||
* 创建主菜单项
|
||||
*/
|
||||
function createMainMenuItems(): MenuItem[] {
|
||||
// 基本编辑操作放在主菜单
|
||||
const basicItems = createEditItems();
|
||||
|
||||
// 历史操作放在主菜单
|
||||
const historyItems = createHistoryItems();
|
||||
|
||||
// 构建主菜单
|
||||
return [
|
||||
...basicItems,
|
||||
...historyItems
|
||||
];
|
||||
function ensureBuiltinMenuRegistered(): void {
|
||||
if (builtinMenuRegistered) return;
|
||||
registerMenuNodes(getBuiltinMenuNodes());
|
||||
builtinMenuRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建编辑器上下文菜单
|
||||
*/
|
||||
|
||||
export function createEditorContextMenu(): Extension {
|
||||
// 为编辑器添加右键事件处理
|
||||
ensureBuiltinMenuRegistered();
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
contextmenu: (event, view) => {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
|
||||
// 获取菜单项
|
||||
const menuItems = createMainMenuItems();
|
||||
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/contextMenu/manager.ts
Normal file
108
frontend/src/views/editor/contextMenu/manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: MenuPosition;
|
||||
items: RenderMenuItem[];
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
class ContextMenuManager {
|
||||
private state: ShallowRef<ContextMenuState> = shallowRef({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [] as RenderMenuItem[],
|
||||
view: null as EditorView | null
|
||||
});
|
||||
|
||||
useState() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
|
||||
const currentState = this.state.value;
|
||||
|
||||
// 如果菜单已经显示,且位置很接近(20px范围内),则只更新内容,避免闪烁
|
||||
if (currentState.visible) {
|
||||
const dx = Math.abs(currentState.position.x - clientX);
|
||||
const dy = Math.abs(currentState.position.y - clientY);
|
||||
const isSamePosition = dx < 20 && dy < 20;
|
||||
|
||||
if (isSamePosition) {
|
||||
// 只更新items和view,保持visible状态和位置
|
||||
this.state.value = {
|
||||
...currentState,
|
||||
items,
|
||||
view
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则正常显示菜单
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
items,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPosition = this.state.value.position;
|
||||
const view = this.state.value.view;
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: previousPosition,
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
runCommand(item: RenderMenuItem): void {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.state.value;
|
||||
if (item.command && view) {
|
||||
item.command(view);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenuManager = new ContextMenuManager();
|
||||
|
||||
export function showContextMenu(
|
||||
view: EditorView,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
items: RenderMenuItem[]
|
||||
): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
|
||||
export interface MenuContext {
|
||||
view: EditorView;
|
||||
event: MouseEvent;
|
||||
hasSelection: boolean;
|
||||
selectionText: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export type MenuSchemaNode =
|
||||
| {
|
||||
id: string;
|
||||
type?: "action";
|
||||
labelKey: string;
|
||||
command?: (view: EditorView) => boolean;
|
||||
shortcutCommand?: KeyBindingCommand;
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
enabled?: (context: MenuContext) => boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "separator";
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
};
|
||||
|
||||
export interface RenderMenuItem {
|
||||
id: string;
|
||||
type: "action" | "separator";
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
command?: (view: EditorView) => boolean;
|
||||
}
|
||||
|
||||
interface MenuBuildOptions {
|
||||
translate: (key: string) => string;
|
||||
formatShortcut: (command?: KeyBindingCommand) => string;
|
||||
}
|
||||
|
||||
const menuRegistry: MenuSchemaNode[] = [];
|
||||
|
||||
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
|
||||
const { state } = view;
|
||||
const hasSelection = state.selection.ranges.some((range) => !range.empty);
|
||||
const selectionText = hasSelection
|
||||
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
|
||||
: "";
|
||||
const isEditable = !state.facet(EditorState.readOnly);
|
||||
|
||||
return {
|
||||
view,
|
||||
event,
|
||||
hasSelection,
|
||||
selectionText,
|
||||
isEditable
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
|
||||
menuRegistry.push(...nodes);
|
||||
}
|
||||
|
||||
export function buildRegisteredMenu(
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem[] {
|
||||
return menuRegistry
|
||||
.map((node) => convertNode(node, context, options))
|
||||
.filter((item): item is RenderMenuItem => Boolean(item));
|
||||
}
|
||||
|
||||
function convertNode(
|
||||
node: MenuSchemaNode,
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem | null {
|
||||
if (node.visible && !node.visible(context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === "separator") {
|
||||
return {
|
||||
id: node.id,
|
||||
type: "separator"
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = node.enabled ? !node.enabled(context) : false;
|
||||
const shortcut = options.formatShortcut(node.shortcutCommand);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: "action",
|
||||
label: options.translate(node.labelKey),
|
||||
shortcut: shortcut || undefined,
|
||||
disabled,
|
||||
command: node.command
|
||||
};
|
||||
}
|
||||
56
frontend/src/views/editor/extensions/codeblock/annotation.ts
Normal file
56
frontend/src/views/editor/extensions/codeblock/annotation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Annotation, Transaction } from "@codemirror/state";
|
||||
|
||||
/**
|
||||
* 统一的 CodeBlock 注解,用于标记内部触发的事务。
|
||||
*/
|
||||
export const codeBlockEvent = Annotation.define<string>();
|
||||
|
||||
export const LANGUAGE_CHANGE = "codeblock-language-change";
|
||||
export const ADD_NEW_BLOCK = "codeblock-add-new-block";
|
||||
export const MOVE_BLOCK = "codeblock-move-block";
|
||||
export const DELETE_BLOCK = "codeblock-delete-block";
|
||||
export const CURRENCIES_LOADED = "codeblock-currencies-loaded";
|
||||
export const CONTENT_EDIT = "codeblock-content-edit";
|
||||
|
||||
/**
|
||||
* 统一管理的 userEvent 常量。
|
||||
*/
|
||||
export const USER_EVENTS = {
|
||||
INPUT: "input",
|
||||
DELETE: "delete",
|
||||
MOVE: "move",
|
||||
SELECT: "select",
|
||||
DELETE_LINE: "delete.line",
|
||||
DELETE_CUT: "delete.cut",
|
||||
INPUT_PASTE: "input.paste",
|
||||
MOVE_LINE: "move.line",
|
||||
MOVE_CHARACTER: "move.character",
|
||||
SELECT_BLOCK_BOUNDARY: "select.block-boundary",
|
||||
INPUT_REPLACE: "input.replace",
|
||||
INPUT_REPLACE_ALL: "input.replace.all",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 判断事务列表中是否包含指定注解。
|
||||
*/
|
||||
export function transactionsHasAnnotation(
|
||||
transactions: readonly Transaction[],
|
||||
annotation: string
|
||||
) {
|
||||
return transactions.some(
|
||||
tr => tr.annotation(codeBlockEvent) === annotation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断事务列表中是否包含任一注解。
|
||||
*/
|
||||
export function transactionsHasAnnotationsAny(
|
||||
transactions: readonly Transaction[],
|
||||
annotations: readonly string[]
|
||||
) {
|
||||
return transactions.some(tr => {
|
||||
const value = tr.annotation(codeBlockEvent);
|
||||
return value ? annotations.includes(value) : false;
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
* Block 命令
|
||||
*/
|
||||
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
|
||||
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
|
||||
import { formatBlockContent } from "./formatCode";
|
||||
import { codeBlockEvent, LANGUAGE_CHANGE, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK, CURRENCIES_LOADED, USER_EVENTS } from "./annotation";
|
||||
|
||||
/**
|
||||
* 获取块分隔符
|
||||
@@ -32,7 +33,7 @@ export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ st
|
||||
|
||||
dispatch(state.replaceSelection(delimText), {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -55,9 +56,10 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -79,10 +81,11 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s
|
||||
from: block.content.to,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length)
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -105,9 +108,10 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -129,10 +133,11 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
||||
from: block.content.to,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length)
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -144,11 +149,6 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
||||
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
|
||||
if (state.readOnly) return false;
|
||||
|
||||
const currentDelimiter = state.doc.sliceString(block.delimiter.from, block.delimiter.to);
|
||||
|
||||
// 重置正则表达式的 lastIndex
|
||||
DELIMITER_REGEX.lastIndex = 0;
|
||||
if (currentDelimiter.match(DELIMITER_REGEX)) {
|
||||
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
||||
|
||||
dispatch({
|
||||
@@ -157,12 +157,10 @@ export function changeLanguageTo(state: any, dispatch: any, block: Block, langua
|
||||
to: block.delimiter.to,
|
||||
insert: newDelimiter,
|
||||
},
|
||||
annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)],
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +187,7 @@ function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelecti
|
||||
}
|
||||
|
||||
function setSel(state: any, selection: EditorSelection) {
|
||||
return state.update({ selection, scrollIntoView: true, userEvent: "select" });
|
||||
return state.update({ selection, scrollIntoView: true, userEvent: USER_EVENTS.SELECT });
|
||||
}
|
||||
|
||||
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
|
||||
@@ -303,10 +301,11 @@ export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispa
|
||||
to: block.range.to,
|
||||
insert: ""
|
||||
},
|
||||
selection: EditorSelection.cursor(newCursorPos)
|
||||
selection: EditorSelection.cursor(newCursorPos),
|
||||
annotations: [codeBlockEvent.of(DELETE_BLOCK)]
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete"
|
||||
userEvent: USER_EVENTS.DELETE
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -366,10 +365,11 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
||||
|
||||
dispatch(state.update({
|
||||
changes,
|
||||
selection: EditorSelection.cursor(newCursorPos)
|
||||
selection: EditorSelection.cursor(newCursorPos),
|
||||
annotations: [codeBlockEvent.of(MOVE_BLOCK)]
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "move"
|
||||
userEvent: USER_EVENTS.MOVE
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -381,3 +381,20 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
||||
export const formatCurrentBlock: Command = (view) => {
|
||||
return formatBlockContent(view);
|
||||
};
|
||||
|
||||
/**
|
||||
* 触发一次货币数据刷新,让数学块重新计算
|
||||
*/
|
||||
export function triggerCurrenciesLoaded({ state, dispatch }: { state: any; dispatch: any }) {
|
||||
if (!dispatch || state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
dispatch(state.update({
|
||||
changes: { from: 0, to: 0, insert: "" },
|
||||
annotations: [
|
||||
codeBlockEvent.of(CURRENCIES_LOADED),
|
||||
Transaction.addToHistory.of(false)
|
||||
],
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorState, EditorSelection } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { LANGUAGES } from "./lang-parser/languages";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 构建块分隔符正则表达式
|
||||
@@ -89,7 +90,8 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
view.dispatch({
|
||||
changes: ranges,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.cut"
|
||||
userEvent: USER_EVENTS.DELETE_CUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,8 @@ const copyCut = (view: EditorView, cut: boolean): boolean => {
|
||||
view.dispatch({
|
||||
changes: ranges,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.cut"
|
||||
userEvent: USER_EVENTS.DELETE_CUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,8 +145,9 @@ function doPaste(view: EditorView, input: string) {
|
||||
}
|
||||
|
||||
view.dispatch(changes, {
|
||||
userEvent: "input.paste",
|
||||
scrollIntoView: true
|
||||
userEvent: USER_EVENTS.INPUT_PASTE,
|
||||
scrollIntoView: true,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorView } from '@codemirror/view';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { blockState } from './state';
|
||||
import { Block } from './types';
|
||||
import { USER_EVENTS } from './annotation';
|
||||
|
||||
/**
|
||||
* 二分查找:找到包含指定位置的块
|
||||
@@ -136,7 +137,7 @@ export function createCursorProtection() {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(adjustedPos),
|
||||
scrollIntoView: true,
|
||||
userEvent: 'select'
|
||||
userEvent: USER_EVENTS.SELECT
|
||||
});
|
||||
|
||||
// 阻止默认行为
|
||||
@@ -148,4 +149,3 @@ export function createCursorProtection() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
|
||||
import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view";
|
||||
import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state";
|
||||
import { StateField, RangeSetBuilder, EditorState, Transaction } from "@codemirror/state";
|
||||
import { blockState } from "./state";
|
||||
import { codeBlockEvent, USER_EVENTS } from "./annotation";
|
||||
|
||||
/**
|
||||
* 块开始装饰组件
|
||||
@@ -180,10 +181,11 @@ const blockLayer = layer({
|
||||
*/
|
||||
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
|
||||
const protect: number[] = [];
|
||||
const internalEvent = tr.annotation(codeBlockEvent);
|
||||
|
||||
// 获取块状态并获取第一个块的分隔符大小
|
||||
const blocks = tr.startState.field(blockState);
|
||||
if (blocks && blocks.length > 0) {
|
||||
if (!internalEvent && blocks && blocks.length > 0) {
|
||||
const firstBlock = blocks[0];
|
||||
const firstBlockDelimiterSize = firstBlock.delimiter.to;
|
||||
|
||||
@@ -194,23 +196,27 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any)
|
||||
}
|
||||
|
||||
// 如果是搜索替换操作,保护所有块分隔符
|
||||
if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) {
|
||||
blocks.forEach((block: any) => {
|
||||
const userEvent = tr.annotation(Transaction.userEvent);
|
||||
if (userEvent && (userEvent === USER_EVENTS.INPUT_REPLACE || userEvent === USER_EVENTS.INPUT_REPLACE_ALL)) {
|
||||
blocks?.forEach((block: any) => {
|
||||
if (block.delimiter) {
|
||||
protect.push(block.delimiter.from, block.delimiter.to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 返回保护范围数组,如果没有需要保护的范围则返回 false
|
||||
return protect.length > 0 ? protect : false;
|
||||
});
|
||||
// 返回保护范围数组;若无需保护则返回 true 放行事务
|
||||
return protect.length > 0 ? protect : true;
|
||||
})
|
||||
|
||||
/**
|
||||
* 防止选择在第一个块之前
|
||||
* 使用 transactionFilter 来确保选择不会在第一个块之前
|
||||
*/
|
||||
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
|
||||
if (tr.annotation(codeBlockEvent)) {
|
||||
return tr;
|
||||
}
|
||||
// 获取块状态并获取第一个块的分隔符大小
|
||||
const blocks = tr.startState.field(blockState);
|
||||
if (!blocks || blocks.length === 0) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
import { USER_EVENTS } from "./annotation";
|
||||
|
||||
interface LineBlock {
|
||||
from: number;
|
||||
@@ -87,7 +89,8 @@ export const deleteLine = (view: EditorView): boolean => {
|
||||
changes,
|
||||
selection,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.line"
|
||||
userEvent: USER_EVENTS.DELETE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -127,7 +130,8 @@ export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: a
|
||||
changes,
|
||||
selection,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.line"
|
||||
userEvent: USER_EVENTS.DELETE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as prettier from "prettier/standalone";
|
||||
import { getActiveNoteBlock } from "./state";
|
||||
import { getLanguage } from "./lang-parser/languages";
|
||||
import { SupportedLanguage } from "./types";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
export const formatBlockContent = (view) => {
|
||||
if (!view || view.state.readOnly)
|
||||
@@ -87,7 +88,8 @@ export const formatBlockContent = (view) => {
|
||||
},
|
||||
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input"
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { transactionsHasAnnotation, CURRENCIES_LOADED } from "./annotation";
|
||||
|
||||
type MathParserEntry = {
|
||||
parser: any;
|
||||
prev?: any;
|
||||
};
|
||||
// 声明全局math对象
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -62,8 +68,7 @@ class MathResult extends WidgetType {
|
||||
/**
|
||||
* 数学装饰函数
|
||||
*/
|
||||
function mathDeco(view: any): any {
|
||||
const mathParsers = new WeakMap();
|
||||
function mathDeco(view: any, parserCache: WeakMap<any, MathParserEntry>): any {
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
@@ -72,12 +77,17 @@ function mathDeco(view: any): any {
|
||||
const block = getNoteBlockFromPos(view.state, pos);
|
||||
|
||||
if (block && block.language.name === "math") {
|
||||
// get math.js parser and cache it for this block
|
||||
let { parser, prev } = mathParsers.get(block) || {};
|
||||
let entry = parserCache.get(block);
|
||||
let parser = entry?.parser;
|
||||
if (!parser) {
|
||||
if (line.from > block.content.from) {
|
||||
pos = block.content.from;
|
||||
continue;
|
||||
}
|
||||
if (typeof window.math !== 'undefined') {
|
||||
parser = window.math.parser();
|
||||
mathParsers.set(block, { parser, prev });
|
||||
entry = { parser, prev: undefined };
|
||||
parserCache.set(block, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +95,15 @@ function mathDeco(view: any): any {
|
||||
let result: any;
|
||||
try {
|
||||
if (parser) {
|
||||
parser.set("prev", prev);
|
||||
if (entry && line.from === block.content.from && typeof parser.clear === "function") {
|
||||
parser.clear();
|
||||
entry.prev = undefined;
|
||||
}
|
||||
const prevValue = entry?.prev;
|
||||
parser.set("prev", prevValue);
|
||||
result = parser.evaluate(line.text);
|
||||
if (result !== undefined) {
|
||||
mathParsers.set(block, { parser, prev: result });
|
||||
if (entry && result !== undefined) {
|
||||
entry.prev = result;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -97,7 +112,7 @@ function mathDeco(view: any): any {
|
||||
|
||||
// if we got a result from math.js, add the result decoration
|
||||
if (result !== undefined) {
|
||||
const format = parser?.get("format");
|
||||
const format = parser?.get?.("format");
|
||||
|
||||
let resultWidget: MathResult | undefined;
|
||||
if (typeof(result) === "string") {
|
||||
@@ -142,15 +157,25 @@ function mathDeco(view: any): any {
|
||||
*/
|
||||
export const mathBlock = ViewPlugin.fromClass(class {
|
||||
decorations: any;
|
||||
mathParsers: WeakMap<any, MathParserEntry>;
|
||||
|
||||
constructor(view: any) {
|
||||
this.decorations = mathDeco(view);
|
||||
this.mathParsers = new WeakMap();
|
||||
this.decorations = mathDeco(view, this.mathParsers);
|
||||
}
|
||||
|
||||
update(update: any) {
|
||||
// If the document changed, the viewport changed, update the decorations
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = mathDeco(update.view);
|
||||
const hasCurrencyUpdate = transactionsHasAnnotation(update.transactions, CURRENCIES_LOADED);
|
||||
if (update.docChanged || hasCurrencyUpdate) {
|
||||
// 文档结构或汇率变化时重置解析缓存
|
||||
this.mathParsers = new WeakMap();
|
||||
}
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
hasCurrencyUpdate
|
||||
) {
|
||||
this.decorations = mathDeco(update.view, this.mathParsers);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||
import { blockState } from "./state";
|
||||
import { LANGUAGES } from "./lang-parser/languages";
|
||||
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
import { USER_EVENTS } from "./annotation";
|
||||
|
||||
interface LineBlock {
|
||||
from: number;
|
||||
@@ -131,7 +133,8 @@ function moveLine(state: any, dispatch: any, forward: boolean): boolean {
|
||||
changes,
|
||||
scrollIntoView: true,
|
||||
selection: EditorSelection.create(ranges, state.selection.mainIndex),
|
||||
userEvent: "move.line"
|
||||
userEvent: USER_EVENTS.MOVE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
|
||||
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language';
|
||||
import type { Tree } from '@lezer/common';
|
||||
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
|
||||
import {
|
||||
SupportedLanguage,
|
||||
@@ -15,51 +16,47 @@ import {
|
||||
} from './types';
|
||||
import { LANGUAGES } from './lang-parser/languages';
|
||||
|
||||
const DEFAULT_LANGUAGE = (LANGUAGES[0]?.token || 'text') as string;
|
||||
|
||||
/**
|
||||
* 从语法树解析代码块
|
||||
*/
|
||||
export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
||||
if (!syntaxTreeAvailable(state)) {
|
||||
const tree = syntaxTree(state);
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
return collectBlocksFromTree(tree, state);
|
||||
}
|
||||
|
||||
const tree = syntaxTree(state);
|
||||
function collectBlocksFromTree(tree: Tree, state: EditorState): Block[] | null {
|
||||
const blocks: Block[] = [];
|
||||
const doc = state.doc;
|
||||
|
||||
// 遍历语法树中的所有块
|
||||
tree.iterate({
|
||||
enter(node) {
|
||||
if (node.type.id === BlockNode) {
|
||||
// 查找块的分隔符和内容
|
||||
let delimiter: { from: number; to: number } | null = null;
|
||||
let content: { from: number; to: number } | null = null;
|
||||
let language = 'text';
|
||||
let language: string = DEFAULT_LANGUAGE;
|
||||
let auto = false;
|
||||
|
||||
// 遍历块的子节点
|
||||
const blockNode = node.node;
|
||||
blockNode.firstChild?.cursor().iterate(child => {
|
||||
if (child.type.id === BlockDelimiter) {
|
||||
delimiter = { from: child.from, to: child.to };
|
||||
|
||||
// 解析整个分隔符文本来获取语言和自动检测标记
|
||||
const delimiterText = doc.sliceString(child.from, child.to);
|
||||
|
||||
// 使用正则表达式解析分隔符
|
||||
const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/);
|
||||
if (match) {
|
||||
language = match[1] || 'text';
|
||||
language = match[1] || DEFAULT_LANGUAGE;
|
||||
auto = match[2] === '-a';
|
||||
} else {
|
||||
// 回退到逐个解析子节点
|
||||
child.node.firstChild?.cursor().iterate(langChild => {
|
||||
if (langChild.type.id === BlockLanguage) {
|
||||
const langText = doc.sliceString(langChild.from, langChild.to);
|
||||
language = langText || 'text';
|
||||
language = langText || DEFAULT_LANGUAGE;
|
||||
}
|
||||
// 检查是否有自动检测标记
|
||||
if (doc.sliceString(langChild.from, langChild.to) === '-a') {
|
||||
if (doc.sliceString(langChild.from, langChild.to) === AUTO_DETECT_SUFFIX) {
|
||||
auto = true;
|
||||
}
|
||||
});
|
||||
@@ -88,7 +85,6 @@ export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
||||
});
|
||||
|
||||
if (blocks.length > 0) {
|
||||
// 设置第一个块分隔符的大小
|
||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||
return blocks;
|
||||
}
|
||||
@@ -107,183 +103,52 @@ export function getBlocksFromString(state: EditorState): Block[] {
|
||||
const doc = state.doc;
|
||||
|
||||
if (doc.length === 0) {
|
||||
// 如果文档为空,创建一个默认的文本块
|
||||
return [{
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
}];
|
||||
return [createPlainTextBlock(0, 0)];
|
||||
}
|
||||
|
||||
const content = doc.sliceString(0, doc.length);
|
||||
const delim = "\n∞∞∞";
|
||||
let pos = 0;
|
||||
const delimiter = DELIMITER_PREFIX;
|
||||
const suffixLength = DELIMITER_SUFFIX.length;
|
||||
|
||||
// 检查文档是否以分隔符开始(不带前导换行符)
|
||||
if (content.startsWith("∞∞∞")) {
|
||||
// 文档直接以分隔符开始,调整为标准格式
|
||||
pos = 0;
|
||||
} else if (content.startsWith("\n∞∞∞")) {
|
||||
// 文档以换行符+分隔符开始,这是标准格式,从位置0开始解析
|
||||
pos = 0;
|
||||
} else {
|
||||
// 如果文档不以分隔符开始,查找第一个分隔符
|
||||
const firstDelimPos = content.indexOf(delim);
|
||||
let pos = content.indexOf(delimiter);
|
||||
|
||||
if (firstDelimPos === -1) {
|
||||
// 如果没有找到分隔符,整个文档作为一个文本块
|
||||
if (pos === -1) {
|
||||
firstBlockDelimiterSize = 0;
|
||||
return [{
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
}];
|
||||
return [createPlainTextBlock(0, doc.length)];
|
||||
}
|
||||
|
||||
// 创建第一个块(分隔符之前的内容)
|
||||
if (pos > 0) {
|
||||
blocks.push(createPlainTextBlock(0, pos));
|
||||
}
|
||||
|
||||
while (pos !== -1 && pos < doc.length) {
|
||||
const blockStart = pos;
|
||||
const langStart = blockStart + delimiter.length;
|
||||
const delimiterEnd = content.indexOf(DELIMITER_SUFFIX, langStart);
|
||||
if (delimiterEnd === -1) break;
|
||||
|
||||
const delimiterText = content.slice(blockStart, delimiterEnd + suffixLength);
|
||||
const delimiterInfo = parseDelimiter(delimiterText);
|
||||
if (!delimiterInfo) break;
|
||||
|
||||
const contentStart = delimiterEnd + suffixLength;
|
||||
const nextDelimiter = content.indexOf(delimiter, contentStart);
|
||||
const contentEnd = nextDelimiter === -1 ? doc.length : nextDelimiter;
|
||||
|
||||
blocks.push({
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: firstDelimPos,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: firstDelimPos,
|
||||
},
|
||||
language: { name: delimiterInfo.language, auto: delimiterInfo.auto },
|
||||
content: { from: contentStart, to: contentEnd },
|
||||
delimiter: { from: blockStart, to: delimiterEnd + suffixLength },
|
||||
range: { from: blockStart, to: contentEnd },
|
||||
});
|
||||
|
||||
pos = firstDelimPos;
|
||||
firstBlockDelimiterSize = 0;
|
||||
pos = nextDelimiter;
|
||||
}
|
||||
|
||||
while (pos < doc.length) {
|
||||
let blockStart: number;
|
||||
|
||||
if (pos === 0 && content.startsWith("∞∞∞")) {
|
||||
// 处理文档开头直接是分隔符的情况(不带前导换行符)
|
||||
blockStart = 0;
|
||||
} else if (pos === 0 && content.startsWith("\n∞∞∞")) {
|
||||
// 处理文档开头是换行符+分隔符的情况(标准格式)
|
||||
blockStart = 0;
|
||||
} else {
|
||||
blockStart = content.indexOf(delim, pos);
|
||||
if (blockStart !== pos) {
|
||||
// 如果在当前位置没有找到分隔符,可能是文档结尾
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 确定语言开始位置
|
||||
let langStart: number;
|
||||
if (pos === 0 && content.startsWith("∞∞∞")) {
|
||||
// 文档直接以分隔符开始,跳过 ∞∞∞
|
||||
langStart = blockStart + 3;
|
||||
} else {
|
||||
// 标准情况,跳过 \n∞∞∞
|
||||
langStart = blockStart + delim.length;
|
||||
}
|
||||
|
||||
const delimiterEnd = content.indexOf("\n", langStart);
|
||||
if (delimiterEnd < 0) {
|
||||
console.error("Error parsing blocks. Delimiter didn't end with newline");
|
||||
break;
|
||||
}
|
||||
|
||||
const langFull = content.substring(langStart, delimiterEnd);
|
||||
let auto = false;
|
||||
let lang = langFull;
|
||||
|
||||
if (langFull.endsWith("-a")) {
|
||||
auto = true;
|
||||
lang = langFull.substring(0, langFull.length - 2);
|
||||
}
|
||||
|
||||
const contentFrom = delimiterEnd + 1;
|
||||
let blockEnd = content.indexOf(delim, contentFrom);
|
||||
if (blockEnd < 0) {
|
||||
blockEnd = doc.length;
|
||||
}
|
||||
|
||||
const block: Block = {
|
||||
language: {
|
||||
name: lang || 'text',
|
||||
auto: auto,
|
||||
},
|
||||
content: {
|
||||
from: contentFrom,
|
||||
to: blockEnd,
|
||||
},
|
||||
delimiter: {
|
||||
from: blockStart,
|
||||
to: delimiterEnd + 1,
|
||||
},
|
||||
range: {
|
||||
from: blockStart,
|
||||
to: blockEnd,
|
||||
},
|
||||
};
|
||||
|
||||
blocks.push(block);
|
||||
pos = blockEnd;
|
||||
}
|
||||
|
||||
// 如果没有找到任何块,创建一个默认块
|
||||
if (blocks.length === 0) {
|
||||
blocks.push({
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
});
|
||||
blocks.push(createPlainTextBlock(0, doc.length));
|
||||
firstBlockDelimiterSize = 0;
|
||||
} else {
|
||||
// 设置第一个块分隔符的大小
|
||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||
}
|
||||
|
||||
@@ -294,13 +159,19 @@ export function getBlocksFromString(state: EditorState): Block[] {
|
||||
* 获取文档中的所有块
|
||||
*/
|
||||
export function getBlocks(state: EditorState): Block[] {
|
||||
// 优先使用语法树解析
|
||||
const syntaxTreeBlocks = getBlocksFromSyntaxTree(state);
|
||||
if (syntaxTreeBlocks) {
|
||||
return syntaxTreeBlocks;
|
||||
let blocks = getBlocksFromSyntaxTree(state);
|
||||
if (blocks) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const ensuredTree = ensureSyntaxTree(state, state.doc.length, 200);
|
||||
if (ensuredTree) {
|
||||
blocks = collectBlocksFromTree(ensuredTree, state);
|
||||
if (blocks) {
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果语法树不可用,回退到字符串解析
|
||||
return getBlocksFromString(state);
|
||||
}
|
||||
|
||||
@@ -396,10 +267,21 @@ export function parseDelimiter(delimiterText: string): { language: SupportedLang
|
||||
|
||||
const validLanguage = LANGUAGES.some(lang => lang.token === languageName)
|
||||
? languageName as SupportedLanguage
|
||||
: 'text';
|
||||
: DEFAULT_LANGUAGE as SupportedLanguage;
|
||||
|
||||
return {
|
||||
language: validLanguage,
|
||||
auto: isAuto
|
||||
};
|
||||
}
|
||||
|
||||
function createPlainTextBlock(from: number, to: number): Block {
|
||||
return {
|
||||
language: { name: DEFAULT_LANGUAGE, auto: false },
|
||||
content: { from, to },
|
||||
delimiter: { from: 0, to: 0 },
|
||||
range: { from, to },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StateField, StateEffect, RangeSetBuilder, EditorSelection, EditorState,
|
||||
import { selectAll as defaultSelectAll } from "@codemirror/commands";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { getActiveNoteBlock, blockState } from "./state";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
|
||||
@@ -115,7 +116,8 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
||||
// 选择当前块的所有内容
|
||||
dispatch(state.update({
|
||||
selection: { anchor: block.content.from, head: block.content.to },
|
||||
userEvent: "select"
|
||||
userEvent: USER_EVENTS.SELECT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -127,7 +129,7 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
||||
*/
|
||||
export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => {
|
||||
// 只处理选择变化的事务,并且忽略我们自己生成的事务
|
||||
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === "select.block-boundary") {
|
||||
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === USER_EVENTS.SELECT_BLOCK_BOUNDARY) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) =>
|
||||
return {
|
||||
...tr,
|
||||
selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex),
|
||||
annotations: tr.annotations.concat(Transaction.userEvent.of("select.block-boundary"))
|
||||
annotations: tr.annotations.concat(Transaction.userEvent.of(USER_EVENTS.SELECT_BLOCK_BOUNDARY))
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { EditorSelection, findClusterBreak } from "@codemirror/state";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 交换光标前后的字符
|
||||
@@ -46,7 +47,8 @@ export const transposeChars = ({ state, dispatch }: { state: any; dispatch: any
|
||||
|
||||
dispatch(state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "move.character"
|
||||
userEvent: USER_EVENTS.MOVE_CHARACTER,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
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/gi;
|
||||
|
||||
export interface HyperLinkState {
|
||||
at: number;
|
||||
@@ -54,18 +53,8 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
class: 'cm-hyper-link-text',
|
||||
attributes: {
|
||||
'data-url': match[0]
|
||||
}
|
||||
class: 'cm-hyper-link-text'
|
||||
});
|
||||
widgets.push(linkMark.range(from, to));
|
||||
|
||||
@@ -91,14 +80,7 @@ const linkDecorator = (
|
||||
) =>
|
||||
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;
|
||||
}
|
||||
|
||||
decorate: (add, from, to, match, _view) => {
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
@@ -109,10 +91,7 @@ const linkDecorator = (
|
||||
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
|
||||
}
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline'
|
||||
}));
|
||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||
},
|
||||
@@ -158,7 +137,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
|
||||
export const hyperLinkStyle = EditorView.baseTheme({
|
||||
'.cm-hyper-link-text': {
|
||||
color: '#0969da',
|
||||
cursor: 'pointer',
|
||||
cursor: 'text',
|
||||
transition: 'color 0.2s ease',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#0969da',
|
||||
@@ -216,17 +195,12 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
});
|
||||
|
||||
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
||||
click: (event, view) => {
|
||||
const target = event.target as HTMLElement;
|
||||
let urlElement = target;
|
||||
click: (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
|
||||
|
||||
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');
|
||||
if (iconElement && iconElement.hasAttribute('data-url')) {
|
||||
const url = iconElement.getAttribute('data-url');
|
||||
if (url) {
|
||||
runtime.Browser.OpenURL(url);
|
||||
event.preventDefault();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Markdown 预览扩展主入口
|
||||
*/
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Compartment } from "@codemirror/state";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { usePanelStore } from "@/stores/panelStore";
|
||||
import { useDocumentStore } from "@/stores/documentStore";
|
||||
@@ -52,11 +53,30 @@ export function toggleMarkdownPreview(view: EditorView): boolean {
|
||||
/**
|
||||
* 导出 Markdown 预览扩展
|
||||
*/
|
||||
export function markdownPreviewExtension() {
|
||||
const previewThemeCompartment = new Compartment();
|
||||
|
||||
const buildPreviewTheme = () => {
|
||||
const themeStore = useThemeStore();
|
||||
const colors = themeStore.currentColors;
|
||||
return colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
|
||||
};
|
||||
|
||||
const theme = colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
|
||||
|
||||
return [previewPanelState, previewPanelPlugin, theme];
|
||||
export function markdownPreviewExtension() {
|
||||
return [
|
||||
previewPanelState,
|
||||
previewPanelPlugin,
|
||||
previewThemeCompartment.of(buildPreviewTheme())
|
||||
];
|
||||
}
|
||||
|
||||
export function updateMarkdownPreviewTheme(view: EditorView): void {
|
||||
if (!view?.dispatch) return;
|
||||
|
||||
try {
|
||||
view.dispatch({
|
||||
effects: previewThemeCompartment.reconfigure(buildPreviewTheme())
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update markdown preview theme", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class MarkdownPreviewPanel {
|
||||
private readonly resizeHandle: HTMLDivElement;
|
||||
private readonly content: HTMLDivElement;
|
||||
private view: EditorView;
|
||||
private themeUnwatch?: () => void;
|
||||
private themeUnwatchers: Array<() => void> = [];
|
||||
private lastRenderedContent: string = "";
|
||||
private readonly debouncedUpdate: ReturnType<typeof createDebounce>;
|
||||
private isDestroyed: boolean = false; // 标记面板是否已销毁
|
||||
@@ -38,11 +38,22 @@ export class MarkdownPreviewPanel {
|
||||
|
||||
// 监听主题变化
|
||||
const themeStore = useThemeStore();
|
||||
this.themeUnwatch = watch(() => themeStore.isDarkMode, (isDark) => {
|
||||
this.themeUnwatchers.push(
|
||||
watch(() => themeStore.isDarkMode, (isDark) => {
|
||||
const newTheme = isDark ? "dark" : "default";
|
||||
updateMermaidTheme(newTheme);
|
||||
this.lastRenderedContent = ""; // 清空缓存,强制重新渲染
|
||||
});
|
||||
this.resetPreviewContent();
|
||||
})
|
||||
);
|
||||
this.themeUnwatchers.push(
|
||||
watch(
|
||||
() => themeStore.currentColors,
|
||||
() => {
|
||||
this.resetPreviewContent();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
// 创建 DOM 结构
|
||||
this.dom = document.createElement("div");
|
||||
@@ -315,6 +326,16 @@ export class MarkdownPreviewPanel {
|
||||
});
|
||||
}
|
||||
|
||||
private resetPreviewContent(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.md = createMarkdownRenderer();
|
||||
this.lastRenderedContent = "";
|
||||
this.updateContentInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应编辑器更新
|
||||
*/
|
||||
@@ -342,6 +363,11 @@ export class MarkdownPreviewPanel {
|
||||
|
||||
// 清空缓存
|
||||
this.lastRenderedContent = "";
|
||||
|
||||
if (this.themeUnwatchers.length) {
|
||||
this.themeUnwatchers.forEach(unwatch => unwatch());
|
||||
this.themeUnwatchers = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,34 @@ export function createMarkdownPreviewTheme(colors: ThemeColors) {
|
||||
}
|
||||
},
|
||||
|
||||
// 面板动画效果
|
||||
'.cm-panels.cm-panels-top': {
|
||||
borderBottom: '2px solid black'
|
||||
},
|
||||
'.cm-panels.cm-panels-bottom': {
|
||||
animation: 'panelSlideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
},
|
||||
'@keyframes panelSlideUp': {
|
||||
from: {
|
||||
transform: 'translateY(100%)',
|
||||
opacity: '0'
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(0)',
|
||||
opacity: '1'
|
||||
}
|
||||
},
|
||||
'@keyframes panelSlideDown': {
|
||||
from: {
|
||||
transform: 'translateY(0)',
|
||||
opacity: '1'
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(100%)',
|
||||
opacity: '0'
|
||||
}
|
||||
},
|
||||
|
||||
// 内容区域
|
||||
".cm-preview-content": {
|
||||
flex: 1,
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onUnmounted, ref, watch} from 'vue';
|
||||
import {translatorManager} from './manager';
|
||||
import {useTranslationStore} from '@/stores/translationStore';
|
||||
|
||||
const props = defineProps<{
|
||||
portalTarget?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const state = translatorManager.useState();
|
||||
const translationStore = useTranslationStore();
|
||||
|
||||
const dialogRef = ref<HTMLDivElement | null>(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const isVisible = computed(() => state.value.visible);
|
||||
const sourceText = computed(() => state.value.sourceText);
|
||||
const position = computed(() => state.value.position);
|
||||
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||
|
||||
const sourceLangSelector = ref('');
|
||||
const targetLangSelector = ref('');
|
||||
const translatorSelector = ref('');
|
||||
const translatedText = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragStart = ref({ x: 0, y: 0 });
|
||||
|
||||
// 监听可见性变化
|
||||
watch(isVisible, async (visible) => {
|
||||
if (visible) {
|
||||
adjustedPosition.value = { ...position.value };
|
||||
await nextTick();
|
||||
adjustDialogPosition();
|
||||
await initializeTranslation();
|
||||
await nextTick();
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
isDragging.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const dialogStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
}));
|
||||
|
||||
const availableLanguages = computed(() => {
|
||||
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||
if (!languageMap) return [];
|
||||
return Object.entries(languageMap).map(([code, info]: [string, any]) => ({
|
||||
code,
|
||||
name: info.Name || info.name || code
|
||||
}));
|
||||
});
|
||||
|
||||
const availableTranslators = computed(() => translationStore.translators);
|
||||
|
||||
function adjustDialogPosition() {
|
||||
const dialogEl = dialogRef.value;
|
||||
const container = props.portalTarget;
|
||||
if (!dialogEl || !container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const dialogRect = dialogEl.getBoundingClientRect();
|
||||
|
||||
let x = adjustedPosition.value.x;
|
||||
let y = adjustedPosition.value.y;
|
||||
|
||||
// 限制在容器范围内
|
||||
x = Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width - 8));
|
||||
y = Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height - 8));
|
||||
|
||||
adjustedPosition.value = { x, y };
|
||||
}
|
||||
|
||||
function clampPosition(x: number, y: number) {
|
||||
const container = props.portalTarget;
|
||||
const dialogEl = dialogRef.value;
|
||||
if (!container || !dialogEl) return { x, y };
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const dialogRect = dialogEl.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width)),
|
||||
y: Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height))
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeTranslation() {
|
||||
isLoading.value = true;
|
||||
translatedText.value = '';
|
||||
|
||||
try {
|
||||
await loadTranslators();
|
||||
await translate();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize translation:', error);
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranslators() {
|
||||
const translators = translationStore.translators;
|
||||
if (translators.length > 0) {
|
||||
translatorSelector.value = translators[0];
|
||||
}
|
||||
resetLanguageSelectors();
|
||||
}
|
||||
|
||||
function resetLanguageSelectors() {
|
||||
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||
if (!languageMap) return;
|
||||
|
||||
const languages = Object.keys(languageMap);
|
||||
if (languages.length > 0) {
|
||||
sourceLangSelector.value = languages[0];
|
||||
targetLangSelector.value = languages[0];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTranslatorChange() {
|
||||
resetLanguageSelectors();
|
||||
translate();
|
||||
}
|
||||
|
||||
function swapLanguages() {
|
||||
const temp = sourceLangSelector.value;
|
||||
sourceLangSelector.value = targetLangSelector.value;
|
||||
targetLangSelector.value = temp;
|
||||
translate();
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
const sourceLang = sourceLangSelector.value;
|
||||
const targetLang = targetLangSelector.value;
|
||||
const translatorType = translatorSelector.value;
|
||||
|
||||
if (!sourceLang || !targetLang || !translatorType) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
translatedText.value = '';
|
||||
|
||||
try {
|
||||
const result = await translationStore.translateText(
|
||||
sourceText.value,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
translatorType
|
||||
);
|
||||
|
||||
translatedText.value = result.translatedText || result.error || '';
|
||||
} catch (err) {
|
||||
console.error('Translation failed:', err);
|
||||
translatedText.value = 'Translation failed';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('select, button')) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = dialogRef.value!.getBoundingClientRect();
|
||||
dragStart.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
isDragging.value = true;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
adjustedPosition.value = clampPosition(
|
||||
e.clientX - dragStart.value.x,
|
||||
e.clientY - dragStart.value.y
|
||||
);
|
||||
}
|
||||
|
||||
function endDrag(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', endDrag);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(translatedText.value);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (isDragging.value) return;
|
||||
if (dialogRef.value?.contains(e.target as Node)) return;
|
||||
translatorManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="cm-translation-tooltip"
|
||||
:class="{ 'cm-translation-dragging': isDragging }"
|
||||
:style="dialogStyle"
|
||||
@mousedown="startDrag"
|
||||
@keydown.esc="translatorManager.hide"
|
||||
@contextmenu.prevent
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="cm-translation-header">
|
||||
<div class="cm-translation-controls">
|
||||
<select
|
||||
v-model="sourceLangSelector"
|
||||
class="cm-translation-select"
|
||||
@change="translate"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button class="cm-translation-swap" @click="swapLanguages" @mousedown.stop title="交换语言">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11">
|
||||
<path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<select
|
||||
v-model="targetLangSelector"
|
||||
class="cm-translation-select"
|
||||
@change="translate"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="translatorSelector"
|
||||
class="cm-translation-select"
|
||||
@change="handleTranslatorChange"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="translator in availableTranslators" :key="translator" :value="translator">
|
||||
{{ translator }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cm-translation-scroll-container">
|
||||
<div v-if="isLoading" class="cm-translation-loading">
|
||||
Translation...
|
||||
</div>
|
||||
|
||||
<div v-else class="cm-translation-result">
|
||||
<div class="cm-translation-result-wrapper">
|
||||
<button
|
||||
v-if="translatedText"
|
||||
class="cm-translation-copy-btn"
|
||||
@click="copyToClipboard"
|
||||
@mousedown.stop
|
||||
title="复制"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="cm-translation-target">{{ translatedText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm-translation-tooltip {
|
||||
position: fixed;
|
||||
background: var(--settings-card-bg, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
padding: 6px;
|
||||
max-width: 240px;
|
||||
max-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: var(--voidraft-font-mono, system-ui, -apple-system, sans-serif), serif;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-translation-dragging {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10001;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.cm-translation-header {
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cm-translation-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cm-translation-select {
|
||||
padding: 2px 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||
background: var(--bg-primary, #f8f8f8);
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #333);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 65px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-translation-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-color, rgba(66, 133, 244, 0.5));
|
||||
}
|
||||
|
||||
.cm-translation-swap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||
background: var(--bg-primary, transparent);
|
||||
color: var(--text-muted, #666);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cm-translation-swap:hover {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.08));
|
||||
border-color: var(--border-color, rgba(66, 133, 244, 0.3));
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cm-translation-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cm-translation-result-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
background: var(--bg-primary, rgba(255, 255, 255, 0.9));
|
||||
color: var(--text-muted, #666);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
z-index: 2;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn:hover {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.cm-translation-target {
|
||||
padding: 5px;
|
||||
padding-right: 24px;
|
||||
background: var(--bg-primary, rgba(66, 133, 244, 0.03));
|
||||
color: var(--text-primary, #333);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.cm-translation-loading {
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.cm-translation-loading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-muted, rgba(0, 0, 0, 0.2));
|
||||
border-top-color: var(--text-muted, #666);
|
||||
animation: cm-translation-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cm-translation-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,154 +1,63 @@
|
||||
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';
|
||||
|
||||
|
||||
class TranslatorExtension {
|
||||
private config: TranslatorConfig;
|
||||
private setTranslationTooltip: StateEffectType<Tooltip | null>;
|
||||
private translationTooltipField: StateField<readonly Tooltip[]>;
|
||||
private translationButtonField: StateField<readonly Tooltip[]>;
|
||||
|
||||
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 [];
|
||||
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 [];
|
||||
|
||||
// 检查文本是否只包含空格
|
||||
if (!selectedText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查文本长度条件
|
||||
if (selectedText.length < this.config.minSelectionLength ||
|
||||
selectedText.length > this.config.maxTranslationLength) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 返回翻译按钮tooltip配置
|
||||
return [{
|
||||
pos: selection.to,
|
||||
above: false,
|
||||
strictSide: true,
|
||||
arrow: false,
|
||||
create: (view) => {
|
||||
// 创建按钮DOM
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-translator-button';
|
||||
dom.innerHTML = TRANSLATION_ICON_SVG;
|
||||
|
||||
// 点击事件
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 显示翻译气泡
|
||||
this.showTranslationTooltip(view);
|
||||
showTranslatorDialog(view);
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示翻译气泡
|
||||
*/
|
||||
private showTranslationTooltip(view: EditorView) {
|
||||
// 直接从当前选择获取文本
|
||||
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 tooltip = createTranslationTooltip(view, selectedText);
|
||||
const coords = view.coordsAtPos(selection.to);
|
||||
if (!coords) return;
|
||||
|
||||
// 更新状态以显示气泡
|
||||
view.dispatch({
|
||||
effects: this.setTranslationTooltip.of(tooltip)
|
||||
});
|
||||
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))
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建扩展
|
||||
*/
|
||||
createExtension(): Extension {
|
||||
export function createTranslatorExtension(): Extension {
|
||||
return [
|
||||
// 翻译按钮tooltip
|
||||
this.translationButtonField,
|
||||
// 翻译气泡tooltip
|
||||
this.translationTooltipField,
|
||||
|
||||
// 添加基础样式
|
||||
translationButtonField,
|
||||
EditorView.baseTheme({
|
||||
".cm-translator-button": {
|
||||
display: "flex",
|
||||
@@ -167,189 +76,9 @@ class TranslatorExtension {
|
||||
"&: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();
|
||||
}
|
||||
|
||||
export default createTranslatorExtension;
|
||||
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||
|
||||
interface TranslatorPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface TranslatorState {
|
||||
visible: boolean;
|
||||
position: TranslatorPosition;
|
||||
sourceText: string;
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
class TranslatorManager {
|
||||
private state: ShallowRef<TranslatorState> = shallowRef({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
});
|
||||
|
||||
useState() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, text: string): void {
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
sourceText: text,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.state.value.view;
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
};
|
||||
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const translatorManager = new TranslatorManager();
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
import {EditorView, Tooltip, TooltipView} from '@codemirror/view';
|
||||
import {useTranslationStore} from '@/stores/translationStore';
|
||||
|
||||
/**
|
||||
* 翻译气泡弹窗类
|
||||
* 提供文本翻译功能的交互式界面
|
||||
*/
|
||||
export class TranslationTooltip implements TooltipView {
|
||||
// ===== 核心属性 =====
|
||||
dom!: HTMLElement;
|
||||
sourceText: string;
|
||||
translationStore: ReturnType<typeof useTranslationStore>;
|
||||
|
||||
// ===== UI 元素 =====
|
||||
private translatorSelector!: HTMLSelectElement;
|
||||
private sourceLangSelector!: HTMLSelectElement;
|
||||
private targetLangSelector!: HTMLSelectElement;
|
||||
private resultContainer!: HTMLDivElement;
|
||||
private loadingIndicator!: HTMLDivElement;
|
||||
private swapButton!: HTMLButtonElement;
|
||||
|
||||
// ===== 状态管理 =====
|
||||
private translatedText: string = '';
|
||||
private eventListeners: Array<{element: HTMLElement | Document, event: string, handler: EventListener}> = [];
|
||||
|
||||
// ===== 拖拽状态 =====
|
||||
private isDragging: boolean = false;
|
||||
private dragOffset: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
constructor(_view: EditorView, text: string) {
|
||||
this.sourceText = text;
|
||||
this.translationStore = useTranslationStore();
|
||||
|
||||
this.initializeDOM();
|
||||
this.setupEventListeners();
|
||||
this.initializeTranslation();
|
||||
}
|
||||
|
||||
// ===== DOM 初始化 =====
|
||||
|
||||
/**
|
||||
* 初始化DOM结构
|
||||
*/
|
||||
private initializeDOM(): void {
|
||||
this.dom = this.createElement('div', 'cm-translation-tooltip');
|
||||
// 设置为绝对定位,允许拖拽移动
|
||||
this.dom.style.position = 'absolute';
|
||||
|
||||
const header = this.createHeader();
|
||||
const scrollContainer = this.createScrollContainer();
|
||||
|
||||
this.dom.appendChild(header);
|
||||
this.dom.appendChild(scrollContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建头部控制区域
|
||||
*/
|
||||
private createHeader(): HTMLElement {
|
||||
const header = this.createElement('div', 'cm-translation-header');
|
||||
|
||||
const controlsContainer = this.createElement('div', 'cm-translation-controls');
|
||||
|
||||
// 创建所有控制元素
|
||||
this.sourceLangSelector = this.createSelector('cm-translation-select');
|
||||
this.swapButton = this.createSwapButton();
|
||||
this.targetLangSelector = this.createSelector('cm-translation-select');
|
||||
this.translatorSelector = this.createTranslatorSelector();
|
||||
|
||||
// 添加到控制容器
|
||||
controlsContainer.appendChild(this.sourceLangSelector);
|
||||
controlsContainer.appendChild(this.swapButton);
|
||||
controlsContainer.appendChild(this.targetLangSelector);
|
||||
controlsContainer.appendChild(this.translatorSelector);
|
||||
|
||||
header.appendChild(controlsContainer);
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建滚动容器
|
||||
*/
|
||||
private createScrollContainer(): HTMLElement {
|
||||
const scrollContainer = this.createElement('div', 'cm-translation-scroll-container');
|
||||
|
||||
this.loadingIndicator = this.createElement('div', 'cm-translation-loading') as HTMLDivElement;
|
||||
this.loadingIndicator.textContent = 'Translation...';
|
||||
this.loadingIndicator.style.display = 'none';
|
||||
|
||||
this.resultContainer = this.createElement('div', 'cm-translation-result') as HTMLDivElement;
|
||||
|
||||
scrollContainer.appendChild(this.loadingIndicator);
|
||||
scrollContainer.appendChild(this.resultContainer);
|
||||
|
||||
return scrollContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建选择器元素
|
||||
*/
|
||||
private createSelector(className: string): HTMLSelectElement {
|
||||
const select = this.createElement('select', className) as HTMLSelectElement;
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建语言交换按钮
|
||||
*/
|
||||
private createSwapButton(): HTMLButtonElement {
|
||||
const button = this.createElement('button', 'cm-translation-swap') as HTMLButtonElement;
|
||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/></svg>`;
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建翻译器选择器
|
||||
*/
|
||||
private createTranslatorSelector(): HTMLSelectElement {
|
||||
const select = this.createSelector('cm-translation-select');
|
||||
const tempOption = this.createElement('option') as HTMLOptionElement;
|
||||
tempOption.textContent = 'Loading...';
|
||||
select.appendChild(tempOption);
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用DOM元素创建方法
|
||||
*/
|
||||
private createElement(tag: string, className?: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// ===== 事件管理 =====
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.addEventListenerWithCleanup(this.sourceLangSelector, 'change', () => {
|
||||
this.handleLanguageChange();
|
||||
});
|
||||
|
||||
this.addEventListenerWithCleanup(this.targetLangSelector, 'change', () => {
|
||||
this.handleLanguageChange();
|
||||
});
|
||||
|
||||
this.addEventListenerWithCleanup(this.swapButton, 'click', () => {
|
||||
this.swapLanguages();
|
||||
});
|
||||
|
||||
// 添加拖拽事件监听器
|
||||
this.setupDragListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器并记录以便清理
|
||||
*/
|
||||
private addEventListenerWithCleanup(element: HTMLElement | Document, event: string, handler: EventListener): void {
|
||||
element.addEventListener(event, handler);
|
||||
this.eventListeners.push({ element, event, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有事件监听器
|
||||
*/
|
||||
private cleanupEventListeners(): void {
|
||||
this.eventListeners.forEach(({ element, event, handler }) => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
// ===== 初始化和生命周期 =====
|
||||
|
||||
/**
|
||||
* 初始化翻译功能
|
||||
*/
|
||||
private async initializeTranslation(): Promise<void> {
|
||||
this.showLoading();
|
||||
this.resultContainer.innerHTML = '<div class="cm-translation-loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
await this.loadTranslators();
|
||||
await this.translate();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize translation:', error);
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 语言管理 =====
|
||||
|
||||
/**
|
||||
* 设置拖拽事件监听器
|
||||
*/
|
||||
private setupDragListeners(): void {
|
||||
// 在整个翻译框上监听鼠标按下事件
|
||||
this.addEventListenerWithCleanup(this.dom, 'mousedown', (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
const target = mouseEvent.target as HTMLElement;
|
||||
|
||||
// 如果点击的是交互元素(按钮、选择框等),不启动拖拽
|
||||
if (target.tagName === 'SELECT' || target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'OPTION' || target.closest('select') || target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startDrag(mouseEvent);
|
||||
});
|
||||
|
||||
// 鼠标移动
|
||||
this.addEventListenerWithCleanup(document, 'mousemove', (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
this.onDrag(mouseEvent);
|
||||
});
|
||||
|
||||
// 鼠标释放结束拖拽
|
||||
this.addEventListenerWithCleanup(document, 'mouseup', () => {
|
||||
this.endDrag();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽
|
||||
*/
|
||||
private startDrag(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
this.isDragging = true;
|
||||
|
||||
const rect = this.dom.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
// 添加拖拽状态样式
|
||||
this.dom.classList.add('cm-translation-dragging');
|
||||
this.dom.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽过程中
|
||||
*/
|
||||
private onDrag(e: MouseEvent): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// 确保不会拖拽到视窗外
|
||||
const maxX = window.innerWidth - this.dom.offsetWidth;
|
||||
const maxY = window.innerHeight - this.dom.offsetHeight;
|
||||
|
||||
const clampedX = Math.max(0, Math.min(newX, maxX));
|
||||
const clampedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.dom.style.left = `${clampedX}px`;
|
||||
this.dom.style.top = `${clampedY}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束拖拽
|
||||
*/
|
||||
private endDrag(): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
|
||||
// 移除拖拽状态样式
|
||||
this.dom.classList.remove('cm-translation-dragging');
|
||||
this.dom.style.cursor = 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理语言变更
|
||||
*/
|
||||
private handleLanguageChange(): void {
|
||||
// 语言变更后重新翻译,具体的语言限制逻辑在store中处理
|
||||
this.translate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换源语言和目标语言
|
||||
*/
|
||||
private swapLanguages(): void {
|
||||
const temp = this.sourceLangSelector.value;
|
||||
this.sourceLangSelector.value = this.targetLangSelector.value;
|
||||
this.targetLangSelector.value = temp;
|
||||
this.translate();
|
||||
}
|
||||
|
||||
// ===== 翻译器管理 =====
|
||||
|
||||
/**
|
||||
* 加载翻译器选项
|
||||
*/
|
||||
private async loadTranslators(): Promise<boolean> {
|
||||
try {
|
||||
this.clearSelectOptions(this.translatorSelector);
|
||||
|
||||
const translators = this.translationStore.translators;
|
||||
this.populateTranslatorOptions(translators);
|
||||
|
||||
// 添加翻译器变更事件监听
|
||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
||||
this.handleTranslatorChange();
|
||||
});
|
||||
|
||||
await this.updateLanguageSelectors();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load translators:', error);
|
||||
this.loadDefaultTranslators();
|
||||
await this.updateLanguageSelectors();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充翻译器选项
|
||||
*/
|
||||
private populateTranslatorOptions(translators: string[]): void {
|
||||
translators.forEach((translator, index) => {
|
||||
const option = this.createElement('option') as HTMLOptionElement;
|
||||
option.value = translator;
|
||||
option.textContent = translator;
|
||||
option.selected = index === 0; // 选择第一个翻译器
|
||||
this.translatorSelector.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认翻译器
|
||||
*/
|
||||
private loadDefaultTranslators(): void {
|
||||
this.clearSelectOptions(this.translatorSelector);
|
||||
|
||||
// 使用从后端获取的翻译器列表
|
||||
const translators = this.translationStore.translators;
|
||||
this.populateTranslatorOptions(translators);
|
||||
|
||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
||||
this.handleTranslatorChange();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理翻译器选择变化
|
||||
*/
|
||||
private async handleTranslatorChange(): Promise<void> {
|
||||
await this.updateLanguageSelectors();
|
||||
this.translate();
|
||||
}
|
||||
|
||||
// ===== 语言选择器管理 =====
|
||||
|
||||
/**
|
||||
* 更新语言选择器
|
||||
*/
|
||||
private async updateLanguageSelectors(): Promise<void> {
|
||||
const currentTranslator = this.translatorSelector.value;
|
||||
|
||||
// 保存当前选中的语言
|
||||
const currentSourceLang = this.sourceLangSelector.value || '';
|
||||
const currentTargetLang = this.targetLangSelector.value;
|
||||
|
||||
// 清空选择器
|
||||
this.clearSelectOptions(this.sourceLangSelector);
|
||||
this.clearSelectOptions(this.targetLangSelector);
|
||||
|
||||
// 直接使用预加载的语言映射
|
||||
const languageMap = this.translationStore.translatorLanguages[currentTranslator];
|
||||
|
||||
if (!languageMap || Object.keys(languageMap).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加语言选项
|
||||
Object.entries(languageMap).forEach(([code, langInfo]) => {
|
||||
this.addLanguageOption(code, langInfo);
|
||||
});
|
||||
|
||||
// 恢复之前的语言选择
|
||||
this.restoreLanguageSelection(currentSourceLang, currentTargetLang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选择器选项
|
||||
*/
|
||||
private clearSelectOptions(selector: HTMLSelectElement): void {
|
||||
while (selector.firstChild) {
|
||||
selector.removeChild(selector.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加语言选项到选择器
|
||||
*/
|
||||
private addLanguageOption(code: string, langInfo: any): void {
|
||||
const displayName = langInfo.Name || langInfo.name || code;
|
||||
|
||||
// 添加源语言选项
|
||||
const sourceOption = this.createElement('option') as HTMLOptionElement;
|
||||
sourceOption.value = code;
|
||||
sourceOption.textContent = displayName;
|
||||
this.sourceLangSelector.appendChild(sourceOption);
|
||||
|
||||
// 添加目标语言选项
|
||||
const targetOption = this.createElement('option') as HTMLOptionElement;
|
||||
targetOption.value = code;
|
||||
targetOption.textContent = displayName;
|
||||
this.targetLangSelector.appendChild(targetOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复语言选择
|
||||
*/
|
||||
private restoreLanguageSelection(sourceLang: string, targetLang: string): void {
|
||||
// 设置源语言
|
||||
if (sourceLang && this.hasLanguageOption(this.sourceLangSelector, sourceLang)) {
|
||||
this.sourceLangSelector.value = sourceLang;
|
||||
} else if (this.sourceLangSelector.options.length > 0) {
|
||||
this.sourceLangSelector.selectedIndex = 0;
|
||||
}
|
||||
|
||||
// 设置目标语言
|
||||
if (targetLang && this.hasLanguageOption(this.targetLangSelector, targetLang)) {
|
||||
this.targetLangSelector.value = targetLang;
|
||||
} else if (this.targetLangSelector.options.length > 0) {
|
||||
this.targetLangSelector.selectedIndex = 0;
|
||||
}
|
||||
|
||||
// 确保源语言和目标语言不同
|
||||
this.handleLanguageChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查选择器是否有指定语言选项
|
||||
*/
|
||||
private hasLanguageOption(selector: HTMLSelectElement, langCode: string): boolean {
|
||||
return Array.from(selector.options).some(option => option.value === langCode);
|
||||
}
|
||||
|
||||
// ===== 翻译功能 =====
|
||||
|
||||
|
||||
/**
|
||||
* 执行翻译
|
||||
*/
|
||||
private async translate(): Promise<void> {
|
||||
const sourceLang = this.sourceLangSelector.value;
|
||||
const targetLang = this.targetLangSelector.value;
|
||||
const translatorType = this.translatorSelector.value;
|
||||
|
||||
this.showLoading();
|
||||
this.resultContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const result = await this.translationStore.translateText(
|
||||
this.sourceText,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
translatorType
|
||||
);
|
||||
|
||||
this.displayTranslationResult(result);
|
||||
} catch (err) {
|
||||
console.error('Translation failed:', err);
|
||||
this.displayError('Translation failed');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI 状态管理 =====
|
||||
|
||||
/**
|
||||
* 显示加载状态
|
||||
*/
|
||||
private showLoading(): void {
|
||||
this.loadingIndicator.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏加载状态
|
||||
*/
|
||||
private hideLoading(): void {
|
||||
this.loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
*/
|
||||
private displayError(message: string): void {
|
||||
this.resultContainer.innerHTML = '';
|
||||
this.translatedText = '';
|
||||
|
||||
const errorElement = this.createElement('div', 'cm-translation-error');
|
||||
errorElement.textContent = message;
|
||||
this.resultContainer.appendChild(errorElement);
|
||||
}
|
||||
|
||||
// ===== 结果显示 =====
|
||||
|
||||
/**
|
||||
* 显示翻译结果
|
||||
*/
|
||||
private displayTranslationResult(result: any): void {
|
||||
this.resultContainer.innerHTML = '';
|
||||
|
||||
const resultWrapper = this.createElement('div', 'cm-translation-result-wrapper');
|
||||
const translatedTextElem = this.createElement('div', 'cm-translation-target');
|
||||
|
||||
if (result.error) {
|
||||
translatedTextElem.classList.add('cm-translation-error');
|
||||
translatedTextElem.textContent = result.error;
|
||||
this.translatedText = '';
|
||||
} else {
|
||||
this.translatedText = result.translatedText || '';
|
||||
translatedTextElem.textContent = this.translatedText;
|
||||
}
|
||||
|
||||
// 添加复制按钮
|
||||
if (this.translatedText) {
|
||||
const copyButton = this.createCopyButton();
|
||||
resultWrapper.appendChild(copyButton);
|
||||
}
|
||||
|
||||
resultWrapper.appendChild(translatedTextElem);
|
||||
this.resultContainer.appendChild(resultWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建复制按钮
|
||||
*/
|
||||
private createCopyButton(): HTMLButtonElement {
|
||||
const copyButton = this.createElement('button', 'cm-translation-copy-btn') as HTMLButtonElement;
|
||||
copyButton.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
|
||||
|
||||
this.addEventListenerWithCleanup(copyButton, 'click', () => {
|
||||
this.copyToClipboard(copyButton);
|
||||
});
|
||||
|
||||
return copyButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
*/
|
||||
private async copyToClipboard(button: HTMLButtonElement): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.translatedText);
|
||||
this.showCopySuccess(button);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示复制成功状态
|
||||
*/
|
||||
private showCopySuccess(button: HTMLButtonElement): void {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('copied');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ===== 生命周期管理 =====
|
||||
|
||||
/**
|
||||
* 销毁组件时的清理工作
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建翻译气泡
|
||||
export function createTranslationTooltip(view: EditorView, text: string): Tooltip {
|
||||
return {
|
||||
pos: view.state.selection.main.to, // 紧贴文本末尾
|
||||
above: false,
|
||||
strictSide: false,
|
||||
arrow: true,
|
||||
create: () => new TranslationTooltip(view, text)
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
|
||||
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
|
||||
import {transposeChars} from '../extensions/codeblock';
|
||||
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
|
||||
import {textHighlightToggleCommand} from '../extensions/textHighlight/textHighlightExtension';
|
||||
import {textHighlightToggleCommand} from '../extensions/textHighlight';
|
||||
import {
|
||||
copyLineDown,
|
||||
copyLineUp,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { useExtensionStore } from '@/stores/extensionStore';
|
||||
import { KeymapManager } from './keymapManager';
|
||||
import { Manager } from './manager';
|
||||
|
||||
/**
|
||||
* 异步创建快捷键扩展
|
||||
@@ -23,7 +23,7 @@ export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
||||
// 获取启用的扩展ID列表
|
||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||
|
||||
return KeymapManager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
||||
return Manager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,10 +37,10 @@ export const updateKeymapExtension = (view: any): void => {
|
||||
// 获取启用的扩展ID列表
|
||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||
|
||||
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
||||
Manager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
||||
};
|
||||
|
||||
// 导出相关模块
|
||||
export { KeymapManager } from './keymapManager';
|
||||
export { Manager } from './manager';
|
||||
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
||||
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
||||
@@ -8,7 +8,7 @@ import {getCommandHandler, isCommandRegistered} from './commands';
|
||||
* 快捷键管理器
|
||||
* 负责将后端配置转换为CodeMirror快捷键扩展
|
||||
*/
|
||||
export class KeymapManager {
|
||||
export class Manager {
|
||||
private static compartment = new Compartment();
|
||||
|
||||
/**
|
||||
@@ -1,299 +0,0 @@
|
||||
import {Compartment, Extension} from '@codemirror/state';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {ExtensionState, EditorViewInfo, ExtensionFactory} from './types'
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 扩展管理器
|
||||
* 负责管理所有动态扩展的注册、启用、禁用和配置更新
|
||||
* 采用统一配置,多视图同步的设计模式
|
||||
*/
|
||||
export class ExtensionManager {
|
||||
// 统一的扩展状态存储
|
||||
private extensionStates = new Map<ExtensionID, ExtensionState>();
|
||||
|
||||
// 编辑器视图管理
|
||||
private viewsMap = new Map<number, EditorViewInfo>();
|
||||
private activeViewId: number | null = null;
|
||||
|
||||
// 注册的扩展工厂
|
||||
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
|
||||
|
||||
// 防抖处理
|
||||
private debouncedUpdateFunctions = new Map<ExtensionID, {
|
||||
debouncedFn: (enabled: boolean, config: any) => void;
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 注册扩展工厂
|
||||
* @param id 扩展ID
|
||||
* @param factory 扩展工厂
|
||||
*/
|
||||
registerExtension(id: ExtensionID, factory: ExtensionFactory): void {
|
||||
this.extensionFactories.set(id, factory);
|
||||
|
||||
// 创建初始状态
|
||||
if (!this.extensionStates.has(id)) {
|
||||
const compartment = new Compartment();
|
||||
const defaultConfig = factory.getDefaultConfig();
|
||||
|
||||
this.extensionStates.set(id, {
|
||||
id,
|
||||
factory,
|
||||
config: defaultConfig,
|
||||
enabled: false,
|
||||
compartment,
|
||||
extension: [] // 默认为空扩展(禁用状态)
|
||||
});
|
||||
}
|
||||
|
||||
// 为每个扩展创建防抖函数
|
||||
if (!this.debouncedUpdateFunctions.has(id)) {
|
||||
const { debouncedFn, cancel, flush } = createDebounce(
|
||||
(enabled: boolean, config: any) => {
|
||||
this.updateExtensionImmediate(id, enabled, config);
|
||||
},
|
||||
{ delay: 300 }
|
||||
);
|
||||
|
||||
this.debouncedUpdateFunctions.set(id, {
|
||||
debouncedFn,
|
||||
cancel,
|
||||
flush
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的扩展ID列表
|
||||
*/
|
||||
getRegisteredExtensions(): ExtensionID[] {
|
||||
return Array.from(this.extensionFactories.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查扩展是否已注册
|
||||
* @param id 扩展ID
|
||||
*/
|
||||
isExtensionRegistered(id: ExtensionID): boolean {
|
||||
return this.extensionFactories.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端配置初始化扩展状态
|
||||
* @param extensionConfigs 后端扩展配置列表
|
||||
*/
|
||||
initializeExtensionsFromConfig(extensionConfigs: ExtensionConfig[]): void {
|
||||
for (const config of extensionConfigs) {
|
||||
const factory = this.extensionFactories.get(config.id);
|
||||
if (!factory) continue;
|
||||
|
||||
// 验证配置
|
||||
if (factory.validateConfig && !factory.validateConfig(config.config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建扩展实例
|
||||
const extension = config.enabled ? factory.create(config.config) : [];
|
||||
|
||||
// 如果状态已存在则更新,否则创建新状态
|
||||
if (this.extensionStates.has(config.id)) {
|
||||
const state = this.extensionStates.get(config.id)!;
|
||||
state.config = config.config;
|
||||
state.enabled = config.enabled;
|
||||
state.extension = extension;
|
||||
} else {
|
||||
const compartment = new Compartment();
|
||||
this.extensionStates.set(config.id, {
|
||||
id: config.id,
|
||||
factory,
|
||||
config: config.config,
|
||||
enabled: config.enabled,
|
||||
compartment,
|
||||
extension
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize extension ${config.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取初始扩展配置数组(用于创建编辑器)
|
||||
* @returns CodeMirror扩展数组
|
||||
*/
|
||||
getInitialExtensions(): Extension[] {
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
// 为每个注册的扩展添加compartment
|
||||
for (const state of this.extensionStates.values()) {
|
||||
extensions.push(state.compartment.of(state.extension));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置编辑器视图
|
||||
* @param view 编辑器视图实例
|
||||
* @param documentId 文档ID
|
||||
*/
|
||||
setView(view: EditorView, documentId: number): void {
|
||||
// 保存视图信息
|
||||
this.viewsMap.set(documentId, {
|
||||
view,
|
||||
documentId,
|
||||
registered: true
|
||||
});
|
||||
|
||||
// 设置当前活动视图
|
||||
this.activeViewId = documentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前活动视图
|
||||
*/
|
||||
private getActiveView(): EditorView | null {
|
||||
if (this.activeViewId === null) return null;
|
||||
const viewInfo = this.viewsMap.get(this.activeViewId);
|
||||
return viewInfo ? viewInfo.view : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个扩展配置并应用到所有视图(带防抖功能)
|
||||
* @param id 扩展ID
|
||||
* @param enabled 是否启用
|
||||
* @param config 扩展配置
|
||||
*/
|
||||
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
||||
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
|
||||
if (debouncedUpdate) {
|
||||
debouncedUpdate.debouncedFn(enabled, config);
|
||||
} else {
|
||||
// 如果没有防抖函数,直接执行
|
||||
this.updateExtensionImmediate(id, enabled, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即更新扩展(无防抖)
|
||||
* @param id 扩展ID
|
||||
* @param enabled 是否启用
|
||||
* @param config 扩展配置
|
||||
*/
|
||||
updateExtensionImmediate(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
||||
// 获取扩展状态
|
||||
const state = this.extensionStates.get(id);
|
||||
if (!state) return;
|
||||
|
||||
// 获取工厂
|
||||
const factory = state.factory;
|
||||
|
||||
// 验证配置
|
||||
if (factory.validateConfig && !factory.validateConfig(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新的扩展实例
|
||||
const extension = enabled ? factory.create(config) : [];
|
||||
|
||||
// 更新内部状态
|
||||
state.config = config;
|
||||
state.enabled = enabled;
|
||||
state.extension = extension;
|
||||
|
||||
// 应用到所有视图
|
||||
this.applyExtensionToAllViews(id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update extension ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定扩展的当前状态应用到所有视图
|
||||
* @param id 扩展ID
|
||||
*/
|
||||
private applyExtensionToAllViews(id: ExtensionID): void {
|
||||
const state = this.extensionStates.get(id);
|
||||
if (!state) return;
|
||||
|
||||
// 遍历所有视图并应用更改
|
||||
for (const viewInfo of this.viewsMap.values()) {
|
||||
try {
|
||||
if (!viewInfo.registered) continue;
|
||||
|
||||
viewInfo.view.dispatch({
|
||||
effects: state.compartment.reconfigure(state.extension)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply extension ${id} to document ${viewInfo.documentId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取扩展当前状态
|
||||
* @param id 扩展ID
|
||||
*/
|
||||
getExtensionState(id: ExtensionID): {
|
||||
enabled: boolean
|
||||
config: any
|
||||
} | null {
|
||||
const state = this.extensionStates.get(id);
|
||||
if (!state) return null;
|
||||
|
||||
return {
|
||||
enabled: state.enabled,
|
||||
config: state.config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置扩展到默认配置
|
||||
* @param id 扩展ID
|
||||
*/
|
||||
resetExtensionToDefault(id: ExtensionID): void {
|
||||
const state = this.extensionStates.get(id);
|
||||
if (!state) return;
|
||||
|
||||
const defaultConfig = state.factory.getDefaultConfig();
|
||||
this.updateExtension(id, true, defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从管理器中移除视图
|
||||
* @param documentId 文档ID
|
||||
*/
|
||||
removeView(documentId: number): void {
|
||||
if (this.activeViewId === documentId) {
|
||||
this.activeViewId = null;
|
||||
}
|
||||
|
||||
this.viewsMap.delete(documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
// 清除所有防抖函数
|
||||
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
|
||||
cancel();
|
||||
}
|
||||
this.debouncedUpdateFunctions.clear();
|
||||
|
||||
this.viewsMap.clear();
|
||||
this.activeViewId = null;
|
||||
this.extensionFactories.clear();
|
||||
this.extensionStates.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,301 +1,146 @@
|
||||
import {ExtensionManager} from './extensionManager';
|
||||
import {Manager} from './manager';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import i18n from '@/i18n';
|
||||
import {ExtensionFactory} from './types'
|
||||
import {ExtensionDefinition} from './types';
|
||||
|
||||
// 导入现有扩展的创建函数
|
||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
||||
import {createTextHighlighter} from '../extensions/textHighlight/textHighlightExtension';
|
||||
|
||||
import {createTextHighlighter} from '../extensions/textHighlight';
|
||||
import {color} from '../extensions/colorSelector';
|
||||
import {hyperLink} from '../extensions/hyperlink';
|
||||
import {minimap} from '../extensions/minimap';
|
||||
import {vscodeSearch} from '../extensions/vscodeSearch';
|
||||
import {createCheckboxExtension} from '../extensions/checkbox';
|
||||
import {createTranslatorExtension} from '../extensions/translator';
|
||||
|
||||
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
||||
|
||||
/**
|
||||
* 彩虹括号扩展工厂
|
||||
*/
|
||||
export const rainbowBracketsFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return rainbowBracketsExtension();
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
type ExtensionEntry = {
|
||||
definition: ExtensionDefinition
|
||||
displayNameKey: string
|
||||
descriptionKey: string
|
||||
};
|
||||
|
||||
/**
|
||||
* 文本高亮扩展工厂
|
||||
*/
|
||||
export const textHighlightFactory: ExtensionFactory = {
|
||||
create(config: any) {
|
||||
return createTextHighlighter({
|
||||
backgroundColor: config.backgroundColor || '#FFD700',
|
||||
opacity: config.opacity || 0.3
|
||||
});
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
backgroundColor: '#FFD700', // 金黄色
|
||||
opacity: 0.3 // 透明度
|
||||
};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object' &&
|
||||
(!config.backgroundColor || typeof config.backgroundColor === 'string') &&
|
||||
(!config.opacity || (typeof config.opacity === 'number' && config.opacity >= 0 && config.opacity <= 1));
|
||||
}
|
||||
};
|
||||
type RegisteredExtensionID = Exclude<ExtensionID, ExtensionID.$zero | ExtensionID.ExtensionEditor>;
|
||||
|
||||
/**
|
||||
* 小地图扩展工厂
|
||||
*/
|
||||
export const minimapFactory: ExtensionFactory = {
|
||||
create(config: any) {
|
||||
const options = {
|
||||
displayText: config.displayText || 'characters',
|
||||
showOverlay: config.showOverlay || 'always',
|
||||
autohide: config.autohide || false
|
||||
};
|
||||
return minimap(options);
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
displayText: 'characters',
|
||||
showOverlay: 'always',
|
||||
autohide: false
|
||||
};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object' &&
|
||||
(!config.displayText || typeof config.displayText === 'string') &&
|
||||
(!config.showOverlay || typeof config.showOverlay === 'string') &&
|
||||
(!config.autohide || typeof config.autohide === 'boolean');
|
||||
}
|
||||
};
|
||||
const defineExtension = (create: (config: any) => any, defaultConfig: Record<string, any> = {}): ExtensionDefinition => ({
|
||||
create,
|
||||
defaultConfig
|
||||
});
|
||||
|
||||
/**
|
||||
* 超链接扩展工厂
|
||||
*/
|
||||
export const hyperlinkFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return hyperLink;
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 颜色选择器扩展工厂
|
||||
*/
|
||||
export const colorSelectorFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return color;
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索扩展工厂
|
||||
*/
|
||||
export const searchFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return vscodeSearch;
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
export const foldFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return foldingOnIndent;
|
||||
},
|
||||
getDefaultConfig(): any {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any): boolean {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择框扩展工厂
|
||||
*/
|
||||
export const checkboxFactory: ExtensionFactory = {
|
||||
create(_config: any) {
|
||||
return createCheckboxExtension();
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译扩展工厂
|
||||
*/
|
||||
export const translatorFactory: ExtensionFactory = {
|
||||
create(config: any) {
|
||||
return createTranslatorExtension({
|
||||
minSelectionLength: config.minSelectionLength || 2,
|
||||
maxTranslationLength: config.maxTranslationLength || 5000,
|
||||
});
|
||||
},
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000,
|
||||
};
|
||||
},
|
||||
validateConfig(config: any) {
|
||||
return typeof config === 'object';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 所有扩展的统一配置
|
||||
* 排除$zero值以避免TypeScript类型错误
|
||||
*/
|
||||
const EXTENSION_CONFIGS = {
|
||||
|
||||
// 编辑增强扩展
|
||||
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||
factory: rainbowBracketsFactory,
|
||||
definition: defineExtension(() => rainbowBracketsExtension()),
|
||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||
},
|
||||
[ExtensionID.ExtensionHyperlink]: {
|
||||
factory: hyperlinkFactory,
|
||||
definition: defineExtension(() => hyperLink),
|
||||
displayNameKey: 'extensions.hyperlink.name',
|
||||
descriptionKey: 'extensions.hyperlink.description'
|
||||
},
|
||||
[ExtensionID.ExtensionColorSelector]: {
|
||||
factory: colorSelectorFactory,
|
||||
definition: defineExtension(() => color),
|
||||
displayNameKey: 'extensions.colorSelector.name',
|
||||
descriptionKey: 'extensions.colorSelector.description'
|
||||
},
|
||||
[ExtensionID.ExtensionTranslator]: {
|
||||
factory: translatorFactory,
|
||||
definition: defineExtension(() => createTranslatorExtension()),
|
||||
displayNameKey: 'extensions.translator.name',
|
||||
descriptionKey: 'extensions.translator.description'
|
||||
},
|
||||
|
||||
// UI增强扩展
|
||||
[ExtensionID.ExtensionMinimap]: {
|
||||
factory: minimapFactory,
|
||||
definition: defineExtension((config: any) => minimap({
|
||||
displayText: config?.displayText ?? 'characters',
|
||||
showOverlay: config?.showOverlay ?? 'always',
|
||||
autohide: config?.autohide ?? false
|
||||
}), {
|
||||
displayText: 'characters',
|
||||
showOverlay: 'always',
|
||||
autohide: false
|
||||
}),
|
||||
displayNameKey: 'extensions.minimap.name',
|
||||
descriptionKey: 'extensions.minimap.description'
|
||||
},
|
||||
|
||||
// 工具扩展
|
||||
[ExtensionID.ExtensionSearch]: {
|
||||
factory: searchFactory,
|
||||
definition: defineExtension(() => vscodeSearch),
|
||||
displayNameKey: 'extensions.search.name',
|
||||
descriptionKey: 'extensions.search.description'
|
||||
},
|
||||
|
||||
[ExtensionID.ExtensionFold]: {
|
||||
factory: foldFactory,
|
||||
definition: defineExtension(() => foldingOnIndent),
|
||||
displayNameKey: 'extensions.fold.name',
|
||||
descriptionKey: 'extensions.fold.description'
|
||||
},
|
||||
[ExtensionID.ExtensionTextHighlight]: {
|
||||
factory: textHighlightFactory,
|
||||
definition: defineExtension((config: any) => createTextHighlighter({
|
||||
backgroundColor: config?.backgroundColor ?? '#FFD700',
|
||||
opacity: config?.opacity ?? 0.3
|
||||
}), {
|
||||
backgroundColor: '#FFD700',
|
||||
opacity: 0.3
|
||||
}),
|
||||
displayNameKey: 'extensions.textHighlight.name',
|
||||
descriptionKey: 'extensions.textHighlight.description'
|
||||
},
|
||||
[ExtensionID.ExtensionCheckbox]: {
|
||||
factory: checkboxFactory,
|
||||
definition: defineExtension(() => createCheckboxExtension()),
|
||||
displayNameKey: 'extensions.checkbox.name',
|
||||
descriptionKey: 'extensions.checkbox.description'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const isRegisteredExtension = (id: ExtensionID): id is RegisteredExtensionID =>
|
||||
Object.prototype.hasOwnProperty.call(EXTENSION_REGISTRY, id);
|
||||
|
||||
const getRegistryEntry = (id: ExtensionID): ExtensionEntry | undefined => {
|
||||
if (!isRegisteredExtension(id)) {
|
||||
return undefined;
|
||||
}
|
||||
return EXTENSION_REGISTRY[id];
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册所有扩展工厂到管理器
|
||||
* @param manager 扩展管理器实例
|
||||
*/
|
||||
export function registerAllExtensions(manager: ExtensionManager): void {
|
||||
Object.entries(EXTENSION_CONFIGS).forEach(([id, config]) => {
|
||||
manager.registerExtension(id as ExtensionID, config.factory);
|
||||
export function registerAllExtensions(manager: Manager): void {
|
||||
(Object.entries(EXTENSION_REGISTRY) as [RegisteredExtensionID, ExtensionEntry][]).forEach(([id, entry]) => {
|
||||
manager.registerExtension(id, entry.definition);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展工厂的显示名称
|
||||
* @param id 扩展ID
|
||||
* @returns 显示名称
|
||||
*/
|
||||
export function getExtensionDisplayName(id: ExtensionID): string {
|
||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
||||
return config?.displayNameKey ? i18n.global.t(config.displayNameKey) : id;
|
||||
const entry = getRegistryEntry(id);
|
||||
return entry?.displayNameKey ? i18n.global.t(entry.displayNameKey) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展工厂的描述
|
||||
* @param id 扩展ID
|
||||
* @returns 描述
|
||||
*/
|
||||
export function getExtensionDescription(id: ExtensionID): string {
|
||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
||||
return config?.descriptionKey ? i18n.global.t(config.descriptionKey) : '';
|
||||
const entry = getRegistryEntry(id);
|
||||
return entry?.descriptionKey ? i18n.global.t(entry.descriptionKey) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展工厂实例
|
||||
* @param id 扩展ID
|
||||
* @returns 扩展工厂实例
|
||||
*/
|
||||
export function getExtensionFactory(id: ExtensionID): ExtensionFactory | undefined {
|
||||
return EXTENSION_CONFIGS[id as ExtensionID]?.factory;
|
||||
function getExtensionDefinition(id: ExtensionID): ExtensionDefinition | undefined {
|
||||
return getRegistryEntry(id)?.definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展的默认配置
|
||||
* @param id 扩展ID
|
||||
* @returns 默认配置对象
|
||||
*/
|
||||
export function getExtensionDefaultConfig(id: ExtensionID): any {
|
||||
const factory = getExtensionFactory(id);
|
||||
return factory?.getDefaultConfig() || {};
|
||||
const definition = getExtensionDefinition(id);
|
||||
if (!definition) return {};
|
||||
return cloneConfig(definition.defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查扩展是否有配置项
|
||||
* @param id 扩展ID
|
||||
* @returns 是否有配置项
|
||||
*/
|
||||
export function hasExtensionConfig(id: ExtensionID): boolean {
|
||||
const defaultConfig = getExtensionDefaultConfig(id);
|
||||
return Object.keys(defaultConfig).length > 0;
|
||||
return Object.keys(getExtensionDefaultConfig(id)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用扩展的ID列表
|
||||
* @returns 扩展ID数组
|
||||
*/
|
||||
export function getAllExtensionIds(): ExtensionID[] {
|
||||
return Object.keys(EXTENSION_CONFIGS) as ExtensionID[];
|
||||
return Object.keys(EXTENSION_REGISTRY) as RegisteredExtensionID[];
|
||||
}
|
||||
|
||||
const cloneConfig = (config: any) => {
|
||||
if (Array.isArray(config)) {
|
||||
return config.map(cloneConfig);
|
||||
}
|
||||
if (config && typeof config === 'object') {
|
||||
return Object.keys(config).reduce((acc, key) => {
|
||||
acc[key] = cloneConfig(config[key]);
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Extension} from '@codemirror/state';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import {useExtensionStore} from '@/stores/extensionStore';
|
||||
import {ExtensionManager} from './extensionManager';
|
||||
import {Manager} from './manager';
|
||||
import {registerAllExtensions} from './extensions';
|
||||
|
||||
/**
|
||||
* 全局扩展管理器实例
|
||||
*/
|
||||
const extensionManager = new ExtensionManager();
|
||||
const extensionManager = new Manager();
|
||||
|
||||
/**
|
||||
* 异步创建动态扩展
|
||||
@@ -26,7 +26,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
||||
}
|
||||
|
||||
// 初始化扩展管理器配置
|
||||
extensionManager.initializeExtensionsFromConfig(extensionStore.extensions);
|
||||
extensionManager.initExtensions(extensionStore.extensions);
|
||||
|
||||
// 获取初始扩展配置
|
||||
return extensionManager.getInitialExtensions();
|
||||
@@ -36,7 +36,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
||||
* 获取扩展管理器实例
|
||||
* @returns 扩展管理器
|
||||
*/
|
||||
export const getExtensionManager = (): ExtensionManager => {
|
||||
export const getExtensionManager = (): Manager => {
|
||||
return extensionManager;
|
||||
};
|
||||
|
||||
@@ -58,5 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
|
||||
};
|
||||
|
||||
// 导出相关模块
|
||||
export {ExtensionManager} from './extensionManager';
|
||||
export {Manager} from './manager';
|
||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user