Compare commits
6
Commits
v1.5.4
...
24f1549730
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f1549730 | ||
|
|
4471441d6f | ||
|
|
991a89147e | ||
|
|
a08c0d8448 | ||
|
|
59db8dd177 | ||
|
|
29693f1baf |
No files matched your search
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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,8 @@
|
|||||||
/* 导入所有CSS文件 */
|
/* 导入所有CSS文件 */
|
||||||
@import 'normalize.css';
|
@import 'normalize.css';
|
||||||
@import 'variables.css';
|
@import 'variables.css';
|
||||||
@import "harmony_fonts.css";
|
|
||||||
@import 'scrollbar.css';
|
@import 'scrollbar.css';
|
||||||
|
@import "harmony_fonts.css";
|
||||||
@import 'hack_fonts.css';
|
@import 'hack_fonts.css';
|
||||||
@import 'opensans_fonts.css';
|
@import 'opensans_fonts.css';
|
||||||
|
@import "monocraft_fonts.css";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export const FONT_OPTIONS = [
|
|||||||
label: 'Open Sans',
|
label: 'Open Sans',
|
||||||
value: '"Open Sans"'
|
value: '"Open Sans"'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Monocraft',
|
||||||
|
value: 'Monocraft'
|
||||||
|
},
|
||||||
// Common system fonts
|
// Common system fonts
|
||||||
{
|
{
|
||||||
label: 'Arial',
|
label: 'Arial',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
|
|||||||
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||||
|
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||||
import {
|
import {
|
||||||
createDynamicExtensions,
|
createDynamicExtensions,
|
||||||
@@ -242,6 +243,11 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
fontWeight: configStore.config.editing.fontWeight
|
fontWeight: configStore.config.editing.fontWeight
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wheelZoomExtension = createWheelZoomExtension(
|
||||||
|
() => configStore.increaseFontSize(),
|
||||||
|
() => configStore.decreaseFontSize()
|
||||||
|
);
|
||||||
|
|
||||||
// 统计扩展
|
// 统计扩展
|
||||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||||
|
|
||||||
@@ -287,6 +293,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
themeExtension,
|
themeExtension,
|
||||||
...tabExtensions,
|
...tabExtensions,
|
||||||
fontExtension,
|
fontExtension,
|
||||||
|
wheelZoomExtension,
|
||||||
statsExtension,
|
statsExtension,
|
||||||
contentChangeExtension,
|
contentChangeExtension,
|
||||||
codeBlockExtension,
|
codeBlockExtension,
|
||||||
@@ -707,12 +714,15 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 更新前端编辑器扩展 - 应用于所有实例
|
// 更新前端编辑器扩展 - 应用于所有实例
|
||||||
const manager = getExtensionManager();
|
const manager = getExtensionManager();
|
||||||
if (manager) {
|
if (manager) {
|
||||||
// 使用立即更新模式,跳过防抖
|
// 直接更新前端扩展至所有视图
|
||||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
manager.updateExtension(id, enabled, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载扩展配置
|
// 重新加载扩展配置
|
||||||
await extensionStore.loadExtensions();
|
await extensionStore.loadExtensions();
|
||||||
|
if (manager) {
|
||||||
|
manager.initExtensions(extensionStore.extensions);
|
||||||
|
}
|
||||||
|
|
||||||
await applyKeymapSettings();
|
await applyKeymapSettings();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
|||||||
import {useEditorStore} from '@/stores/editorStore';
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
import {useDocumentStore} from '@/stores/documentStore';
|
import {useDocumentStore} from '@/stores/documentStore';
|
||||||
import {useConfigStore} from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
|
||||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||||
import {useWindowStore} from "@/stores/windowStore";
|
import {useWindowStore} from "@/stores/windowStore";
|
||||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||||
@@ -19,12 +18,6 @@ const editorElement = ref<HTMLElement | null>(null);
|
|||||||
|
|
||||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||||
|
|
||||||
// 创建滚轮缩放处理器
|
|
||||||
const wheelHandler = createWheelZoomHandler(
|
|
||||||
configStore.increaseFontSize,
|
|
||||||
configStore.decreaseFontSize
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!editorElement.value) return;
|
if (!editorElement.value) return;
|
||||||
|
|
||||||
@@ -38,16 +31,9 @@ onMounted(async () => {
|
|||||||
editorStore.setEditorContainer(editorElement.value);
|
editorStore.setEditorContainer(editorElement.value);
|
||||||
|
|
||||||
await tabStore.initializeTab();
|
await tabStore.initializeTab();
|
||||||
|
|
||||||
// 添加滚轮事件监听
|
|
||||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// 移除滚轮事件监听
|
|
||||||
if (editorElement.value) {
|
|
||||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
|
||||||
}
|
|
||||||
editorStore.clearAllEditors();
|
editorStore.clearAllEditors();
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -88,7 +74,6 @@ onBeforeUnmount(() => {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载动画过渡效果
|
|
||||||
.loading-fade-enter-active,
|
.loading-fade-enter-active,
|
||||||
.loading-fade-leave-active {
|
.loading-fade-leave-active {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
@@ -99,3 +84,4 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -1,22 +1,40 @@
|
|||||||
// 处理滚轮缩放字体的事件处理函数
|
import {EditorView} from '@codemirror/view';
|
||||||
export const createWheelZoomHandler = (
|
import type {Extension} from '@codemirror/state';
|
||||||
increaseFontSize: () => void,
|
|
||||||
decreaseFontSize: () => void
|
type FontAdjuster = () => Promise<void> | void;
|
||||||
) => {
|
|
||||||
return (event: WheelEvent) => {
|
const runAdjuster = (adjuster: FontAdjuster) => {
|
||||||
// 检查是否按住了Ctrl键
|
try {
|
||||||
if (event.ctrlKey) {
|
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();
|
event.preventDefault();
|
||||||
|
|
||||||
// 根据滚轮方向增大或减小字体
|
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
// 向上滚动,增大字体
|
runAdjuster(increaseFontSize);
|
||||||
increaseFontSize();
|
} else if (event.deltaY > 0) {
|
||||||
} else {
|
runAdjuster(decreaseFontSize);
|
||||||
// 向下滚动,减小字体
|
|
||||||
decreaseFontSize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
@@ -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 命令
|
* Block 命令
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EditorSelection } from "@codemirror/state";
|
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||||
import { Command } from "@codemirror/view";
|
import { Command } from "@codemirror/view";
|
||||||
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
|
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
|
||||||
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
|
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
|
||||||
import { formatBlockContent } from "./formatCode";
|
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), {
|
dispatch(state.replaceSelection(delimText), {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "input",
|
userEvent: USER_EVENTS.INPUT,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -55,9 +56,10 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({
|
|||||||
insert: delimText,
|
insert: delimText,
|
||||||
},
|
},
|
||||||
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
|
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
|
||||||
|
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||||
}, {
|
}, {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "input",
|
userEvent: USER_EVENTS.INPUT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -79,10 +81,11 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s
|
|||||||
from: block.content.to,
|
from: block.content.to,
|
||||||
insert: delimText,
|
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,
|
scrollIntoView: true,
|
||||||
userEvent: "input",
|
userEvent: USER_EVENTS.INPUT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -105,9 +108,10 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st
|
|||||||
insert: delimText,
|
insert: delimText,
|
||||||
},
|
},
|
||||||
selection: EditorSelection.cursor(delimText.length),
|
selection: EditorSelection.cursor(delimText.length),
|
||||||
|
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||||
}, {
|
}, {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "input",
|
userEvent: USER_EVENTS.INPUT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -129,10 +133,11 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
|||||||
from: block.content.to,
|
from: block.content.to,
|
||||||
insert: delimText,
|
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,
|
scrollIntoView: true,
|
||||||
userEvent: "input",
|
userEvent: USER_EVENTS.INPUT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
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) {
|
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
|
||||||
if (state.readOnly) return false;
|
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`;
|
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -157,12 +157,10 @@ export function changeLanguageTo(state: any, dispatch: any, block: Block, langua
|
|||||||
to: block.delimiter.to,
|
to: block.delimiter.to,
|
||||||
insert: newDelimiter,
|
insert: newDelimiter,
|
||||||
},
|
},
|
||||||
|
annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,7 +187,7 @@ function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSel(state: any, selection: EditorSelection) {
|
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) {
|
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,
|
to: block.range.to,
|
||||||
insert: ""
|
insert: ""
|
||||||
},
|
},
|
||||||
selection: EditorSelection.cursor(newCursorPos)
|
selection: EditorSelection.cursor(newCursorPos),
|
||||||
|
annotations: [codeBlockEvent.of(DELETE_BLOCK)]
|
||||||
}, {
|
}, {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "delete"
|
userEvent: USER_EVENTS.DELETE
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -366,10 +365,11 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
|||||||
|
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
changes,
|
changes,
|
||||||
selection: EditorSelection.cursor(newCursorPos)
|
selection: EditorSelection.cursor(newCursorPos),
|
||||||
|
annotations: [codeBlockEvent.of(MOVE_BLOCK)]
|
||||||
}, {
|
}, {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "move"
|
userEvent: USER_EVENTS.MOVE
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -381,3 +381,20 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
|||||||
export const formatCurrentBlock: Command = (view) => {
|
export const formatCurrentBlock: Command = (view) => {
|
||||||
return formatBlockContent(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 { EditorView } from "@codemirror/view";
|
||||||
import { Command } from "@codemirror/view";
|
import { Command } from "@codemirror/view";
|
||||||
import { LANGUAGES } from "./lang-parser/languages";
|
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({
|
view.dispatch({
|
||||||
changes: ranges,
|
changes: ranges,
|
||||||
scrollIntoView: true,
|
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({
|
view.dispatch({
|
||||||
changes: ranges,
|
changes: ranges,
|
||||||
scrollIntoView: true,
|
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, {
|
view.dispatch(changes, {
|
||||||
userEvent: "input.paste",
|
userEvent: USER_EVENTS.INPUT_PASTE,
|
||||||
scrollIntoView: true
|
scrollIntoView: true,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EditorView } from '@codemirror/view';
|
|||||||
import { EditorSelection } from '@codemirror/state';
|
import { EditorSelection } from '@codemirror/state';
|
||||||
import { blockState } from './state';
|
import { blockState } from './state';
|
||||||
import { Block } from './types';
|
import { Block } from './types';
|
||||||
|
import { USER_EVENTS } from './annotation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 二分查找:找到包含指定位置的块
|
* 二分查找:找到包含指定位置的块
|
||||||
@@ -136,7 +137,7 @@ export function createCursorProtection() {
|
|||||||
view.dispatch({
|
view.dispatch({
|
||||||
selection: EditorSelection.cursor(adjustedPos),
|
selection: EditorSelection.cursor(adjustedPos),
|
||||||
scrollIntoView: true,
|
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 { 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 { blockState } from "./state";
|
||||||
|
import { codeBlockEvent, USER_EVENTS } from "./annotation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 块开始装饰组件
|
* 块开始装饰组件
|
||||||
@@ -180,10 +181,11 @@ const blockLayer = layer({
|
|||||||
*/
|
*/
|
||||||
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
|
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
|
||||||
const protect: number[] = [];
|
const protect: number[] = [];
|
||||||
|
const internalEvent = tr.annotation(codeBlockEvent);
|
||||||
|
|
||||||
// 获取块状态并获取第一个块的分隔符大小
|
// 获取块状态并获取第一个块的分隔符大小
|
||||||
const blocks = tr.startState.field(blockState);
|
const blocks = tr.startState.field(blockState);
|
||||||
if (blocks && blocks.length > 0) {
|
if (!internalEvent && blocks && blocks.length > 0) {
|
||||||
const firstBlock = blocks[0];
|
const firstBlock = blocks[0];
|
||||||
const firstBlockDelimiterSize = firstBlock.delimiter.to;
|
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")) {
|
const userEvent = tr.annotation(Transaction.userEvent);
|
||||||
blocks.forEach((block: any) => {
|
if (userEvent && (userEvent === USER_EVENTS.INPUT_REPLACE || userEvent === USER_EVENTS.INPUT_REPLACE_ALL)) {
|
||||||
|
blocks?.forEach((block: any) => {
|
||||||
if (block.delimiter) {
|
if (block.delimiter) {
|
||||||
protect.push(block.delimiter.from, block.delimiter.to);
|
protect.push(block.delimiter.from, block.delimiter.to);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回保护范围数组,如果没有需要保护的范围则返回 false
|
// 返回保护范围数组;若无需保护则返回 true 放行事务
|
||||||
return protect.length > 0 ? protect : false;
|
return protect.length > 0 ? protect : true;
|
||||||
});
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 防止选择在第一个块之前
|
* 防止选择在第一个块之前
|
||||||
* 使用 transactionFilter 来确保选择不会在第一个块之前
|
* 使用 transactionFilter 来确保选择不会在第一个块之前
|
||||||
*/
|
*/
|
||||||
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
|
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
|
||||||
|
if (tr.annotation(codeBlockEvent)) {
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
// 获取块状态并获取第一个块的分隔符大小
|
// 获取块状态并获取第一个块的分隔符大小
|
||||||
const blocks = tr.startState.field(blockState);
|
const blocks = tr.startState.field(blockState);
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { getNoteBlockFromPos } from "./state";
|
import { getNoteBlockFromPos } from "./state";
|
||||||
|
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||||
|
import { USER_EVENTS } from "./annotation";
|
||||||
|
|
||||||
interface LineBlock {
|
interface LineBlock {
|
||||||
from: number;
|
from: number;
|
||||||
@@ -87,7 +89,8 @@ export const deleteLine = (view: EditorView): boolean => {
|
|||||||
changes,
|
changes,
|
||||||
selection,
|
selection,
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "delete.line"
|
userEvent: USER_EVENTS.DELETE_LINE,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -127,7 +130,8 @@ export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: a
|
|||||||
changes,
|
changes,
|
||||||
selection,
|
selection,
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "delete.line"
|
userEvent: USER_EVENTS.DELETE_LINE,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as prettier from "prettier/standalone";
|
|||||||
import { getActiveNoteBlock } from "./state";
|
import { getActiveNoteBlock } from "./state";
|
||||||
import { getLanguage } from "./lang-parser/languages";
|
import { getLanguage } from "./lang-parser/languages";
|
||||||
import { SupportedLanguage } from "./types";
|
import { SupportedLanguage } from "./types";
|
||||||
|
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||||
|
|
||||||
export const formatBlockContent = (view) => {
|
export const formatBlockContent = (view) => {
|
||||||
if (!view || view.state.readOnly)
|
if (!view || view.state.readOnly)
|
||||||
@@ -87,7 +88,8 @@ export const formatBlockContent = (view) => {
|
|||||||
},
|
},
|
||||||
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
|
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "input"
|
userEvent: USER_EVENTS.INPUT,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
|
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
|
||||||
import { RangeSetBuilder } from "@codemirror/state";
|
import { RangeSetBuilder } from "@codemirror/state";
|
||||||
import { getNoteBlockFromPos } from "./state";
|
import { getNoteBlockFromPos } from "./state";
|
||||||
|
import { transactionsHasAnnotation, CURRENCIES_LOADED } from "./annotation";
|
||||||
|
|
||||||
|
type MathParserEntry = {
|
||||||
|
parser: any;
|
||||||
|
prev?: any;
|
||||||
|
};
|
||||||
// 声明全局math对象
|
// 声明全局math对象
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -62,8 +68,7 @@ class MathResult extends WidgetType {
|
|||||||
/**
|
/**
|
||||||
* 数学装饰函数
|
* 数学装饰函数
|
||||||
*/
|
*/
|
||||||
function mathDeco(view: any): any {
|
function mathDeco(view: any, parserCache: WeakMap<any, MathParserEntry>): any {
|
||||||
const mathParsers = new WeakMap();
|
|
||||||
const builder = new RangeSetBuilder();
|
const builder = new RangeSetBuilder();
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
@@ -72,12 +77,17 @@ function mathDeco(view: any): any {
|
|||||||
const block = getNoteBlockFromPos(view.state, pos);
|
const block = getNoteBlockFromPos(view.state, pos);
|
||||||
|
|
||||||
if (block && block.language.name === "math") {
|
if (block && block.language.name === "math") {
|
||||||
// get math.js parser and cache it for this block
|
let entry = parserCache.get(block);
|
||||||
let { parser, prev } = mathParsers.get(block) || {};
|
let parser = entry?.parser;
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
|
if (line.from > block.content.from) {
|
||||||
|
pos = block.content.from;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (typeof window.math !== 'undefined') {
|
if (typeof window.math !== 'undefined') {
|
||||||
parser = window.math.parser();
|
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;
|
let result: any;
|
||||||
try {
|
try {
|
||||||
if (parser) {
|
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);
|
result = parser.evaluate(line.text);
|
||||||
if (result !== undefined) {
|
if (entry && result !== undefined) {
|
||||||
mathParsers.set(block, { parser, prev: result });
|
entry.prev = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -97,7 +112,7 @@ function mathDeco(view: any): any {
|
|||||||
|
|
||||||
// if we got a result from math.js, add the result decoration
|
// if we got a result from math.js, add the result decoration
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
const format = parser?.get("format");
|
const format = parser?.get?.("format");
|
||||||
|
|
||||||
let resultWidget: MathResult | undefined;
|
let resultWidget: MathResult | undefined;
|
||||||
if (typeof(result) === "string") {
|
if (typeof(result) === "string") {
|
||||||
@@ -142,15 +157,25 @@ function mathDeco(view: any): any {
|
|||||||
*/
|
*/
|
||||||
export const mathBlock = ViewPlugin.fromClass(class {
|
export const mathBlock = ViewPlugin.fromClass(class {
|
||||||
decorations: any;
|
decorations: any;
|
||||||
|
mathParsers: WeakMap<any, MathParserEntry>;
|
||||||
|
|
||||||
constructor(view: any) {
|
constructor(view: any) {
|
||||||
this.decorations = mathDeco(view);
|
this.mathParsers = new WeakMap();
|
||||||
|
this.decorations = mathDeco(view, this.mathParsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: any) {
|
update(update: any) {
|
||||||
// If the document changed, the viewport changed, update the decorations
|
const hasCurrencyUpdate = transactionsHasAnnotation(update.transactions, CURRENCIES_LOADED);
|
||||||
if (update.docChanged || update.viewportChanged) {
|
if (update.docChanged || hasCurrencyUpdate) {
|
||||||
this.decorations = mathDeco(update.view);
|
// 文档结构或汇率变化时重置解析缓存
|
||||||
|
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 { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||||
import { blockState } from "./state";
|
import { blockState } from "./state";
|
||||||
import { LANGUAGES } from "./lang-parser/languages";
|
import { LANGUAGES } from "./lang-parser/languages";
|
||||||
|
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||||
|
import { USER_EVENTS } from "./annotation";
|
||||||
|
|
||||||
interface LineBlock {
|
interface LineBlock {
|
||||||
from: number;
|
from: number;
|
||||||
@@ -131,7 +133,8 @@ function moveLine(state: any, dispatch: any, forward: boolean): boolean {
|
|||||||
changes,
|
changes,
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
selection: EditorSelection.create(ranges, state.selection.mainIndex),
|
selection: EditorSelection.create(ranges, state.selection.mainIndex),
|
||||||
userEvent: "move.line"
|
userEvent: USER_EVENTS.MOVE_LINE,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EditorState } from '@codemirror/state';
|
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 { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
|
||||||
import {
|
import {
|
||||||
SupportedLanguage,
|
SupportedLanguage,
|
||||||
@@ -15,51 +16,47 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { LANGUAGES } from './lang-parser/languages';
|
import { LANGUAGES } from './lang-parser/languages';
|
||||||
|
|
||||||
|
const DEFAULT_LANGUAGE = (LANGUAGES[0]?.token || 'text') as string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从语法树解析代码块
|
* 从语法树解析代码块
|
||||||
*/
|
*/
|
||||||
export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
||||||
if (!syntaxTreeAvailable(state)) {
|
const tree = syntaxTree(state);
|
||||||
|
if (!tree) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return collectBlocksFromTree(tree, state);
|
||||||
|
}
|
||||||
|
|
||||||
const tree = syntaxTree(state);
|
function collectBlocksFromTree(tree: Tree, state: EditorState): Block[] | null {
|
||||||
const blocks: Block[] = [];
|
const blocks: Block[] = [];
|
||||||
const doc = state.doc;
|
const doc = state.doc;
|
||||||
|
|
||||||
// 遍历语法树中的所有块
|
|
||||||
tree.iterate({
|
tree.iterate({
|
||||||
enter(node) {
|
enter(node) {
|
||||||
if (node.type.id === BlockNode) {
|
if (node.type.id === BlockNode) {
|
||||||
// 查找块的分隔符和内容
|
|
||||||
let delimiter: { from: number; to: number } | null = null;
|
let delimiter: { from: number; to: number } | null = null;
|
||||||
let content: { 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;
|
let auto = false;
|
||||||
|
|
||||||
// 遍历块的子节点
|
|
||||||
const blockNode = node.node;
|
const blockNode = node.node;
|
||||||
blockNode.firstChild?.cursor().iterate(child => {
|
blockNode.firstChild?.cursor().iterate(child => {
|
||||||
if (child.type.id === BlockDelimiter) {
|
if (child.type.id === BlockDelimiter) {
|
||||||
delimiter = { from: child.from, to: child.to };
|
delimiter = { from: child.from, to: child.to };
|
||||||
|
|
||||||
// 解析整个分隔符文本来获取语言和自动检测标记
|
|
||||||
const delimiterText = doc.sliceString(child.from, child.to);
|
const delimiterText = doc.sliceString(child.from, child.to);
|
||||||
|
|
||||||
// 使用正则表达式解析分隔符
|
|
||||||
const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/);
|
const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/);
|
||||||
if (match) {
|
if (match) {
|
||||||
language = match[1] || 'text';
|
language = match[1] || DEFAULT_LANGUAGE;
|
||||||
auto = match[2] === '-a';
|
auto = match[2] === '-a';
|
||||||
} else {
|
} else {
|
||||||
// 回退到逐个解析子节点
|
|
||||||
child.node.firstChild?.cursor().iterate(langChild => {
|
child.node.firstChild?.cursor().iterate(langChild => {
|
||||||
if (langChild.type.id === BlockLanguage) {
|
if (langChild.type.id === BlockLanguage) {
|
||||||
const langText = doc.sliceString(langChild.from, langChild.to);
|
const langText = doc.sliceString(langChild.from, langChild.to);
|
||||||
language = langText || 'text';
|
language = langText || DEFAULT_LANGUAGE;
|
||||||
}
|
}
|
||||||
// 检查是否有自动检测标记
|
if (doc.sliceString(langChild.from, langChild.to) === AUTO_DETECT_SUFFIX) {
|
||||||
if (doc.sliceString(langChild.from, langChild.to) === '-a') {
|
|
||||||
auto = true;
|
auto = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,7 +85,6 @@ export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (blocks.length > 0) {
|
if (blocks.length > 0) {
|
||||||
// 设置第一个块分隔符的大小
|
|
||||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
@@ -107,183 +103,52 @@ export function getBlocksFromString(state: EditorState): Block[] {
|
|||||||
const doc = state.doc;
|
const doc = state.doc;
|
||||||
|
|
||||||
if (doc.length === 0) {
|
if (doc.length === 0) {
|
||||||
// 如果文档为空,创建一个默认的文本块
|
return [createPlainTextBlock(0, 0)];
|
||||||
return [{
|
|
||||||
language: {
|
|
||||||
name: 'text',
|
|
||||||
auto: false,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
delimiter: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = doc.sliceString(0, doc.length);
|
const content = doc.sliceString(0, doc.length);
|
||||||
const delim = "\n∞∞∞";
|
const delimiter = DELIMITER_PREFIX;
|
||||||
let pos = 0;
|
const suffixLength = DELIMITER_SUFFIX.length;
|
||||||
|
|
||||||
// 检查文档是否以分隔符开始(不带前导换行符)
|
let pos = content.indexOf(delimiter);
|
||||||
if (content.startsWith("∞∞∞")) {
|
|
||||||
// 文档直接以分隔符开始,调整为标准格式
|
|
||||||
pos = 0;
|
|
||||||
} else if (content.startsWith("\n∞∞∞")) {
|
|
||||||
// 文档以换行符+分隔符开始,这是标准格式,从位置0开始解析
|
|
||||||
pos = 0;
|
|
||||||
} else {
|
|
||||||
// 如果文档不以分隔符开始,查找第一个分隔符
|
|
||||||
const firstDelimPos = content.indexOf(delim);
|
|
||||||
|
|
||||||
if (firstDelimPos === -1) {
|
if (pos === -1) {
|
||||||
// 如果没有找到分隔符,整个文档作为一个文本块
|
|
||||||
firstBlockDelimiterSize = 0;
|
firstBlockDelimiterSize = 0;
|
||||||
return [{
|
return [createPlainTextBlock(0, doc.length)];
|
||||||
language: {
|
|
||||||
name: 'text',
|
|
||||||
auto: false,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
from: 0,
|
|
||||||
to: doc.length,
|
|
||||||
},
|
|
||||||
delimiter: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
from: 0,
|
|
||||||
to: 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({
|
blocks.push({
|
||||||
language: {
|
language: { name: delimiterInfo.language, auto: delimiterInfo.auto },
|
||||||
name: 'text',
|
content: { from: contentStart, to: contentEnd },
|
||||||
auto: false,
|
delimiter: { from: blockStart, to: delimiterEnd + suffixLength },
|
||||||
},
|
range: { from: blockStart, to: contentEnd },
|
||||||
content: {
|
|
||||||
from: 0,
|
|
||||||
to: firstDelimPos,
|
|
||||||
},
|
|
||||||
delimiter: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
from: 0,
|
|
||||||
to: firstDelimPos,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pos = firstDelimPos;
|
pos = nextDelimiter;
|
||||||
firstBlockDelimiterSize = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (blocks.length === 0) {
|
||||||
blocks.push({
|
blocks.push(createPlainTextBlock(0, doc.length));
|
||||||
language: {
|
|
||||||
name: 'text',
|
|
||||||
auto: false,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
from: 0,
|
|
||||||
to: doc.length,
|
|
||||||
},
|
|
||||||
delimiter: {
|
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
from: 0,
|
|
||||||
to: doc.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
firstBlockDelimiterSize = 0;
|
firstBlockDelimiterSize = 0;
|
||||||
} else {
|
} else {
|
||||||
// 设置第一个块分隔符的大小
|
|
||||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,13 +159,19 @@ export function getBlocksFromString(state: EditorState): Block[] {
|
|||||||
* 获取文档中的所有块
|
* 获取文档中的所有块
|
||||||
*/
|
*/
|
||||||
export function getBlocks(state: EditorState): Block[] {
|
export function getBlocks(state: EditorState): Block[] {
|
||||||
// 优先使用语法树解析
|
let blocks = getBlocksFromSyntaxTree(state);
|
||||||
const syntaxTreeBlocks = getBlocksFromSyntaxTree(state);
|
if (blocks) {
|
||||||
if (syntaxTreeBlocks) {
|
return blocks;
|
||||||
return syntaxTreeBlocks;
|
}
|
||||||
|
|
||||||
|
const ensuredTree = ensureSyntaxTree(state, state.doc.length, 200);
|
||||||
|
if (ensuredTree) {
|
||||||
|
blocks = collectBlocksFromTree(ensuredTree, state);
|
||||||
|
if (blocks) {
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果语法树不可用,回退到字符串解析
|
|
||||||
return getBlocksFromString(state);
|
return getBlocksFromString(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,10 +267,21 @@ export function parseDelimiter(delimiterText: string): { language: SupportedLang
|
|||||||
|
|
||||||
const validLanguage = LANGUAGES.some(lang => lang.token === languageName)
|
const validLanguage = LANGUAGES.some(lang => lang.token === languageName)
|
||||||
? languageName as SupportedLanguage
|
? languageName as SupportedLanguage
|
||||||
: 'text';
|
: DEFAULT_LANGUAGE as SupportedLanguage;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
language: validLanguage,
|
language: validLanguage,
|
||||||
auto: isAuto
|
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 { selectAll as defaultSelectAll } from "@codemirror/commands";
|
||||||
import { Command } from "@codemirror/view";
|
import { Command } from "@codemirror/view";
|
||||||
import { getActiveNoteBlock, blockState } from "./state";
|
import { getActiveNoteBlock, blockState } from "./state";
|
||||||
|
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
|
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
|
||||||
@@ -115,7 +116,8 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
|||||||
// 选择当前块的所有内容
|
// 选择当前块的所有内容
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
selection: { anchor: block.content.from, head: block.content.to },
|
selection: { anchor: block.content.from, head: block.content.to },
|
||||||
userEvent: "select"
|
userEvent: USER_EVENTS.SELECT,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -127,7 +129,7 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
|||||||
*/
|
*/
|
||||||
export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => {
|
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;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) =>
|
|||||||
return {
|
return {
|
||||||
...tr,
|
...tr,
|
||||||
selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex),
|
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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { EditorSelection, findClusterBreak } from "@codemirror/state";
|
import { EditorSelection, findClusterBreak } from "@codemirror/state";
|
||||||
import { getNoteBlockFromPos } from "./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, {
|
dispatch(state.update(changes, {
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
userEvent: "move.character"
|
userEvent: USER_EVENTS.MOVE_CHARACTER,
|
||||||
|
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Extension } from '@codemirror/state';
|
import { Extension } from '@codemirror/state';
|
||||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||||
import { useExtensionStore } from '@/stores/extensionStore';
|
import { useExtensionStore } from '@/stores/extensionStore';
|
||||||
import { KeymapManager } from './keymapManager';
|
import { Manager } from './manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步创建快捷键扩展
|
* 异步创建快捷键扩展
|
||||||
@@ -23,7 +23,7 @@ export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
|||||||
// 获取启用的扩展ID列表
|
// 获取启用的扩展ID列表
|
||||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||||
|
|
||||||
return KeymapManager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
return Manager.createKeymapExtension(keybindingStore.keyBindings, enabledExtensionIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,10 +37,10 @@ export const updateKeymapExtension = (view: any): void => {
|
|||||||
// 获取启用的扩展ID列表
|
// 获取启用的扩展ID列表
|
||||||
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
const enabledExtensionIds = extensionStore.enabledExtensions.map(ext => ext.id);
|
||||||
|
|
||||||
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
Manager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export { KeymapManager } from './keymapManager';
|
export { Manager } from './manager';
|
||||||
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
||||||
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import {getCommandHandler, isCommandRegistered} from './commands';
|
|||||||
* 快捷键管理器
|
* 快捷键管理器
|
||||||
* 负责将后端配置转换为CodeMirror快捷键扩展
|
* 负责将后端配置转换为CodeMirror快捷键扩展
|
||||||
*/
|
*/
|
||||||
export class KeymapManager {
|
export class Manager {
|
||||||
private static compartment = new Compartment();
|
private static compartment = new Compartment();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import {Compartment, Extension} from '@codemirror/state';
|
|
||||||
import {EditorView} from '@codemirror/view';
|
|
||||||
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
import {ExtensionState, EditorViewInfo, ExtensionFactory} from './types'
|
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展管理器
|
|
||||||
* 负责管理所有动态扩展的注册、启用、禁用和配置更新
|
|
||||||
* 采用统一配置,多视图同步的设计模式
|
|
||||||
*/
|
|
||||||
export class ExtensionManager {
|
|
||||||
// 统一的扩展状态存储
|
|
||||||
private extensionStates = new Map<ExtensionID, ExtensionState>();
|
|
||||||
|
|
||||||
// 编辑器视图管理
|
|
||||||
private viewsMap = new Map<number, EditorViewInfo>();
|
|
||||||
private activeViewId: number | null = null;
|
|
||||||
|
|
||||||
// 注册的扩展工厂
|
|
||||||
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
|
|
||||||
|
|
||||||
// 防抖处理
|
|
||||||
private debouncedUpdateFunctions = new Map<ExtensionID, {
|
|
||||||
debouncedFn: (enabled: boolean, config: any) => void;
|
|
||||||
cancel: () => void;
|
|
||||||
flush: () => void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册扩展工厂
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param factory 扩展工厂
|
|
||||||
*/
|
|
||||||
registerExtension(id: ExtensionID, factory: ExtensionFactory): void {
|
|
||||||
this.extensionFactories.set(id, factory);
|
|
||||||
|
|
||||||
// 创建初始状态
|
|
||||||
if (!this.extensionStates.has(id)) {
|
|
||||||
const compartment = new Compartment();
|
|
||||||
const defaultConfig = factory.getDefaultConfig();
|
|
||||||
|
|
||||||
this.extensionStates.set(id, {
|
|
||||||
id,
|
|
||||||
factory,
|
|
||||||
config: defaultConfig,
|
|
||||||
enabled: false,
|
|
||||||
compartment,
|
|
||||||
extension: [] // 默认为空扩展(禁用状态)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个扩展创建防抖函数
|
|
||||||
if (!this.debouncedUpdateFunctions.has(id)) {
|
|
||||||
const { debouncedFn, cancel, flush } = createDebounce(
|
|
||||||
(enabled: boolean, config: any) => {
|
|
||||||
this.updateExtensionImmediate(id, enabled, config);
|
|
||||||
},
|
|
||||||
{ delay: 300 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.debouncedUpdateFunctions.set(id, {
|
|
||||||
debouncedFn,
|
|
||||||
cancel,
|
|
||||||
flush
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有注册的扩展ID列表
|
|
||||||
*/
|
|
||||||
getRegisteredExtensions(): ExtensionID[] {
|
|
||||||
return Array.from(this.extensionFactories.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查扩展是否已注册
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
isExtensionRegistered(id: ExtensionID): boolean {
|
|
||||||
return this.extensionFactories.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从后端配置初始化扩展状态
|
|
||||||
* @param extensionConfigs 后端扩展配置列表
|
|
||||||
*/
|
|
||||||
initializeExtensionsFromConfig(extensionConfigs: ExtensionConfig[]): void {
|
|
||||||
for (const config of extensionConfigs) {
|
|
||||||
const factory = this.extensionFactories.get(config.id);
|
|
||||||
if (!factory) continue;
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (factory.validateConfig && !factory.validateConfig(config.config)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建扩展实例
|
|
||||||
const extension = config.enabled ? factory.create(config.config) : [];
|
|
||||||
|
|
||||||
// 如果状态已存在则更新,否则创建新状态
|
|
||||||
if (this.extensionStates.has(config.id)) {
|
|
||||||
const state = this.extensionStates.get(config.id)!;
|
|
||||||
state.config = config.config;
|
|
||||||
state.enabled = config.enabled;
|
|
||||||
state.extension = extension;
|
|
||||||
} else {
|
|
||||||
const compartment = new Compartment();
|
|
||||||
this.extensionStates.set(config.id, {
|
|
||||||
id: config.id,
|
|
||||||
factory,
|
|
||||||
config: config.config,
|
|
||||||
enabled: config.enabled,
|
|
||||||
compartment,
|
|
||||||
extension
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to initialize extension ${config.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取初始扩展配置数组(用于创建编辑器)
|
|
||||||
* @returns CodeMirror扩展数组
|
|
||||||
*/
|
|
||||||
getInitialExtensions(): Extension[] {
|
|
||||||
const extensions: Extension[] = [];
|
|
||||||
|
|
||||||
// 为每个注册的扩展添加compartment
|
|
||||||
for (const state of this.extensionStates.values()) {
|
|
||||||
extensions.push(state.compartment.of(state.extension));
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置编辑器视图
|
|
||||||
* @param view 编辑器视图实例
|
|
||||||
* @param documentId 文档ID
|
|
||||||
*/
|
|
||||||
setView(view: EditorView, documentId: number): void {
|
|
||||||
// 保存视图信息
|
|
||||||
this.viewsMap.set(documentId, {
|
|
||||||
view,
|
|
||||||
documentId,
|
|
||||||
registered: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 设置当前活动视图
|
|
||||||
this.activeViewId = documentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前活动视图
|
|
||||||
*/
|
|
||||||
private getActiveView(): EditorView | null {
|
|
||||||
if (this.activeViewId === null) return null;
|
|
||||||
const viewInfo = this.viewsMap.get(this.activeViewId);
|
|
||||||
return viewInfo ? viewInfo.view : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新单个扩展配置并应用到所有视图(带防抖功能)
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param enabled 是否启用
|
|
||||||
* @param config 扩展配置
|
|
||||||
*/
|
|
||||||
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
|
||||||
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
|
|
||||||
if (debouncedUpdate) {
|
|
||||||
debouncedUpdate.debouncedFn(enabled, config);
|
|
||||||
} else {
|
|
||||||
// 如果没有防抖函数,直接执行
|
|
||||||
this.updateExtensionImmediate(id, enabled, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 立即更新扩展(无防抖)
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @param enabled 是否启用
|
|
||||||
* @param config 扩展配置
|
|
||||||
*/
|
|
||||||
updateExtensionImmediate(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
|
||||||
// 获取扩展状态
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
// 获取工厂
|
|
||||||
const factory = state.factory;
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (factory.validateConfig && !factory.validateConfig(config)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建新的扩展实例
|
|
||||||
const extension = enabled ? factory.create(config) : [];
|
|
||||||
|
|
||||||
// 更新内部状态
|
|
||||||
state.config = config;
|
|
||||||
state.enabled = enabled;
|
|
||||||
state.extension = extension;
|
|
||||||
|
|
||||||
// 应用到所有视图
|
|
||||||
this.applyExtensionToAllViews(id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update extension ${id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将指定扩展的当前状态应用到所有视图
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
private applyExtensionToAllViews(id: ExtensionID): void {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
// 遍历所有视图并应用更改
|
|
||||||
for (const viewInfo of this.viewsMap.values()) {
|
|
||||||
try {
|
|
||||||
if (!viewInfo.registered) continue;
|
|
||||||
|
|
||||||
viewInfo.view.dispatch({
|
|
||||||
effects: state.compartment.reconfigure(state.extension)
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to apply extension ${id} to document ${viewInfo.documentId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展当前状态
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
getExtensionState(id: ExtensionID): {
|
|
||||||
enabled: boolean
|
|
||||||
config: any
|
|
||||||
} | null {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: state.enabled,
|
|
||||||
config: state.config
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置扩展到默认配置
|
|
||||||
* @param id 扩展ID
|
|
||||||
*/
|
|
||||||
resetExtensionToDefault(id: ExtensionID): void {
|
|
||||||
const state = this.extensionStates.get(id);
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
const defaultConfig = state.factory.getDefaultConfig();
|
|
||||||
this.updateExtension(id, true, defaultConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从管理器中移除视图
|
|
||||||
* @param documentId 文档ID
|
|
||||||
*/
|
|
||||||
removeView(documentId: number): void {
|
|
||||||
if (this.activeViewId === documentId) {
|
|
||||||
this.activeViewId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.viewsMap.delete(documentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁管理器
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
// 清除所有防抖函数
|
|
||||||
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
this.debouncedUpdateFunctions.clear();
|
|
||||||
|
|
||||||
this.viewsMap.clear();
|
|
||||||
this.activeViewId = null;
|
|
||||||
this.extensionFactories.clear();
|
|
||||||
this.extensionStates.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +1,152 @@
|
|||||||
import {ExtensionManager} from './extensionManager';
|
import {Manager} from './manager';
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import {ExtensionFactory} from './types'
|
import {ExtensionDefinition} from './types';
|
||||||
|
|
||||||
// 导入现有扩展的创建函数
|
|
||||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
||||||
import {createTextHighlighter} from '../extensions/textHighlight/textHighlightExtension';
|
import {createTextHighlighter} from '../extensions/textHighlight/textHighlightExtension';
|
||||||
|
|
||||||
import {color} from '../extensions/colorSelector';
|
import {color} from '../extensions/colorSelector';
|
||||||
import {hyperLink} from '../extensions/hyperlink';
|
import {hyperLink} from '../extensions/hyperlink';
|
||||||
import {minimap} from '../extensions/minimap';
|
import {minimap} from '../extensions/minimap';
|
||||||
import {vscodeSearch} from '../extensions/vscodeSearch';
|
import {vscodeSearch} from '../extensions/vscodeSearch';
|
||||||
import {createCheckboxExtension} from '../extensions/checkbox';
|
import {createCheckboxExtension} from '../extensions/checkbox';
|
||||||
import {createTranslatorExtension} from '../extensions/translator';
|
import {createTranslatorExtension} from '../extensions/translator';
|
||||||
|
|
||||||
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
import {foldingOnIndent} from '../extensions/fold/foldExtension';
|
||||||
|
|
||||||
/**
|
type ExtensionEntry = {
|
||||||
* 彩虹括号扩展工厂
|
definition: ExtensionDefinition
|
||||||
*/
|
displayNameKey: string
|
||||||
export const rainbowBracketsFactory: ExtensionFactory = {
|
descriptionKey: string
|
||||||
create(_config: any) {
|
|
||||||
return rainbowBracketsExtension();
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
type RegisteredExtensionID = Exclude<ExtensionID, ExtensionID.$zero | ExtensionID.ExtensionEditor>;
|
||||||
* 文本高亮扩展工厂
|
|
||||||
*/
|
|
||||||
export const textHighlightFactory: ExtensionFactory = {
|
|
||||||
create(config: any) {
|
|
||||||
return createTextHighlighter({
|
|
||||||
backgroundColor: config.backgroundColor || '#FFD700',
|
|
||||||
opacity: config.opacity || 0.3
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
backgroundColor: '#FFD700', // 金黄色
|
|
||||||
opacity: 0.3 // 透明度
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object' &&
|
|
||||||
(!config.backgroundColor || typeof config.backgroundColor === 'string') &&
|
|
||||||
(!config.opacity || (typeof config.opacity === 'number' && config.opacity >= 0 && config.opacity <= 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const defineExtension = (create: (config: any) => any, defaultConfig: Record<string, any> = {}): ExtensionDefinition => ({
|
||||||
* 小地图扩展工厂
|
create,
|
||||||
*/
|
defaultConfig
|
||||||
export const minimapFactory: ExtensionFactory = {
|
});
|
||||||
create(config: any) {
|
|
||||||
const options = {
|
|
||||||
displayText: config.displayText || 'characters',
|
|
||||||
showOverlay: config.showOverlay || 'always',
|
|
||||||
autohide: config.autohide || false
|
|
||||||
};
|
|
||||||
return minimap(options);
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
displayText: 'characters',
|
|
||||||
showOverlay: 'always',
|
|
||||||
autohide: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object' &&
|
|
||||||
(!config.displayText || typeof config.displayText === 'string') &&
|
|
||||||
(!config.showOverlay || typeof config.showOverlay === 'string') &&
|
|
||||||
(!config.autohide || typeof config.autohide === 'boolean');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||||
* 超链接扩展工厂
|
|
||||||
*/
|
|
||||||
export const hyperlinkFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return hyperLink;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 颜色选择器扩展工厂
|
|
||||||
*/
|
|
||||||
export const colorSelectorFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return color;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索扩展工厂
|
|
||||||
*/
|
|
||||||
export const searchFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return vscodeSearch;
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const foldFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return foldingOnIndent;
|
|
||||||
},
|
|
||||||
getDefaultConfig(): any {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any): boolean {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择框扩展工厂
|
|
||||||
*/
|
|
||||||
export const checkboxFactory: ExtensionFactory = {
|
|
||||||
create(_config: any) {
|
|
||||||
return createCheckboxExtension();
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译扩展工厂
|
|
||||||
*/
|
|
||||||
export const translatorFactory: ExtensionFactory = {
|
|
||||||
create(config: any) {
|
|
||||||
return createTranslatorExtension({
|
|
||||||
minSelectionLength: config.minSelectionLength || 2,
|
|
||||||
maxTranslationLength: config.maxTranslationLength || 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getDefaultConfig() {
|
|
||||||
return {
|
|
||||||
minSelectionLength: 2,
|
|
||||||
maxTranslationLength: 5000,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validateConfig(config: any) {
|
|
||||||
return typeof config === 'object';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所有扩展的统一配置
|
|
||||||
* 排除$zero值以避免TypeScript类型错误
|
|
||||||
*/
|
|
||||||
const EXTENSION_CONFIGS = {
|
|
||||||
|
|
||||||
// 编辑增强扩展
|
|
||||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||||
factory: rainbowBracketsFactory,
|
definition: defineExtension(() => rainbowBracketsExtension()),
|
||||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionHyperlink]: {
|
[ExtensionID.ExtensionHyperlink]: {
|
||||||
factory: hyperlinkFactory,
|
definition: defineExtension(() => hyperLink),
|
||||||
displayNameKey: 'extensions.hyperlink.name',
|
displayNameKey: 'extensions.hyperlink.name',
|
||||||
descriptionKey: 'extensions.hyperlink.description'
|
descriptionKey: 'extensions.hyperlink.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionColorSelector]: {
|
[ExtensionID.ExtensionColorSelector]: {
|
||||||
factory: colorSelectorFactory,
|
definition: defineExtension(() => color),
|
||||||
displayNameKey: 'extensions.colorSelector.name',
|
displayNameKey: 'extensions.colorSelector.name',
|
||||||
descriptionKey: 'extensions.colorSelector.description'
|
descriptionKey: 'extensions.colorSelector.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionTranslator]: {
|
[ExtensionID.ExtensionTranslator]: {
|
||||||
factory: translatorFactory,
|
definition: defineExtension((config: any) => createTranslatorExtension({
|
||||||
|
minSelectionLength: config?.minSelectionLength ?? 2,
|
||||||
|
maxTranslationLength: config?.maxTranslationLength ?? 5000
|
||||||
|
}), {
|
||||||
|
minSelectionLength: 2,
|
||||||
|
maxTranslationLength: 5000
|
||||||
|
}),
|
||||||
displayNameKey: 'extensions.translator.name',
|
displayNameKey: 'extensions.translator.name',
|
||||||
descriptionKey: 'extensions.translator.description'
|
descriptionKey: 'extensions.translator.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
// UI增强扩展
|
|
||||||
[ExtensionID.ExtensionMinimap]: {
|
[ExtensionID.ExtensionMinimap]: {
|
||||||
factory: minimapFactory,
|
definition: defineExtension((config: any) => minimap({
|
||||||
|
displayText: config?.displayText ?? 'characters',
|
||||||
|
showOverlay: config?.showOverlay ?? 'always',
|
||||||
|
autohide: config?.autohide ?? false
|
||||||
|
}), {
|
||||||
|
displayText: 'characters',
|
||||||
|
showOverlay: 'always',
|
||||||
|
autohide: false
|
||||||
|
}),
|
||||||
displayNameKey: 'extensions.minimap.name',
|
displayNameKey: 'extensions.minimap.name',
|
||||||
descriptionKey: 'extensions.minimap.description'
|
descriptionKey: 'extensions.minimap.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
// 工具扩展
|
|
||||||
[ExtensionID.ExtensionSearch]: {
|
[ExtensionID.ExtensionSearch]: {
|
||||||
factory: searchFactory,
|
definition: defineExtension(() => vscodeSearch),
|
||||||
displayNameKey: 'extensions.search.name',
|
displayNameKey: 'extensions.search.name',
|
||||||
descriptionKey: 'extensions.search.description'
|
descriptionKey: 'extensions.search.description'
|
||||||
},
|
},
|
||||||
|
|
||||||
[ExtensionID.ExtensionFold]: {
|
[ExtensionID.ExtensionFold]: {
|
||||||
factory: foldFactory,
|
definition: defineExtension(() => foldingOnIndent),
|
||||||
displayNameKey: 'extensions.fold.name',
|
displayNameKey: 'extensions.fold.name',
|
||||||
descriptionKey: 'extensions.fold.description'
|
descriptionKey: 'extensions.fold.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionTextHighlight]: {
|
[ExtensionID.ExtensionTextHighlight]: {
|
||||||
factory: textHighlightFactory,
|
definition: defineExtension((config: any) => createTextHighlighter({
|
||||||
|
backgroundColor: config?.backgroundColor ?? '#FFD700',
|
||||||
|
opacity: config?.opacity ?? 0.3
|
||||||
|
}), {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
opacity: 0.3
|
||||||
|
}),
|
||||||
displayNameKey: 'extensions.textHighlight.name',
|
displayNameKey: 'extensions.textHighlight.name',
|
||||||
descriptionKey: 'extensions.textHighlight.description'
|
descriptionKey: 'extensions.textHighlight.description'
|
||||||
},
|
},
|
||||||
[ExtensionID.ExtensionCheckbox]: {
|
[ExtensionID.ExtensionCheckbox]: {
|
||||||
factory: checkboxFactory,
|
definition: defineExtension(() => createCheckboxExtension()),
|
||||||
displayNameKey: 'extensions.checkbox.name',
|
displayNameKey: 'extensions.checkbox.name',
|
||||||
descriptionKey: 'extensions.checkbox.description'
|
descriptionKey: 'extensions.checkbox.description'
|
||||||
}
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const isRegisteredExtension = (id: ExtensionID): id is RegisteredExtensionID =>
|
||||||
|
Object.prototype.hasOwnProperty.call(EXTENSION_REGISTRY, id);
|
||||||
|
|
||||||
|
const getRegistryEntry = (id: ExtensionID): ExtensionEntry | undefined => {
|
||||||
|
if (!isRegisteredExtension(id)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return EXTENSION_REGISTRY[id];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function registerAllExtensions(manager: Manager): void {
|
||||||
* 注册所有扩展工厂到管理器
|
(Object.entries(EXTENSION_REGISTRY) as [RegisteredExtensionID, ExtensionEntry][]).forEach(([id, entry]) => {
|
||||||
* @param manager 扩展管理器实例
|
manager.registerExtension(id, entry.definition);
|
||||||
*/
|
|
||||||
export function registerAllExtensions(manager: ExtensionManager): void {
|
|
||||||
Object.entries(EXTENSION_CONFIGS).forEach(([id, config]) => {
|
|
||||||
manager.registerExtension(id as ExtensionID, config.factory);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展工厂的显示名称
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 显示名称
|
|
||||||
*/
|
|
||||||
export function getExtensionDisplayName(id: ExtensionID): string {
|
export function getExtensionDisplayName(id: ExtensionID): string {
|
||||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
const entry = getRegistryEntry(id);
|
||||||
return config?.displayNameKey ? i18n.global.t(config.displayNameKey) : id;
|
return entry?.displayNameKey ? i18n.global.t(entry.displayNameKey) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展工厂的描述
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 描述
|
|
||||||
*/
|
|
||||||
export function getExtensionDescription(id: ExtensionID): string {
|
export function getExtensionDescription(id: ExtensionID): string {
|
||||||
const config = EXTENSION_CONFIGS[id as ExtensionID];
|
const entry = getRegistryEntry(id);
|
||||||
return config?.descriptionKey ? i18n.global.t(config.descriptionKey) : '';
|
return entry?.descriptionKey ? i18n.global.t(entry.descriptionKey) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getExtensionDefinition(id: ExtensionID): ExtensionDefinition | undefined {
|
||||||
* 获取扩展工厂实例
|
return getRegistryEntry(id)?.definition;
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 扩展工厂实例
|
|
||||||
*/
|
|
||||||
export function getExtensionFactory(id: ExtensionID): ExtensionFactory | undefined {
|
|
||||||
return EXTENSION_CONFIGS[id as ExtensionID]?.factory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取扩展的默认配置
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 默认配置对象
|
|
||||||
*/
|
|
||||||
export function getExtensionDefaultConfig(id: ExtensionID): any {
|
export function getExtensionDefaultConfig(id: ExtensionID): any {
|
||||||
const factory = getExtensionFactory(id);
|
const definition = getExtensionDefinition(id);
|
||||||
return factory?.getDefaultConfig() || {};
|
if (!definition) return {};
|
||||||
|
return cloneConfig(definition.defaultConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查扩展是否有配置项
|
|
||||||
* @param id 扩展ID
|
|
||||||
* @returns 是否有配置项
|
|
||||||
*/
|
|
||||||
export function hasExtensionConfig(id: ExtensionID): boolean {
|
export function hasExtensionConfig(id: ExtensionID): boolean {
|
||||||
const defaultConfig = getExtensionDefaultConfig(id);
|
return Object.keys(getExtensionDefaultConfig(id)).length > 0;
|
||||||
return Object.keys(defaultConfig).length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有可用扩展的ID列表
|
|
||||||
* @returns 扩展ID数组
|
|
||||||
*/
|
|
||||||
export function getAllExtensionIds(): ExtensionID[] {
|
export function getAllExtensionIds(): ExtensionID[] {
|
||||||
return Object.keys(EXTENSION_CONFIGS) as ExtensionID[];
|
return Object.keys(EXTENSION_REGISTRY) as RegisteredExtensionID[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloneConfig = (config: any) => {
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
return config.map(cloneConfig);
|
||||||
|
}
|
||||||
|
if (config && typeof config === 'object') {
|
||||||
|
return Object.keys(config).reduce((acc, key) => {
|
||||||
|
acc[key] = cloneConfig(config[key]);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import {Extension} from '@codemirror/state';
|
import {Extension} from '@codemirror/state';
|
||||||
import {EditorView} from '@codemirror/view';
|
import {EditorView} from '@codemirror/view';
|
||||||
import {useExtensionStore} from '@/stores/extensionStore';
|
import {useExtensionStore} from '@/stores/extensionStore';
|
||||||
import {ExtensionManager} from './extensionManager';
|
import {Manager} from './manager';
|
||||||
import {registerAllExtensions} from './extensions';
|
import {registerAllExtensions} from './extensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局扩展管理器实例
|
* 全局扩展管理器实例
|
||||||
*/
|
*/
|
||||||
const extensionManager = new ExtensionManager();
|
const extensionManager = new Manager();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步创建动态扩展
|
* 异步创建动态扩展
|
||||||
@@ -26,7 +26,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化扩展管理器配置
|
// 初始化扩展管理器配置
|
||||||
extensionManager.initializeExtensionsFromConfig(extensionStore.extensions);
|
extensionManager.initExtensions(extensionStore.extensions);
|
||||||
|
|
||||||
// 获取初始扩展配置
|
// 获取初始扩展配置
|
||||||
return extensionManager.getInitialExtensions();
|
return extensionManager.getInitialExtensions();
|
||||||
@@ -36,7 +36,7 @@ export const createDynamicExtensions = async (_documentId?: number): Promise<Ext
|
|||||||
* 获取扩展管理器实例
|
* 获取扩展管理器实例
|
||||||
* @returns 扩展管理器
|
* @returns 扩展管理器
|
||||||
*/
|
*/
|
||||||
export const getExtensionManager = (): ExtensionManager => {
|
export const getExtensionManager = (): Manager => {
|
||||||
return extensionManager;
|
return extensionManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,5 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export {ExtensionManager} from './extensionManager';
|
export {Manager} from './manager';
|
||||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {Compartment, Extension} from '@codemirror/state';
|
||||||
|
import {EditorView} from '@codemirror/view';
|
||||||
|
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
import {ExtensionDefinition, ExtensionState} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展管理器
|
||||||
|
* 负责注册、初始化与同步所有动态扩展
|
||||||
|
*/
|
||||||
|
export class Manager {
|
||||||
|
private extensionStates = new Map<ExtensionID, ExtensionState>();
|
||||||
|
private views = new Map<number, EditorView>();
|
||||||
|
|
||||||
|
registerExtension(id: ExtensionID, definition: ExtensionDefinition): void {
|
||||||
|
const existingState = this.extensionStates.get(id);
|
||||||
|
if (existingState) {
|
||||||
|
existingState.definition = definition;
|
||||||
|
if (existingState.config === undefined) {
|
||||||
|
existingState.config = this.cloneConfig(definition.defaultConfig ?? {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const compartment = new Compartment();
|
||||||
|
const defaultConfig = this.cloneConfig(definition.defaultConfig ?? {});
|
||||||
|
this.extensionStates.set(id, {
|
||||||
|
id,
|
||||||
|
definition,
|
||||||
|
config: defaultConfig,
|
||||||
|
enabled: false,
|
||||||
|
compartment,
|
||||||
|
extension: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initExtensions(extensionConfigs: ExtensionConfig[]): void {
|
||||||
|
for (const config of extensionConfigs) {
|
||||||
|
const state = this.extensionStates.get(config.id);
|
||||||
|
if (!state) continue;
|
||||||
|
const resolvedConfig = this.cloneConfig(config.config ?? state.definition.defaultConfig ?? {});
|
||||||
|
this.commitExtensionState(state, config.enabled, resolvedConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitialExtensions(): Extension[] {
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
for (const state of this.extensionStates.values()) {
|
||||||
|
extensions.push(state.compartment.of(state.extension));
|
||||||
|
}
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(view: EditorView, documentId: number): void {
|
||||||
|
this.views.set(documentId, view);
|
||||||
|
this.applyAllExtensionsToView(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExtension(id: ExtensionID, enabled: boolean, config?: any): void {
|
||||||
|
const state = this.extensionStates.get(id);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const resolvedConfig = this.resolveConfig(state, config);
|
||||||
|
this.commitExtensionState(state, enabled, resolvedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeView(documentId: number): void {
|
||||||
|
this.views.delete(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.views.clear();
|
||||||
|
this.extensionStates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveConfig(state: ExtensionState, config?: any): any {
|
||||||
|
if (config !== undefined) {
|
||||||
|
return this.cloneConfig(config);
|
||||||
|
}
|
||||||
|
if (state.config !== undefined) {
|
||||||
|
return this.cloneConfig(state.config);
|
||||||
|
}
|
||||||
|
return this.cloneConfig(state.definition.defaultConfig ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitExtensionState(state: ExtensionState, enabled: boolean, config: any): void {
|
||||||
|
try {
|
||||||
|
const runtimeExtension = enabled ? state.definition.create(config) : [];
|
||||||
|
state.enabled = enabled;
|
||||||
|
state.config = config;
|
||||||
|
state.extension = runtimeExtension;
|
||||||
|
this.applyExtensionToAllViews(state.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update extension ${state.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyExtensionToAllViews(id: ExtensionID): void {
|
||||||
|
const state = this.extensionStates.get(id);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
for (const [documentId, view] of this.views.entries()) {
|
||||||
|
try {
|
||||||
|
view.dispatch({effects: state.compartment.reconfigure(state.extension)});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to apply extension ${id} to document ${documentId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAllExtensionsToView(view: EditorView): void {
|
||||||
|
const effects: any[] = [];
|
||||||
|
for (const state of this.extensionStates.values()) {
|
||||||
|
effects.push(state.compartment.reconfigure(state.extension));
|
||||||
|
}
|
||||||
|
if (effects.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
view.dispatch({effects});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register extensions on view:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneConfig<T>(config: T): T {
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
return config.map(item => this.cloneConfig(item)) as unknown as T;
|
||||||
|
}
|
||||||
|
if (config && typeof config === 'object') {
|
||||||
|
return Object.keys(config as Record<string, any>).reduce((acc, key) => {
|
||||||
|
(acc as any)[key] = this.cloneConfig((config as Record<string, any>)[key]);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>) as T;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1 @@
|
|||||||
import {Compartment, Extension} from '@codemirror/state';
|
import {Compartment, Extension} from '@codemirror/state';␍␍import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';␍␍␍/**␍␍ * 扩展定义␍ * 标准化 create 方法和默认配置␍ */␍␍export interface ExtensionDefinition {␍ create(config: any): Extension␍ defaultConfig: Record<string, any>␍␍}␍␍␍␍/**␍ * 扩展运行时状态␍ */␍export interface ExtensionState {␍ id: ExtensionID␍ definition: ExtensionDefinition␍ config: any␍ enabled: boolean␍ compartment: Compartment␍ extension: Extension␍}␍␍
|
||||||
import {EditorView} from '@codemirror/view';
|
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
/**
|
|
||||||
* 扩展工厂接口
|
|
||||||
* 每个扩展需要实现此接口来创建和配置扩展
|
|
||||||
*/
|
|
||||||
export interface ExtensionFactory {
|
|
||||||
/**
|
|
||||||
* 创建扩展实例
|
|
||||||
* @param config 扩展配置
|
|
||||||
* @returns CodeMirror扩展
|
|
||||||
*/
|
|
||||||
create(config: any): Extension
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取默认配置
|
|
||||||
* @returns 默认配置对象
|
|
||||||
*/
|
|
||||||
getDefaultConfig(): any
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证配置
|
|
||||||
* @param config 配置对象
|
|
||||||
* @returns 是否有效
|
|
||||||
*/
|
|
||||||
validateConfig?(config: any): boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展状态
|
|
||||||
*/
|
|
||||||
export interface ExtensionState {
|
|
||||||
id: ExtensionID
|
|
||||||
factory: ExtensionFactory
|
|
||||||
config: any
|
|
||||||
enabled: boolean
|
|
||||||
compartment: Compartment
|
|
||||||
extension: Extension
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视图信息
|
|
||||||
*/
|
|
||||||
export interface EditorViewInfo {
|
|
||||||
view: EditorView
|
|
||||||
documentId: number
|
|
||||||
registered: boolean
|
|
||||||
}
|
|
||||||
@@ -319,7 +319,7 @@ const handlePickerClose = () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 1000px;
|
//max-width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-input {
|
.select-input {
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ const selectSshKeyFile = async () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 800px;
|
//max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的输入控件样式
|
// 统一的输入控件样式
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const handleAutoSaveDelayChange = async (event: Event) => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 800px;
|
//max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-control {
|
.number-control {
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ const updateExtensionConfig = async (extensionId: ExtensionID, configKey: string
|
|||||||
if (!extension) return;
|
if (!extension) return;
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
const updatedConfig = {...extension.config, [configKey]: value};
|
const updatedConfig = {...extension.config};
|
||||||
|
if (value === undefined) {
|
||||||
console.log(`[ExtensionsPage] 更新扩展 ${extensionId} 配置, ${configKey}=${value}`);
|
delete updatedConfig[configKey];
|
||||||
|
} else {
|
||||||
|
updatedConfig[configKey] = value;
|
||||||
|
}
|
||||||
// 使用editorStore的updateExtension方法更新,确保应用到所有编辑器实例
|
// 使用editorStore的updateExtension方法更新,确保应用到所有编辑器实例
|
||||||
await editorStore.updateExtension(extensionId, extension.enabled, updatedConfig);
|
await editorStore.updateExtension(extensionId, extension.enabled, updatedConfig);
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ const updateExtensionConfig = async (extensionId: ExtensionID, configKey: string
|
|||||||
// 重置扩展到默认配置
|
// 重置扩展到默认配置
|
||||||
const resetExtension = async (extensionId: ExtensionID) => {
|
const resetExtension = async (extensionId: ExtensionID) => {
|
||||||
try {
|
try {
|
||||||
// 重置到默认配置(后端)
|
// 重置到默认配置
|
||||||
await ExtensionService.ResetExtensionToDefault(extensionId);
|
await ExtensionService.ResetExtensionToDefault(extensionId);
|
||||||
|
|
||||||
// 重新加载扩展状态以获取最新配置
|
// 重新加载扩展状态以获取最新配置
|
||||||
@@ -92,63 +94,65 @@ const resetExtension = async (extensionId: ExtensionID) => {
|
|||||||
if (extension) {
|
if (extension) {
|
||||||
// 通过editorStore更新,确保所有视图都能同步
|
// 通过editorStore更新,确保所有视图都能同步
|
||||||
await editorStore.updateExtension(extensionId, extension.enabled, extension.config);
|
await editorStore.updateExtension(extensionId, extension.enabled, extension.config);
|
||||||
console.log(`[ExtensionsPage] 重置扩展 ${extensionId} 配置,同步应用到所有编辑器实例`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset extension:', error);
|
console.error('Failed to reset extension:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 配置项类型定义
|
const getConfigValue = (
|
||||||
type ConfigItemType = 'toggle' | 'number' | 'text' | 'select'
|
config: Record<string, any> | undefined,
|
||||||
|
configKey: string,
|
||||||
interface SelectOption {
|
defaultValue: any
|
||||||
value: any
|
) => {
|
||||||
label: string
|
if (config && Object.prototype.hasOwnProperty.call(config, configKey)) {
|
||||||
}
|
return config[configKey];
|
||||||
|
|
||||||
interface ConfigItemMeta {
|
|
||||||
type: ConfigItemType
|
|
||||||
options?: SelectOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只保留 select 类型的配置项元数据
|
|
||||||
const extensionConfigMeta: Partial<Record<ExtensionID, Record<string, ConfigItemMeta>>> = {
|
|
||||||
[ExtensionID.ExtensionMinimap]: {
|
|
||||||
displayText: {
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{value: 'characters', label: 'Characters'},
|
|
||||||
{value: 'blocks', label: 'Blocks'}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
showOverlay: {
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{value: 'always', label: 'Always'},
|
|
||||||
{value: 'mouse-over', label: 'Mouse Over'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取配置项类型
|
|
||||||
const getConfigItemType = (extensionId: ExtensionID, configKey: string, defaultValue: any): string => {
|
const formatConfigValue = (value: any): string => {
|
||||||
const meta = extensionConfigMeta[extensionId]?.[configKey];
|
if (value === undefined) return '';
|
||||||
if (meta?.type) {
|
try {
|
||||||
return meta.type;
|
const serialized = JSON.stringify(value);
|
||||||
|
return serialized ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to stringify config value', error);
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据默认值类型自动推断
|
|
||||||
if (typeof defaultValue === 'boolean') return 'toggle';
|
|
||||||
if (typeof defaultValue === 'number') return 'number';
|
|
||||||
return 'text';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取选择框的选项列表
|
|
||||||
const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOption[] => {
|
const handleConfigInput = async (
|
||||||
return extensionConfigMeta[extensionId]?.[configKey]?.options || [];
|
extensionId: ExtensionID,
|
||||||
|
configKey: string,
|
||||||
|
defaultValue: any,
|
||||||
|
event: Event
|
||||||
|
) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
const rawValue = target.value;
|
||||||
|
const trimmedValue = rawValue.trim();
|
||||||
|
if (!trimmedValue.length) {
|
||||||
|
await updateExtensionConfig(extensionId, configKey, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(trimmedValue);
|
||||||
|
await updateExtensionConfig(extensionId, configKey, parsedValue);
|
||||||
|
} catch (_error) {
|
||||||
|
const extension = extensionStore.extensions.find(ext => ext.id === extensionId);
|
||||||
|
const fallbackValue = getConfigValue(extension?.config, configKey, defaultValue);
|
||||||
|
target.value = formatConfigValue(fallbackValue);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -204,58 +208,28 @@ const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOp
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="config-table-wrapper">
|
||||||
|
<table class="config-table">
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
v-for="[configKey, configValue] in Object.entries(extension.defaultConfig)"
|
v-for="[configKey, configValue] in Object.entries(extension.defaultConfig)"
|
||||||
:key="configKey"
|
:key="configKey"
|
||||||
class="config-item"
|
|
||||||
>
|
>
|
||||||
<SettingItem
|
<th scope="row" class="config-table-key">
|
||||||
:title="configKey"
|
{{ configKey }}
|
||||||
>
|
</th>
|
||||||
<!-- 布尔值切换开关 -->
|
<td class="config-table-value">
|
||||||
<ToggleSwitch
|
|
||||||
v-if="getConfigItemType(extension.id, configKey, configValue) === 'toggle'"
|
|
||||||
:model-value="extension.config[configKey] ?? configValue"
|
|
||||||
@update:model-value="updateExtensionConfig(extension.id, configKey, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 数字输入框 -->
|
|
||||||
<input
|
<input
|
||||||
v-else-if="getConfigItemType(extension.id, configKey, configValue) === 'number'"
|
class="config-value-input"
|
||||||
type="number"
|
|
||||||
class="config-input"
|
|
||||||
:value="extension.config[configKey] ?? configValue"
|
|
||||||
:min="configKey === 'opacity' ? 0 : undefined"
|
|
||||||
:max="configKey === 'opacity' ? 1 : undefined"
|
|
||||||
:step="configKey === 'opacity' ? 0.1 : 1"
|
|
||||||
@input="updateExtensionConfig(extension.id, configKey, parseFloat(($event.target as HTMLInputElement).value))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 选择框 -->
|
|
||||||
<select
|
|
||||||
v-else-if="getConfigItemType(extension.id, configKey, configValue) === 'select'"
|
|
||||||
class="config-select"
|
|
||||||
:value="extension.config[configKey] ?? configValue"
|
|
||||||
@change="updateExtensionConfig(extension.id, configKey, ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="option in getSelectOptions(extension.id, configKey)"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- 文本输入框 -->
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
type="text"
|
type="text"
|
||||||
class="config-input"
|
:value="formatConfigValue(getConfigValue(extension.config, configKey, configValue))"
|
||||||
:value="extension.config[configKey] ?? configValue"
|
@change="handleConfigInput(extension.id, configKey, configValue, $event)"
|
||||||
@input="updateExtensionConfig(extension.id, configKey, ($event.target as HTMLInputElement).value)"
|
@keyup.enter.prevent="handleConfigInput(extension.id, configKey, configValue, $event)"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +239,7 @@ const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOp
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 1000px;
|
//max-width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension-item {
|
.extension-item {
|
||||||
@@ -361,37 +335,65 @@ const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item {
|
.config-table-wrapper {
|
||||||
&:not(:last-child) {
|
border: 1px solid var(--settings-input-border);
|
||||||
margin-bottom: 12px;
|
border-radius: 6px;
|
||||||
}
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
/* 配置项标题和描述字体大小 */
|
background-color: var(--settings-panel, var(--settings-input-bg));
|
||||||
:deep(.setting-item-title) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.setting-item-description) {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-input, .config-select {
|
.config-table {
|
||||||
min-width: 120px;
|
width: 100%;
|
||||||
padding: 4px 8px;
|
border-collapse: collapse;
|
||||||
border: 1px solid var(--settings-input-border);
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
}
|
||||||
background-color: var(--settings-input-bg);
|
|
||||||
color: var(--settings-text);
|
|
||||||
font-size: 11px;
|
|
||||||
|
|
||||||
&:focus {
|
.config-table tr + tr {
|
||||||
|
border-top: 1px solid var(--settings-input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-table th,
|
||||||
|
.config-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-table-key {
|
||||||
|
width: 36%;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--settings-text-secondary);
|
||||||
|
border-right: 1px solid var(--settings-input-border);
|
||||||
|
background-color: var(--settings-input-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-table-value {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--settings-text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value-input:hover {
|
||||||
|
border-color: var(--settings-hover-border, var(--settings-input-border));
|
||||||
|
background-color: var(--settings-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--settings-accent);
|
border-color: var(--settings-accent);
|
||||||
}
|
background-color: var(--settings-input-bg);
|
||||||
}
|
|
||||||
|
|
||||||
.config-select {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 800px;
|
//max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotkey-selector {
|
.hotkey-selector {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 800px;
|
//max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-bindings-container {
|
.key-bindings-container {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const clearAll = async () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 20px 0 20px 0;
|
//padding: 20px 0 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dev-description {
|
.dev-description {
|
||||||
|
|||||||
@@ -151,8 +151,7 @@ const currentVersion = computed(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 800px;
|
//max-width: 800px;
|
||||||
width: 100%; // 确保在小屏幕上也能占满可用空间
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-button {
|
.check-button {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ require (
|
|||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
golang.org/x/crypto v0.44.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/image v0.33.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
VERSION=1.5.3
|
VERSION=1.5.4
|
||||||
Reference in new issue
Block a user