Compare commits
42 Commits
build
...
fc5639d7bd
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5639d7bd | |||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e | |||
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f | |||
| 991a89147e | |||
| a08c0d8448 | |||
| 59db8dd177 | |||
| 29693f1baf | |||
| 5d6f157ae1 | |||
| afda3d5301 | |||
| 5d4ba757aa | |||
| d12d58b15a | |||
| 627c3dc71f | |||
| 26c7a3241c | |||
| 46c5e3dd1a | |||
|
|
92a6c6bfdb | ||
| 031aa49f9f | |||
| 1d7aee4cea | |||
| dec3ef5ef4 | |||
| d42f913250 | |||
| bae4e663fb | |||
| a17e060d16 | |||
| 71946965eb | |||
| d4cd22d234 | |||
| 05f2f7d46d | |||
| 9deb2744a9 | |||
| 6fac7c42d6 |
125
.github/workflows/build-release.yml
vendored
125
.github/workflows/build-release.yml
vendored
@@ -50,24 +50,27 @@ jobs:
|
|||||||
MATRIX='{"include":[]}'
|
MATRIX='{"include":[]}'
|
||||||
|
|
||||||
if [[ "$PLATFORMS" == *"windows"* ]]; then
|
if [[ "$PLATFORMS" == *"windows"* ]]; then
|
||||||
MATRIX=$(echo $MATRIX | jq '.include += [{"platform":"windows-latest","os":"windows","arch":"amd64","output_name":"voidraft-windows-amd64.exe","id":"windows"}]')
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"windows-latest","os":"windows","arch":"amd64","platform_dir":"windows","id":"windows"}]')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORMS" == *"linux"* ]]; then
|
if [[ "$PLATFORMS" == *"linux"* ]]; then
|
||||||
MATRIX=$(echo $MATRIX | jq '.include += [{"platform":"ubuntu-22.04","os":"linux","arch":"amd64","output_name":"voidraft-linux-amd64","id":"linux"}]')
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"ubuntu-22.04","os":"linux","arch":"amd64","platform_dir":"linux","id":"linux"}]')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORMS" == *"macos-intel"* ]]; then
|
if [[ "$PLATFORMS" == *"macos-intel"* ]]; then
|
||||||
MATRIX=$(echo $MATRIX | jq '.include += [{"platform":"macos-latest","os":"darwin","arch":"amd64","output_name":"voidraft-darwin-amd64","id":"macos-intel"}]')
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"amd64","platform_dir":"darwin","id":"macos-intel"}]')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORMS" == *"macos-arm"* ]]; then
|
if [[ "$PLATFORMS" == *"macos-arm"* ]]; then
|
||||||
MATRIX=$(echo $MATRIX | jq '.include += [{"platform":"macos-latest","os":"darwin","arch":"arm64","output_name":"voidraft-darwin-arm64","id":"macos-arm"}]')
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"arm64","platform_dir":"darwin","id":"macos-arm"}]')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
|
# 使用 -c 确保输出紧凑的单行 JSON,符合 GitHub Actions 输出格式
|
||||||
|
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 输出美化的 JSON 用于日志查看
|
||||||
echo "生成的矩阵:"
|
echo "生成的矩阵:"
|
||||||
echo $MATRIX | jq .
|
echo "$MATRIX" | jq .
|
||||||
|
|
||||||
- name: 检查是否创建 Release
|
- name: 检查是否创建 Release
|
||||||
id: check-release
|
id: check-release
|
||||||
@@ -113,17 +116,41 @@ jobs:
|
|||||||
if: matrix.os == 'linux'
|
if: matrix.os == 'linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
# Wails 3 + 项目特定依赖
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libwebkit2gtk-4.0-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
pkg-config
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libx11-dev \
|
||||||
|
rpm \
|
||||||
|
fuse \
|
||||||
|
file
|
||||||
|
|
||||||
|
# 依赖说明:
|
||||||
|
# - build-essential: C/C++ 编译工具链
|
||||||
|
# - pkg-config: 包配置工具
|
||||||
|
# - libgtk-3-dev: GTK3 GUI 框架
|
||||||
|
# - libwebkit2gtk-4.1-dev: WebKit2GTK 4.1 (Wails 3 要求)
|
||||||
|
# - libayatana-appindicator3-dev: 系统托盘支持
|
||||||
|
# - librsvg2-dev: SVG 图标支持
|
||||||
|
# - patchelf: 修改 ELF 二进制文件
|
||||||
|
# - libx11-dev: X11 库 (热键服务依赖)
|
||||||
|
# - rpm: RPM 打包工具
|
||||||
|
# - fuse: AppImage 运行依赖
|
||||||
|
# - file: 文件类型检测工具
|
||||||
|
|
||||||
# Windows 平台依赖(GitHub Actions 的 Windows runner 已包含 MinGW)
|
# Windows 平台依赖
|
||||||
- name: 设置 Windows 构建环境
|
- name: 设置 Windows 构建环境
|
||||||
if: matrix.os == 'windows'
|
if: matrix.os == 'windows'
|
||||||
run: |
|
run: |
|
||||||
echo "Windows runner 已包含构建工具"
|
# 安装 NSIS (用于创建安装程序)
|
||||||
|
choco install nsis -y
|
||||||
|
# 将 NSIS 添加到 PATH
|
||||||
|
echo "C:\Program Files (x86)\NSIS" >> $GITHUB_PATH
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# macOS 平台依赖
|
# macOS 平台依赖
|
||||||
@@ -147,52 +174,64 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
# 构建 Wails 应用
|
# 使用 Wails Task 构建和打包应用
|
||||||
- name: 构建 Wails 应用
|
- name: 构建和打包 Wails 应用
|
||||||
run: |
|
run: wails3 task ${{ matrix.id }}:package PRODUCTION=true ARCH=${{ matrix.arch }}
|
||||||
wails3 build -platform ${{ matrix.os }}/${{ matrix.arch }}
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
APP_NAME: voidraft
|
||||||
|
BIN_DIR: bin
|
||||||
|
ROOT_DIR: .
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# 查找构建产物
|
# 整理构建产物
|
||||||
- name: 查找构建产物
|
- name: 整理构建产物
|
||||||
id: find_binary
|
id: organize_artifacts
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
|
echo "=== 构建产物列表 ==="
|
||||||
|
ls -lhR bin/ || echo "bin 目录不存在"
|
||||||
|
|
||||||
|
# 创建输出目录
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# 根据平台复制产物
|
||||||
if [ "${{ matrix.os }}" = "windows" ]; then
|
if [ "${{ matrix.os }}" = "windows" ]; then
|
||||||
BINARY=$(find build/bin -name "*.exe" -type f | head -n 1)
|
echo "Windows 平台:查找 NSIS 安装程序"
|
||||||
|
find . -name "*.exe" -type f
|
||||||
|
cp bin/*.exe artifacts/ 2>/dev/null || echo "未找到 .exe 文件"
|
||||||
|
|
||||||
|
elif [ "${{ matrix.os }}" = "linux" ]; then
|
||||||
|
echo "Linux 平台:查找 AppImage, deb, rpm, archlinux 包"
|
||||||
|
find bin -type f
|
||||||
|
cp bin/*.AppImage artifacts/ 2>/dev/null || echo "未找到 AppImage"
|
||||||
|
cp bin/*.deb artifacts/ 2>/dev/null || echo "未找到 deb"
|
||||||
|
cp bin/*.rpm artifacts/ 2>/dev/null || echo "未找到 rpm"
|
||||||
|
cp bin/*.pkg.tar.zst artifacts/ 2>/dev/null || echo "未找到 archlinux 包"
|
||||||
|
|
||||||
elif [ "${{ matrix.os }}" = "darwin" ]; then
|
elif [ "${{ matrix.os }}" = "darwin" ]; then
|
||||||
# macOS 可能生成 .app 包或二进制文件
|
echo "macOS 平台:查找 .app bundle"
|
||||||
BINARY=$(find build/bin -type f \( -name "*.app" -o ! -name ".*" \) | head -n 1)
|
find bin -name "*.app" -type d
|
||||||
else
|
# macOS: .app bundle,打包成 zip
|
||||||
BINARY=$(find build/bin -type f ! -name ".*" | head -n 1)
|
if [ -d "bin/voidraft.app" ]; then
|
||||||
fi
|
cd bin
|
||||||
echo "binary_path=$BINARY" >> $GITHUB_OUTPUT
|
zip -r ../artifacts/voidraft-darwin-${{ matrix.arch }}.app.zip voidraft.app
|
||||||
echo "找到的二进制文件: $BINARY"
|
cd ..
|
||||||
|
|
||||||
# 重命名构建产物
|
|
||||||
- name: 重命名构建产物
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
BINARY_PATH="${{ steps.find_binary.outputs.binary_path }}"
|
|
||||||
if [ -n "$BINARY_PATH" ]; then
|
|
||||||
# 对于 macOS .app 包,打包为 zip
|
|
||||||
if [[ "$BINARY_PATH" == *.app ]]; then
|
|
||||||
cd "$(dirname "$BINARY_PATH")"
|
|
||||||
zip -r "${{ matrix.output_name }}.zip" "$(basename "$BINARY_PATH")"
|
|
||||||
echo "ARTIFACT_PATH=$(pwd)/${{ matrix.output_name }}.zip" >> $GITHUB_ENV
|
|
||||||
else
|
else
|
||||||
cp "$BINARY_PATH" "${{ matrix.output_name }}"
|
echo "未找到 .app bundle"
|
||||||
echo "ARTIFACT_PATH=$(pwd)/${{ matrix.output_name }}" >> $GITHUB_ENV
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "=== 最终产物 ==="
|
||||||
|
ls -lh artifacts/ || echo "artifacts 目录为空"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
# 上传构建产物到 Artifacts
|
# 上传构建产物到 Artifacts
|
||||||
- name: 上传构建产物
|
- name: 上传构建产物
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.output_name }}
|
name: voidraft-${{ matrix.id }}
|
||||||
path: ${{ env.ARTIFACT_PATH }}
|
path: artifacts/*
|
||||||
if-no-files-found: error
|
if-no-files-found: warn
|
||||||
|
|
||||||
# 创建 GitHub Release 并上传所有构建产物
|
# 创建 GitHub Release 并上传所有构建产物
|
||||||
release:
|
release:
|
||||||
|
|||||||
12
Taskfile.yml
12
Taskfile.yml
@@ -12,25 +12,13 @@ vars:
|
|||||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
version:
|
|
||||||
summary: Generate version information
|
|
||||||
cmds:
|
|
||||||
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
|
|
||||||
sources:
|
|
||||||
- scripts/version.bat
|
|
||||||
- scripts/version.sh
|
|
||||||
generates:
|
|
||||||
- version.txt
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
summary: Builds the application
|
summary: Builds the application
|
||||||
deps: [version]
|
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:build"
|
- task: "{{OS}}:build"
|
||||||
|
|
||||||
package:
|
package:
|
||||||
summary: Packages a production build of the application
|
summary: Packages a production build of the application
|
||||||
deps: [version]
|
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:package"
|
- task: "{{OS}}:package"
|
||||||
|
|
||||||
|
|||||||
293
build/COMMANDS.md
Normal file
293
build/COMMANDS.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Wails 3 命令参考表
|
||||||
|
|
||||||
|
本文档列出了 Voidraft 项目中使用的所有 Wails 3 命令和参数。
|
||||||
|
|
||||||
|
## 📋 命令总览
|
||||||
|
|
||||||
|
| 类别 | 命令 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 生成工具 | `wails3 generate` | 生成项目所需的各种资源文件 |
|
||||||
|
| 打包工具 | `wails3 tool package` | 使用 nfpm 打包应用程序 |
|
||||||
|
| 任务执行 | `wails3 task` | 执行 Taskfile.yml 中定义的任务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 详细命令参数
|
||||||
|
|
||||||
|
### 1. `wails3 generate bindings`
|
||||||
|
**生成 TypeScript 绑定文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-f` | 构建标志 | `''` (空字符串) |
|
||||||
|
| `-clean` | 清理旧绑定 | `true` |
|
||||||
|
| `-ts` | 生成 TypeScript | 无需值 |
|
||||||
|
|
||||||
|
**使用位置:** `build/Taskfile.yml:53`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate bindings -f '' -clean=true -ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `wails3 generate icons`
|
||||||
|
**从单个图片生成多平台图标**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-input` | 输入图片路径 | `appicon.png` |
|
||||||
|
| `-macfilename` | macOS 图标输出路径 | `darwin/icons.icns` |
|
||||||
|
| `-windowsfilename` | Windows 图标输出路径 | `windows/icons.ico` |
|
||||||
|
|
||||||
|
**使用位置:** `build/Taskfile.yml:64`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate icons \
|
||||||
|
-input appicon.png \
|
||||||
|
-macfilename darwin/icons.icns \
|
||||||
|
-windowsfilename windows/icons.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `wails3 generate syso`
|
||||||
|
**生成 Windows .syso 资源文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-arch` | 目标架构 | `amd64` / `arm64` |
|
||||||
|
| `-icon` | 图标文件 | `windows/icon.ico` |
|
||||||
|
| `-manifest` | 清单文件 | `windows/wails.exe.manifest` |
|
||||||
|
| `-info` | 应用信息 JSON | `windows/info.json` |
|
||||||
|
| `-out` | 输出文件路径 | `../wails_windows_amd64.syso` |
|
||||||
|
|
||||||
|
**使用位置:** `build/windows/Taskfile.yml:42`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate syso \
|
||||||
|
-arch amd64 \
|
||||||
|
-icon windows/icon.ico \
|
||||||
|
-manifest windows/wails.exe.manifest \
|
||||||
|
-info windows/info.json \
|
||||||
|
-out ../wails_windows_amd64.syso
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `wails3 generate webview2bootstrapper`
|
||||||
|
**生成 Windows WebView2 引导程序**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-dir` | 输出目录 | `build/windows/nsis` |
|
||||||
|
|
||||||
|
**使用位置:** `build/windows/Taskfile.yml:55`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate webview2bootstrapper -dir "build/windows/nsis"
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明:** 下载 Microsoft Edge WebView2 运行时安装程序,用于 NSIS 打包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `wails3 generate .desktop`
|
||||||
|
**生成 Linux .desktop 桌面文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 应用名称 | `voidraft` |
|
||||||
|
| `-exec` | 可执行文件名 | `voidraft` |
|
||||||
|
| `-icon` | 图标名称 | `appicon` |
|
||||||
|
| `-outputfile` | 输出文件路径 | `build/linux/voidraft.desktop` |
|
||||||
|
| `-categories` | 应用分类 | `Development;` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:107`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate .desktop \
|
||||||
|
-name "voidraft" \
|
||||||
|
-exec "voidraft" \
|
||||||
|
-icon "appicon" \
|
||||||
|
-outputfile build/linux/voidraft.desktop \
|
||||||
|
-categories "Development;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `wails3 generate appimage`
|
||||||
|
**生成 Linux AppImage 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-binary` | 二进制文件名 | `voidraft` |
|
||||||
|
| `-icon` | 图标文件路径 | `../../appicon.png` |
|
||||||
|
| `-desktopfile` | .desktop 文件路径 | `../voidraft.desktop` |
|
||||||
|
| `-outputdir` | 输出目录 | `../../../bin` |
|
||||||
|
| `-builddir` | 构建临时目录 | `build/linux/appimage/build` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:49`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate appimage \
|
||||||
|
-binary voidraft \
|
||||||
|
-icon ../../appicon.png \
|
||||||
|
-desktopfile ../voidraft.desktop \
|
||||||
|
-outputdir ../../../bin \
|
||||||
|
-builddir build/linux/appimage/build
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明:** 自动下载 linuxdeploy 工具并创建 AppImage。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `wails3 tool package` (deb)
|
||||||
|
**创建 Debian/Ubuntu .deb 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `deb` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:90`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format deb \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. `wails3 tool package` (rpm)
|
||||||
|
**创建 RedHat/CentOS/Fedora .rpm 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `rpm` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:95`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format rpm \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `wails3 tool package` (archlinux)
|
||||||
|
**创建 Arch Linux .pkg.tar.zst 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `archlinux` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:100`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format archlinux \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `wails3 task`
|
||||||
|
**执行 Taskfile.yml 中定义的任务**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `[taskname]` | 任务名称 | `build`, `package`, `run` |
|
||||||
|
| `[VAR=value]` | 变量赋值 | `PRODUCTION=true`, `ARCH=amd64` |
|
||||||
|
|
||||||
|
**常用任务:**
|
||||||
|
|
||||||
|
| 任务 | 说明 | 命令 |
|
||||||
|
|------|------|------|
|
||||||
|
| `build` | 构建应用 | `wails3 task build PRODUCTION=true` |
|
||||||
|
| `package` | 打包应用 | `wails3 task package` |
|
||||||
|
| `run` | 运行应用 | `wails3 task run` |
|
||||||
|
|
||||||
|
**使用位置:** `build/config.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 平台对应命令表
|
||||||
|
|
||||||
|
| 平台 | 主要命令 | 产物 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **Windows** | `generate syso`<br>`generate webview2bootstrapper` | `.syso` 资源文件<br>NSIS 安装程序 |
|
||||||
|
| **Linux** | `generate .desktop`<br>`generate appimage`<br>`tool package -format deb/rpm/archlinux` | `.desktop` 文件<br>`.AppImage`<br>`.deb` / `.rpm` / `.pkg.tar.zst` |
|
||||||
|
| **macOS** | `generate icons` | `.icns` 图标<br>`.app` 应用包 |
|
||||||
|
| **通用** | `generate bindings`<br>`generate icons` | TypeScript 绑定<br>多平台图标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速参考
|
||||||
|
|
||||||
|
### 完整构建流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成绑定和图标
|
||||||
|
wails3 task common:generate:bindings
|
||||||
|
wails3 task common:generate:icons
|
||||||
|
|
||||||
|
# 2. 构建前端
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 3. 构建应用(各平台)
|
||||||
|
cd build/windows && wails3 task build PRODUCTION=true # Windows
|
||||||
|
cd build/linux && wails3 task build PRODUCTION=true # Linux
|
||||||
|
cd build/darwin && wails3 task build PRODUCTION=true # macOS
|
||||||
|
|
||||||
|
# 4. 打包应用(各平台)
|
||||||
|
cd build/windows && wails3 task package # NSIS 安装程序
|
||||||
|
cd build/linux && wails3 task package # AppImage + deb + rpm + archlinux
|
||||||
|
cd build/darwin && wails3 task package # .app bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 注意事项
|
||||||
|
|
||||||
|
1. **变量传递:** Task 命令支持通过 `VAR=value` 格式传递变量
|
||||||
|
2. **路径问题:** 相对路径基于 Taskfile.yml 所在目录
|
||||||
|
3. **依赖顺序:** 某些任务有依赖关系(通过 `deps:` 定义)
|
||||||
|
4. **环境变量:** 使用 `env:` 定义的环境变量会自动设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [Wails 3 官方文档](https://v3alpha.wails.io/)
|
||||||
|
- [Taskfile 语法](https://taskfile.dev/)
|
||||||
|
- [nfpm 打包工具](https://nfpm.goreleaser.com/)
|
||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.9 KiB |
@@ -1170,7 +1170,7 @@ export class Theme {
|
|||||||
this["type"] = ("" as ThemeType);
|
this["type"] = ("" as ThemeType);
|
||||||
}
|
}
|
||||||
if (!("colors" in $$source)) {
|
if (!("colors" in $$source)) {
|
||||||
this["colors"] = (new ThemeColorConfig());
|
this["colors"] = ({} as ThemeColorConfig);
|
||||||
}
|
}
|
||||||
if (!("isDefault" in $$source)) {
|
if (!("isDefault" in $$source)) {
|
||||||
this["isDefault"] = false;
|
this["isDefault"] = false;
|
||||||
@@ -1199,303 +1199,9 @@ export class Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
|
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
|
||||||
*/
|
*/
|
||||||
export class ThemeColorConfig {
|
export type ThemeColorConfig = { [_: string]: any };
|
||||||
/**
|
|
||||||
* 主题基本信息
|
|
||||||
* 主题名称
|
|
||||||
*/
|
|
||||||
"name": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为深色主题
|
|
||||||
*/
|
|
||||||
"dark": boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 基础色调
|
|
||||||
* 主背景色
|
|
||||||
*/
|
|
||||||
"background": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 次要背景色(用于代码块交替背景)
|
|
||||||
*/
|
|
||||||
"backgroundSecondary": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 面板背景
|
|
||||||
*/
|
|
||||||
"surface": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 下拉菜单背景
|
|
||||||
*/
|
|
||||||
"dropdownBackground": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 下拉菜单边框
|
|
||||||
*/
|
|
||||||
"dropdownBorder": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文本颜色
|
|
||||||
* 主文本色
|
|
||||||
*/
|
|
||||||
"foreground": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 次要文本色
|
|
||||||
*/
|
|
||||||
"foregroundSecondary": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注释色
|
|
||||||
*/
|
|
||||||
"comment": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语法高亮色 - 核心
|
|
||||||
* 关键字
|
|
||||||
*/
|
|
||||||
"keyword": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 字符串
|
|
||||||
*/
|
|
||||||
"string": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 函数名
|
|
||||||
*/
|
|
||||||
"function": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 数字
|
|
||||||
*/
|
|
||||||
"number": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 操作符
|
|
||||||
*/
|
|
||||||
"operator": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 变量
|
|
||||||
*/
|
|
||||||
"variable": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 类型
|
|
||||||
*/
|
|
||||||
"type": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语法高亮色 - 扩展
|
|
||||||
* 常量
|
|
||||||
*/
|
|
||||||
"constant": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 存储类型(如 static, const)
|
|
||||||
*/
|
|
||||||
"storage": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 参数
|
|
||||||
*/
|
|
||||||
"parameter": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 类名
|
|
||||||
*/
|
|
||||||
"class": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标题(Markdown等)
|
|
||||||
*/
|
|
||||||
"heading": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无效内容/错误
|
|
||||||
*/
|
|
||||||
"invalid": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 正则表达式
|
|
||||||
*/
|
|
||||||
"regexp": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 界面元素
|
|
||||||
* 光标
|
|
||||||
*/
|
|
||||||
"cursor": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选中背景
|
|
||||||
*/
|
|
||||||
"selection": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 失焦选中背景
|
|
||||||
*/
|
|
||||||
"selectionBlur": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前行高亮
|
|
||||||
*/
|
|
||||||
"activeLine": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 行号
|
|
||||||
*/
|
|
||||||
"lineNumber": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 活动行号颜色
|
|
||||||
*/
|
|
||||||
"activeLineNumber": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 边框和分割线
|
|
||||||
* 边框色
|
|
||||||
*/
|
|
||||||
"borderColor": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 浅色边框
|
|
||||||
*/
|
|
||||||
"borderLight": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索和匹配
|
|
||||||
* 搜索匹配
|
|
||||||
*/
|
|
||||||
"searchMatch": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 匹配括号
|
|
||||||
*/
|
|
||||||
"matchingBracket": string;
|
|
||||||
|
|
||||||
/** Creates a new ThemeColorConfig instance. */
|
|
||||||
constructor($$source: Partial<ThemeColorConfig> = {}) {
|
|
||||||
if (!("name" in $$source)) {
|
|
||||||
this["name"] = "";
|
|
||||||
}
|
|
||||||
if (!("dark" in $$source)) {
|
|
||||||
this["dark"] = false;
|
|
||||||
}
|
|
||||||
if (!("background" in $$source)) {
|
|
||||||
this["background"] = "";
|
|
||||||
}
|
|
||||||
if (!("backgroundSecondary" in $$source)) {
|
|
||||||
this["backgroundSecondary"] = "";
|
|
||||||
}
|
|
||||||
if (!("surface" in $$source)) {
|
|
||||||
this["surface"] = "";
|
|
||||||
}
|
|
||||||
if (!("dropdownBackground" in $$source)) {
|
|
||||||
this["dropdownBackground"] = "";
|
|
||||||
}
|
|
||||||
if (!("dropdownBorder" in $$source)) {
|
|
||||||
this["dropdownBorder"] = "";
|
|
||||||
}
|
|
||||||
if (!("foreground" in $$source)) {
|
|
||||||
this["foreground"] = "";
|
|
||||||
}
|
|
||||||
if (!("foregroundSecondary" in $$source)) {
|
|
||||||
this["foregroundSecondary"] = "";
|
|
||||||
}
|
|
||||||
if (!("comment" in $$source)) {
|
|
||||||
this["comment"] = "";
|
|
||||||
}
|
|
||||||
if (!("keyword" in $$source)) {
|
|
||||||
this["keyword"] = "";
|
|
||||||
}
|
|
||||||
if (!("string" in $$source)) {
|
|
||||||
this["string"] = "";
|
|
||||||
}
|
|
||||||
if (!("function" in $$source)) {
|
|
||||||
this["function"] = "";
|
|
||||||
}
|
|
||||||
if (!("number" in $$source)) {
|
|
||||||
this["number"] = "";
|
|
||||||
}
|
|
||||||
if (!("operator" in $$source)) {
|
|
||||||
this["operator"] = "";
|
|
||||||
}
|
|
||||||
if (!("variable" in $$source)) {
|
|
||||||
this["variable"] = "";
|
|
||||||
}
|
|
||||||
if (!("type" in $$source)) {
|
|
||||||
this["type"] = "";
|
|
||||||
}
|
|
||||||
if (!("constant" in $$source)) {
|
|
||||||
this["constant"] = "";
|
|
||||||
}
|
|
||||||
if (!("storage" in $$source)) {
|
|
||||||
this["storage"] = "";
|
|
||||||
}
|
|
||||||
if (!("parameter" in $$source)) {
|
|
||||||
this["parameter"] = "";
|
|
||||||
}
|
|
||||||
if (!("class" in $$source)) {
|
|
||||||
this["class"] = "";
|
|
||||||
}
|
|
||||||
if (!("heading" in $$source)) {
|
|
||||||
this["heading"] = "";
|
|
||||||
}
|
|
||||||
if (!("invalid" in $$source)) {
|
|
||||||
this["invalid"] = "";
|
|
||||||
}
|
|
||||||
if (!("regexp" in $$source)) {
|
|
||||||
this["regexp"] = "";
|
|
||||||
}
|
|
||||||
if (!("cursor" in $$source)) {
|
|
||||||
this["cursor"] = "";
|
|
||||||
}
|
|
||||||
if (!("selection" in $$source)) {
|
|
||||||
this["selection"] = "";
|
|
||||||
}
|
|
||||||
if (!("selectionBlur" in $$source)) {
|
|
||||||
this["selectionBlur"] = "";
|
|
||||||
}
|
|
||||||
if (!("activeLine" in $$source)) {
|
|
||||||
this["activeLine"] = "";
|
|
||||||
}
|
|
||||||
if (!("lineNumber" in $$source)) {
|
|
||||||
this["lineNumber"] = "";
|
|
||||||
}
|
|
||||||
if (!("activeLineNumber" in $$source)) {
|
|
||||||
this["activeLineNumber"] = "";
|
|
||||||
}
|
|
||||||
if (!("borderColor" in $$source)) {
|
|
||||||
this["borderColor"] = "";
|
|
||||||
}
|
|
||||||
if (!("borderLight" in $$source)) {
|
|
||||||
this["borderLight"] = "";
|
|
||||||
}
|
|
||||||
if (!("searchMatch" in $$source)) {
|
|
||||||
this["searchMatch"] = "";
|
|
||||||
}
|
|
||||||
if (!("matchingBracket" in $$source)) {
|
|
||||||
this["matchingBracket"] = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new ThemeColorConfig instance from a string or object.
|
|
||||||
*/
|
|
||||||
static createFrom($$source: any = {}): ThemeColorConfig {
|
|
||||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
|
||||||
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeType 主题类型枚举
|
* ThemeType 主题类型枚举
|
||||||
@@ -1636,6 +1342,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
|||||||
});
|
});
|
||||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||||
const $$createType8 = HotkeyCombo.createFrom;
|
const $$createType8 = HotkeyCombo.createFrom;
|
||||||
const $$createType9 = ThemeColorConfig.createFrom;
|
var $$createType9 = (function $$initCreateType9(...args): any {
|
||||||
|
if ($$createType9 === $$initCreateType9) {
|
||||||
|
$$createType9 = $$createType7;
|
||||||
|
}
|
||||||
|
return $$createType9(...args);
|
||||||
|
});
|
||||||
const $$createType10 = GithubConfig.createFrom;
|
const $$createType10 = GithubConfig.createFrom;
|
||||||
const $$createType11 = GiteaConfig.createFrom;
|
const $$createType11 = GiteaConfig.createFrom;
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* OnDataPathChanged handles data path changes
|
|
||||||
*/
|
|
||||||
export function OnDataPathChanged(): Promise<void> & { cancel(): void } {
|
|
||||||
let $resultPromise = $Call.ByID(3652863491) as any;
|
|
||||||
return $resultPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RegisterModel 注册模型与表的映射关系
|
* RegisterModel 注册模型与表的映射关系
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
|
|||||||
import * as models$0 from "../models/models.js";
|
import * as models$0 from "../models/models.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GetAllThemes 获取所有主题
|
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
|
||||||
*/
|
*/
|
||||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
let $resultPromise = $Call.ByID(1938954770, name) as any;
|
||||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
|
||||||
return $$createType2($result);
|
|
||||||
}) as any;
|
|
||||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
|
||||||
return $typingPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GetThemeByID 根据ID或名称获取主题
|
|
||||||
* 如果 id > 0,按ID查询;如果 id = 0,按名称查询
|
|
||||||
*/
|
|
||||||
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
|
|
||||||
let $resultPromise = $Call.ByID(127385338, id, name) as any;
|
|
||||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||||
return $$createType1($result);
|
return $$createType1($result);
|
||||||
}) as any;
|
}) as any;
|
||||||
@@ -43,10 +30,10 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResetTheme 重置主题为预设配置
|
* ResetTheme 删除指定主题的覆盖配置
|
||||||
*/
|
*/
|
||||||
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
|
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
let $resultPromise = $Call.ByID(1806334457, name) as any;
|
||||||
return $resultPromise;
|
return $resultPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +46,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServiceStartup 服务启动时初始化
|
* ServiceStartup 服务启动
|
||||||
*/
|
*/
|
||||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
||||||
@@ -67,14 +54,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UpdateTheme 更新主题
|
* UpdateTheme 保存或更新主题覆盖
|
||||||
*/
|
*/
|
||||||
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
|
||||||
return $resultPromise;
|
return $resultPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = models$0.Theme.createFrom;
|
const $$createType0 = models$0.Theme.createFrom;
|
||||||
const $$createType1 = $Create.Nullable($$createType0);
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
const $$createType2 = $Create.Array($$createType1);
|
|
||||||
|
|||||||
2148
frontend/package-lock.json
generated
2148
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
|||||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.19.1",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.0",
|
"@codemirror/commands": "^6.10.0",
|
||||||
"@codemirror/lang-angular": "^0.1.4",
|
"@codemirror/lang-angular": "^0.1.4",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
@@ -47,17 +47,18 @@
|
|||||||
"@codemirror/language": "^6.11.3",
|
"@codemirror/language": "^6.11.3",
|
||||||
"@codemirror/language-data": "^6.5.2",
|
"@codemirror/language-data": "^6.5.2",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
"@codemirror/lint": "^6.9.1",
|
"@codemirror/lint": "^6.9.2",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.6",
|
"@codemirror/view": "^6.38.8",
|
||||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@lezer/lr": "^1.4.2",
|
"@lezer/lr": "^1.4.4",
|
||||||
"@prettier/plugin-xml": "^3.4.2",
|
"@prettier/plugin-xml": "^3.4.2",
|
||||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^1.0.0",
|
"@toml-tools/parser": "^1.0.0",
|
||||||
|
"@types/katex": "^0.16.7",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
@@ -65,38 +66,42 @@
|
|||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
"jsox": "^1.2.123",
|
"katex": "^0.16.25",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
|
"marked": "^17.0.1",
|
||||||
|
"mermaid": "^11.12.1",
|
||||||
"php-parser": "^3.2.5",
|
"php-parser": "^3.2.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.7.2",
|
||||||
"remarkable": "^2.0.1",
|
"sass": "^1.94.2",
|
||||||
"sass": "^1.93.3",
|
"vue": "^3.5.25",
|
||||||
"vue": "^3.5.22",
|
"vue-i18n": "^11.2.2",
|
||||||
"vue-i18n": "^11.1.12",
|
|
||||||
"vue-pick-colors": "^1.8.0",
|
"vue-pick-colors": "^1.8.0",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.1",
|
||||||
"@lezer/generator": "^1.8.0",
|
"@lezer/generator": "^1.8.0",
|
||||||
"@types/node": "^24.9.2",
|
"@types/node": "^24.10.1",
|
||||||
"@types/remarkable": "^2.0.8",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"@wailsio/runtime": "latest",
|
"@wailsio/runtime": "latest",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-vue": "^10.5.1",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"happy-dom": "^20.0.11",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.48.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.1.12",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"vite-plugin-node-polyfills": "^0.24.0",
|
"vite-plugin-node-polyfills": "^0.24.0",
|
||||||
"vitepress": "^2.0.0-alpha.12",
|
"vitepress": "^2.0.0-alpha.12",
|
||||||
"vitest": "^4.0.6",
|
"vitest": "^4.0.14",
|
||||||
"vue-eslint-parser": "^10.2.0",
|
"vue-eslint-parser": "^10.2.0",
|
||||||
"vue-tsc": "^3.1.2"
|
"vue-tsc": "^3.1.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
鸿蒙字体压缩工具
|
|
||||||
使用 fonttools 库压缩 TTF 字体文件,减小文件大小
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
def check_dependencies():
|
|
||||||
"""检查必要的依赖是否已安装"""
|
|
||||||
missing_packages = []
|
|
||||||
|
|
||||||
# 检查 fonttools
|
|
||||||
try:
|
|
||||||
import fontTools
|
|
||||||
except ImportError:
|
|
||||||
missing_packages.append('fonttools')
|
|
||||||
|
|
||||||
# 检查 brotli
|
|
||||||
try:
|
|
||||||
import brotli
|
|
||||||
except ImportError:
|
|
||||||
missing_packages.append('brotli')
|
|
||||||
|
|
||||||
# 检查 pyftsubset 命令是否可用
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
|
||||||
if result.returncode != 0:
|
|
||||||
missing_packages.append('fonttools[subset]')
|
|
||||||
except FileNotFoundError:
|
|
||||||
if 'fonttools' not in missing_packages:
|
|
||||||
missing_packages.append('fonttools[subset]')
|
|
||||||
|
|
||||||
if missing_packages:
|
|
||||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
|
||||||
print("请运行以下命令安装:")
|
|
||||||
print(f"pip install {' '.join(missing_packages)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_file_size(file_path: str) -> int:
|
|
||||||
"""获取文件大小(字节)"""
|
|
||||||
return os.path.getsize(file_path)
|
|
||||||
|
|
||||||
def format_file_size(size_bytes: int) -> str:
|
|
||||||
"""格式化文件大小显示"""
|
|
||||||
if size_bytes < 1024:
|
|
||||||
return f"{size_bytes} B"
|
|
||||||
elif size_bytes < 1024 * 1024:
|
|
||||||
return f"{size_bytes / 1024:.2f} KB"
|
|
||||||
else:
|
|
||||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
|
||||||
|
|
||||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
|
||||||
"""
|
|
||||||
压缩单个字体文件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_path: 输入字体文件路径
|
|
||||||
output_path: 输出字体文件路径
|
|
||||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 压缩是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 基础压缩参数
|
|
||||||
base_args = [
|
|
||||||
"pyftsubset", input_path,
|
|
||||||
"--output-file=" + output_path,
|
|
||||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
|
||||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
|
||||||
]
|
|
||||||
|
|
||||||
# 根据压缩级别设置不同的参数
|
|
||||||
if compression_level == "basic":
|
|
||||||
# 基础压缩:保留常用字符和功能
|
|
||||||
args = base_args + [
|
|
||||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
|
||||||
"--layout-features=*", # 保留所有布局特性
|
|
||||||
"--glyph-names", # 保留字形名称
|
|
||||||
"--symbol-cmap", # 保留符号映射
|
|
||||||
"--legacy-cmap", # 保留传统字符映射
|
|
||||||
"--notdef-glyph", # 保留 .notdef 字形
|
|
||||||
"--recommended-glyphs", # 保留推荐字形
|
|
||||||
"--name-IDs=*", # 保留所有名称ID
|
|
||||||
"--name-legacy", # 保留传统名称
|
|
||||||
]
|
|
||||||
elif compression_level == "medium":
|
|
||||||
# 中等压缩:移除一些不常用的功能
|
|
||||||
args = base_args + [
|
|
||||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
|
||||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
|
||||||
"--no-glyph-names", # 移除字形名称
|
|
||||||
"--notdef-glyph",
|
|
||||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
|
||||||
]
|
|
||||||
else: # aggressive
|
|
||||||
# 激进压缩:最大程度减小文件大小
|
|
||||||
args = base_args + [
|
|
||||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
|
||||||
"--no-layout-features", # 移除所有布局特性
|
|
||||||
"--no-glyph-names", # 移除字形名称
|
|
||||||
"--no-symbol-cmap", # 移除符号映射
|
|
||||||
"--no-legacy-cmap", # 移除传统映射
|
|
||||||
"--notdef-glyph",
|
|
||||||
"--name-IDs=1,2", # 只保留最基本的名称
|
|
||||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 执行压缩命令
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"压缩失败: {result.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"压缩过程中出现错误: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def find_font_files(directory: str) -> List[str]:
|
|
||||||
"""查找目录中的所有字体文件"""
|
|
||||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
|
||||||
font_files = []
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(directory):
|
|
||||||
for file in files:
|
|
||||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
|
||||||
font_files.append(os.path.join(root, file))
|
|
||||||
|
|
||||||
return font_files
|
|
||||||
|
|
||||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic"):
|
|
||||||
"""
|
|
||||||
批量压缩字体文件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
font_directory: 字体文件目录
|
|
||||||
compression_level: 压缩级别
|
|
||||||
"""
|
|
||||||
if not os.path.exists(font_directory):
|
|
||||||
print(f"错误: 目录 {font_directory} 不存在")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 查找所有字体文件
|
|
||||||
font_files = find_font_files(font_directory)
|
|
||||||
|
|
||||||
if not font_files:
|
|
||||||
print("未找到字体文件")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"找到 {len(font_files)} 个字体文件")
|
|
||||||
print(f"压缩级别: {compression_level}")
|
|
||||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
|
||||||
print("-" * 60)
|
|
||||||
|
|
||||||
total_original_size = 0
|
|
||||||
total_compressed_size = 0
|
|
||||||
successful_compressions = 0
|
|
||||||
|
|
||||||
for i, font_file in enumerate(font_files, 1):
|
|
||||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
|
||||||
|
|
||||||
# 获取原始文件大小
|
|
||||||
original_size = get_file_size(font_file)
|
|
||||||
total_original_size += original_size
|
|
||||||
|
|
||||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
|
||||||
file_dir = os.path.dirname(font_file)
|
|
||||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
|
||||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
|
||||||
|
|
||||||
# 压缩字体
|
|
||||||
if compress_font(font_file, output_file, compression_level):
|
|
||||||
if os.path.exists(output_file):
|
|
||||||
compressed_size = get_file_size(output_file)
|
|
||||||
total_compressed_size += compressed_size
|
|
||||||
successful_compressions += 1
|
|
||||||
|
|
||||||
# 计算压缩率
|
|
||||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
|
||||||
|
|
||||||
print(f" ✓ 成功: {format_file_size(original_size)} → {format_file_size(compressed_size)} "
|
|
||||||
f"(压缩 {compression_ratio:.1f}%)")
|
|
||||||
else:
|
|
||||||
print(f" ✗ 失败: 输出文件未生成")
|
|
||||||
else:
|
|
||||||
print(f" ✗ 失败: 压缩过程出错")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 显示总结
|
|
||||||
print("=" * 60)
|
|
||||||
print("压缩完成!")
|
|
||||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
|
||||||
|
|
||||||
if successful_compressions > 0:
|
|
||||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
|
||||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
|
||||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
|
||||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""主函数"""
|
|
||||||
print("鸿蒙字体压缩工具")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 检查依赖
|
|
||||||
if not check_dependencies():
|
|
||||||
return
|
|
||||||
|
|
||||||
# 获取当前脚本所在目录
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
# 设置默认字体目录
|
|
||||||
font_directory = current_dir
|
|
||||||
|
|
||||||
print(f"字体目录: {font_directory}")
|
|
||||||
|
|
||||||
# 让用户选择压缩级别
|
|
||||||
print("\n请选择压缩级别:")
|
|
||||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
|
||||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
|
||||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
choice = input("\n请输入选择 (1-3): ").strip()
|
|
||||||
if choice == "1":
|
|
||||||
compression_level = "basic"
|
|
||||||
break
|
|
||||||
elif choice == "2":
|
|
||||||
compression_level = "medium"
|
|
||||||
break
|
|
||||||
elif choice == "3":
|
|
||||||
compression_level = "aggressive"
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("无效选择,请输入 1、2 或 3")
|
|
||||||
|
|
||||||
# 开始批量压缩
|
|
||||||
compress_fonts_batch(font_directory, compression_level=compression_level)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
179
frontend/src/assets/fonts/README.md
Normal file
179
frontend/src/assets/fonts/README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 字体压缩工具使用指南
|
||||||
|
|
||||||
|
## 📖 简介
|
||||||
|
|
||||||
|
`font_compressor.py` 是一个通用的字体压缩工具,可以:
|
||||||
|
- ✅ 将 TTF、OTF、WOFF 字体文件转换为 WOFF2 格式
|
||||||
|
- ✅ 支持相对路径和绝对路径
|
||||||
|
- ✅ 自动生成 CSS 字体定义文件
|
||||||
|
- ✅ 智能识别字体字重和样式
|
||||||
|
- ✅ 批量处理整个目录(包括子目录)
|
||||||
|
|
||||||
|
## 🚀 前置要求
|
||||||
|
|
||||||
|
安装 Python 依赖包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install fonttools brotli
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 使用方法
|
||||||
|
|
||||||
|
### 基础用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入 fonts 目录
|
||||||
|
cd frontend/src/assets/fonts
|
||||||
|
|
||||||
|
# 交互式模式处理当前目录
|
||||||
|
python font_compressor.py
|
||||||
|
|
||||||
|
# 处理相对路径的 Monocraft 目录
|
||||||
|
python font_compressor.py Monocraft
|
||||||
|
|
||||||
|
# 处理相对路径并指定压缩级别
|
||||||
|
python font_compressor.py Monocraft -l basic
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成 CSS 文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 压缩 Monocraft 字体并生成 CSS 文件
|
||||||
|
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||||
|
|
||||||
|
# 压缩 Hack 字体并生成 CSS
|
||||||
|
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||||
|
|
||||||
|
# 压缩 OpenSans 字体并生成 CSS
|
||||||
|
python font_compressor.py OpenSans -l medium -c ../styles/opensans_fonts.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### 高级用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用绝对路径
|
||||||
|
python font_compressor.py E:\Go_WorkSpace\voidraft\frontend\src\assets\fonts\Monocraft -l basic -c monocraft.css
|
||||||
|
|
||||||
|
# 不同压缩级别
|
||||||
|
python font_compressor.py Monocraft -l basic # 基础压缩,保留所有功能
|
||||||
|
python font_compressor.py Monocraft -l medium # 中等压缩,平衡大小和功能
|
||||||
|
python font_compressor.py Monocraft -l aggressive # 激进压缩,最小文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ 命令行参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `directory` | 字体目录(相对/绝对路径) | `Monocraft` 或 `/path/to/fonts` |
|
||||||
|
| `-l, --level` | 压缩级别 (basic/medium/aggressive) | `-l basic` |
|
||||||
|
| `-c, --css` | CSS 输出文件路径 | `-c monocraft.css` |
|
||||||
|
| `--version` | 显示版本信息 | `--version` |
|
||||||
|
| `-h, --help` | 显示帮助信息 | `-h` |
|
||||||
|
|
||||||
|
## 📊 压缩级别说明
|
||||||
|
|
||||||
|
### basic(基础) - 推荐
|
||||||
|
- 保留大部分字体功能
|
||||||
|
- 适合网页使用
|
||||||
|
- 压缩率约 30-40%
|
||||||
|
|
||||||
|
### medium(中等)
|
||||||
|
- 移除一些不常用的功能
|
||||||
|
- 平衡文件大小和功能
|
||||||
|
- 压缩率约 40-50%
|
||||||
|
|
||||||
|
### aggressive(激进)
|
||||||
|
- 最大程度减小文件大小
|
||||||
|
- 可能影响高级排版功能
|
||||||
|
- 压缩率约 50-60%
|
||||||
|
|
||||||
|
## 📁 输出结果
|
||||||
|
|
||||||
|
### 字体文件
|
||||||
|
压缩后的 `.woff2` 文件会保存在原文件相同的目录下,例如:
|
||||||
|
- `Monocraft/ttf/Monocraft-Bold.ttf` → `Monocraft/ttf/Monocraft-Bold.woff2`
|
||||||
|
- `Hack/hack-regular.ttf` → `Hack/hack-regular.woff2`
|
||||||
|
|
||||||
|
### CSS 文件
|
||||||
|
生成的 CSS 文件会包含:
|
||||||
|
- 自动识别的字体家族名称
|
||||||
|
- 正确的字重和样式设置
|
||||||
|
- 使用相对路径的字体引用
|
||||||
|
- 按字重排序的 `@font-face` 定义
|
||||||
|
|
||||||
|
生成的 CSS 示例:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 自动生成的字体文件 */
|
||||||
|
/* 由 font_compressor.py 生成 */
|
||||||
|
|
||||||
|
/* Monocraft 字体家族 */
|
||||||
|
|
||||||
|
/* Monocraft Light */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Bold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 实际使用示例
|
||||||
|
|
||||||
|
### 示例 1: 压缩 Monocraft 字体
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/src/assets/fonts
|
||||||
|
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||||
|
```
|
||||||
|
|
||||||
|
这将:
|
||||||
|
1. 扫描 `Monocraft/ttf` 和 `Monocraft/otf` 目录
|
||||||
|
2. 将所有字体文件转换为 WOFF2
|
||||||
|
3. 在 `frontend/src/assets/styles/monocraft_fonts.css` 生成 CSS 文件
|
||||||
|
|
||||||
|
### 示例 2: 批量处理多个字体目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/src/assets/fonts
|
||||||
|
|
||||||
|
# 压缩 Monocraft
|
||||||
|
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||||
|
|
||||||
|
# 压缩 OpenSans
|
||||||
|
python font_compressor.py OpenSans -l basic -c ../styles/opensans_fonts.css
|
||||||
|
|
||||||
|
# 压缩 Hack(已有 CSS,只需生成新版本对比)
|
||||||
|
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 字体信息自动识别
|
||||||
|
|
||||||
|
工具会自动从文件名识别:
|
||||||
|
- **字重**:Thin(100), Light(300), Regular(400), Medium(500), SemiBold(600), Bold(700), Black(900)
|
||||||
|
- **样式**:normal, italic
|
||||||
|
- **字体家族**:自动去除字重和样式后缀
|
||||||
|
|
||||||
|
支持的命名格式:
|
||||||
|
- `FontName-Bold.ttf`
|
||||||
|
- `FontName_Bold.otf`
|
||||||
|
- `FontName-BoldItalic.ttf`
|
||||||
|
- `FontName_SemiBold_Italic.woff`
|
||||||
|
|
||||||
|
|
||||||
|
## 📞 获取帮助
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python font_compressor.py --help
|
||||||
|
```
|
||||||
|
|
||||||
494
frontend/src/assets/fonts/font_compressor.py
Normal file
494
frontend/src/assets/fonts/font_compressor.py
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
通用字体压缩工具
|
||||||
|
使用 fonttools 库将字体文件转换为 WOFF2 格式,减小文件大小
|
||||||
|
支持 TTF、OTF、WOFF 等格式的字体文件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Dict, Optional
|
||||||
|
|
||||||
|
def check_dependencies():
|
||||||
|
"""检查必要的依赖是否已安装"""
|
||||||
|
missing_packages = []
|
||||||
|
|
||||||
|
# 检查 fonttools
|
||||||
|
try:
|
||||||
|
import fontTools
|
||||||
|
except ImportError:
|
||||||
|
missing_packages.append('fonttools')
|
||||||
|
|
||||||
|
# 检查 brotli
|
||||||
|
try:
|
||||||
|
import brotli
|
||||||
|
except ImportError:
|
||||||
|
missing_packages.append('brotli')
|
||||||
|
|
||||||
|
# 检查 pyftsubset 命令是否可用
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
missing_packages.append('fonttools[subset]')
|
||||||
|
except FileNotFoundError:
|
||||||
|
if 'fonttools' not in missing_packages:
|
||||||
|
missing_packages.append('fonttools[subset]')
|
||||||
|
|
||||||
|
if missing_packages:
|
||||||
|
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||||
|
print("请运行以下命令安装:")
|
||||||
|
print(f"pip install {' '.join(missing_packages)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_file_size(file_path: str) -> int:
|
||||||
|
"""获取文件大小(字节)"""
|
||||||
|
return os.path.getsize(file_path)
|
||||||
|
|
||||||
|
def format_file_size(size_bytes: int) -> str:
|
||||||
|
"""格式化文件大小显示"""
|
||||||
|
if size_bytes < 1024:
|
||||||
|
return f"{size_bytes} B"
|
||||||
|
elif size_bytes < 1024 * 1024:
|
||||||
|
return f"{size_bytes / 1024:.2f} KB"
|
||||||
|
else:
|
||||||
|
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||||
|
|
||||||
|
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||||
|
"""
|
||||||
|
压缩单个字体文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: 输入字体文件路径
|
||||||
|
output_path: 输出字体文件路径
|
||||||
|
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 压缩是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 基础压缩参数
|
||||||
|
base_args = [
|
||||||
|
"pyftsubset", input_path,
|
||||||
|
"--output-file=" + output_path,
|
||||||
|
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||||
|
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||||
|
]
|
||||||
|
|
||||||
|
# 根据压缩级别设置不同的参数
|
||||||
|
if compression_level == "basic":
|
||||||
|
# 基础压缩:保留常用字符和功能
|
||||||
|
args = base_args + [
|
||||||
|
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||||
|
"--layout-features=*", # 保留所有布局特性
|
||||||
|
"--glyph-names", # 保留字形名称
|
||||||
|
"--symbol-cmap", # 保留符号映射
|
||||||
|
"--legacy-cmap", # 保留传统字符映射
|
||||||
|
"--notdef-glyph", # 保留 .notdef 字形
|
||||||
|
"--recommended-glyphs", # 保留推荐字形
|
||||||
|
"--name-IDs=*", # 保留所有名称ID
|
||||||
|
"--name-legacy", # 保留传统名称
|
||||||
|
]
|
||||||
|
elif compression_level == "medium":
|
||||||
|
# 中等压缩:移除一些不常用的功能
|
||||||
|
args = base_args + [
|
||||||
|
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||||
|
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||||
|
"--no-glyph-names", # 移除字形名称
|
||||||
|
"--notdef-glyph",
|
||||||
|
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||||
|
]
|
||||||
|
else: # aggressive
|
||||||
|
# 激进压缩:最大程度减小文件大小
|
||||||
|
args = base_args + [
|
||||||
|
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||||
|
"--no-layout-features", # 移除所有布局特性
|
||||||
|
"--no-glyph-names", # 移除字形名称
|
||||||
|
"--no-symbol-cmap", # 移除符号映射
|
||||||
|
"--no-legacy-cmap", # 移除传统映射
|
||||||
|
"--notdef-glyph",
|
||||||
|
"--name-IDs=1,2", # 只保留最基本的名称
|
||||||
|
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||||
|
]
|
||||||
|
|
||||||
|
# 执行压缩命令
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"压缩失败: {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"压缩过程中出现错误: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def find_font_files(directory: str, exclude_woff2: bool = False) -> List[str]:
|
||||||
|
"""查找目录中的所有字体文件"""
|
||||||
|
if exclude_woff2:
|
||||||
|
font_extensions = ['.ttf', '.otf', '.woff']
|
||||||
|
else:
|
||||||
|
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||||
|
font_files = []
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(directory):
|
||||||
|
for file in files:
|
||||||
|
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||||
|
font_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
return font_files
|
||||||
|
|
||||||
|
def parse_font_info(filename: str) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
从字体文件名解析字体信息(字重、样式等)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 字体文件名(不含路径)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含字体信息的字典
|
||||||
|
"""
|
||||||
|
# 移除扩展名
|
||||||
|
name_without_ext = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# 字重映射
|
||||||
|
weight_mapping = {
|
||||||
|
'thin': (100, 'Thin'),
|
||||||
|
'extralight': (200, 'ExtraLight'),
|
||||||
|
'light': (300, 'Light'),
|
||||||
|
'regular': (400, 'Regular'),
|
||||||
|
'normal': (400, 'Regular'),
|
||||||
|
'medium': (500, 'Medium'),
|
||||||
|
'semibold': (600, 'SemiBold'),
|
||||||
|
'bold': (700, 'Bold'),
|
||||||
|
'extrabold': (800, 'ExtraBold'),
|
||||||
|
'black': (900, 'Black'),
|
||||||
|
'heavy': (900, 'Heavy'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 默认值
|
||||||
|
font_weight = 400
|
||||||
|
font_style = 'normal'
|
||||||
|
weight_name = 'Regular'
|
||||||
|
|
||||||
|
# 检查是否为斜体
|
||||||
|
if re.search(r'italic', name_without_ext, re.IGNORECASE):
|
||||||
|
font_style = 'italic'
|
||||||
|
|
||||||
|
# 检查字重
|
||||||
|
name_lower = name_without_ext.lower()
|
||||||
|
for weight_key, (weight_value, weight_label) in weight_mapping.items():
|
||||||
|
if weight_key in name_lower:
|
||||||
|
font_weight = weight_value
|
||||||
|
weight_name = weight_label
|
||||||
|
break
|
||||||
|
|
||||||
|
# 提取字体家族名称(移除字重和样式后缀)
|
||||||
|
family_name = name_without_ext
|
||||||
|
for weight_key, (_, weight_label) in weight_mapping.items():
|
||||||
|
family_name = re.sub(r'[-_]?' + weight_label, '', family_name, flags=re.IGNORECASE)
|
||||||
|
family_name = re.sub(r'[-_]?italic', '', family_name, flags=re.IGNORECASE)
|
||||||
|
family_name = family_name.strip('-_')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'family': family_name,
|
||||||
|
'weight': font_weight,
|
||||||
|
'style': font_style,
|
||||||
|
'weight_name': weight_name,
|
||||||
|
'full_name': name_without_ext
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_css(font_files: List[str], output_css_path: str, css_base_path: str):
|
||||||
|
"""
|
||||||
|
生成CSS字体文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_files: 字体文件路径列表(woff2文件)
|
||||||
|
output_css_path: 输出CSS文件路径
|
||||||
|
css_base_path: CSS文件相对于字体文件的基础路径
|
||||||
|
"""
|
||||||
|
# 按字体家族分组
|
||||||
|
font_groups: Dict[str, List[Dict]] = {}
|
||||||
|
|
||||||
|
for font_file in font_files:
|
||||||
|
if not font_file.endswith('.woff2'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = os.path.basename(font_file)
|
||||||
|
font_info = parse_font_info(filename)
|
||||||
|
|
||||||
|
# 计算相对路径
|
||||||
|
font_dir = os.path.dirname(font_file)
|
||||||
|
css_dir = os.path.dirname(output_css_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 计算从CSS文件到字体文件的相对路径
|
||||||
|
rel_path = os.path.relpath(font_file, css_dir)
|
||||||
|
# 统一使用正斜杠(适用于Web)
|
||||||
|
rel_path = rel_path.replace('\\', '/')
|
||||||
|
except ValueError:
|
||||||
|
# 如果在不同驱动器上,使用绝对路径
|
||||||
|
rel_path = font_file.replace('\\', '/')
|
||||||
|
|
||||||
|
font_info['path'] = rel_path
|
||||||
|
|
||||||
|
family = font_info['family']
|
||||||
|
if family not in font_groups:
|
||||||
|
font_groups[family] = []
|
||||||
|
font_groups[family].append(font_info)
|
||||||
|
|
||||||
|
# 生成CSS内容
|
||||||
|
css_lines = ['/* 自动生成的字体文件 */', '/* 由 font_compressor.py 生成 */', '']
|
||||||
|
|
||||||
|
for family, fonts in sorted(font_groups.items()):
|
||||||
|
css_lines.append(f'/* {family} 字体家族 */')
|
||||||
|
css_lines.append('')
|
||||||
|
|
||||||
|
# 按字重排序
|
||||||
|
fonts.sort(key=lambda x: (x['weight'], x['style']))
|
||||||
|
|
||||||
|
for font in fonts:
|
||||||
|
css_lines.append(f"/* {family} {font['weight_name']}{' Italic' if font['style'] == 'italic' else ''} */")
|
||||||
|
css_lines.append('@font-face {')
|
||||||
|
css_lines.append(f" font-family: '{family}';")
|
||||||
|
css_lines.append(f" src: url('{font['path']}') format('woff2');")
|
||||||
|
css_lines.append(f" font-weight: {font['weight']};")
|
||||||
|
css_lines.append(f" font-style: {font['style']};")
|
||||||
|
css_lines.append(' font-display: swap;')
|
||||||
|
css_lines.append('}')
|
||||||
|
css_lines.append('')
|
||||||
|
|
||||||
|
# 写入CSS文件
|
||||||
|
with open(output_css_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(css_lines))
|
||||||
|
|
||||||
|
print(f"[OK] CSS文件已生成: {output_css_path}")
|
||||||
|
print(f" 包含 {sum(len(fonts) for fonts in font_groups.values())} 个字体定义")
|
||||||
|
print(f" 字体家族: {', '.join(sorted(font_groups.keys()))}")
|
||||||
|
|
||||||
|
def compress_fonts_batch(font_directory: str, compression_level: str = "basic") -> List[str]:
|
||||||
|
"""
|
||||||
|
批量压缩字体文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_directory: 字体文件目录
|
||||||
|
compression_level: 压缩级别
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
生成的woff2文件路径列表
|
||||||
|
"""
|
||||||
|
if not os.path.exists(font_directory):
|
||||||
|
print(f"错误: 目录 {font_directory} 不存在")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 查找所有字体文件(排除已经是woff2的)
|
||||||
|
font_files = find_font_files(font_directory, exclude_woff2=True)
|
||||||
|
|
||||||
|
if not font_files:
|
||||||
|
print("未找到字体文件")
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"找到 {len(font_files)} 个字体文件")
|
||||||
|
print(f"压缩级别: {compression_level}")
|
||||||
|
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
total_original_size = 0
|
||||||
|
total_compressed_size = 0
|
||||||
|
successful_compressions = 0
|
||||||
|
generated_woff2_files = []
|
||||||
|
|
||||||
|
for i, font_file in enumerate(font_files, 1):
|
||||||
|
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||||
|
|
||||||
|
# 获取原始文件大小
|
||||||
|
original_size = get_file_size(font_file)
|
||||||
|
total_original_size += original_size
|
||||||
|
|
||||||
|
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||||
|
file_dir = os.path.dirname(font_file)
|
||||||
|
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||||
|
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||||
|
|
||||||
|
# 压缩字体
|
||||||
|
if compress_font(font_file, output_file, compression_level):
|
||||||
|
if os.path.exists(output_file):
|
||||||
|
compressed_size = get_file_size(output_file)
|
||||||
|
total_compressed_size += compressed_size
|
||||||
|
successful_compressions += 1
|
||||||
|
generated_woff2_files.append(output_file)
|
||||||
|
|
||||||
|
# 计算压缩率
|
||||||
|
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||||
|
|
||||||
|
print(f" [OK] 成功: {format_file_size(original_size)} -> {format_file_size(compressed_size)} "
|
||||||
|
f"(压缩 {compression_ratio:.1f}%)")
|
||||||
|
else:
|
||||||
|
print(f" [失败] 输出文件未生成")
|
||||||
|
else:
|
||||||
|
print(f" [失败] 压缩过程出错")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 显示总结
|
||||||
|
print("=" * 60)
|
||||||
|
print("压缩完成!")
|
||||||
|
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||||
|
|
||||||
|
if successful_compressions > 0:
|
||||||
|
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||||
|
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||||
|
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||||
|
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||||
|
|
||||||
|
return generated_woff2_files
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
# 解析命令行参数
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='通用字体压缩工具 - 将字体文件转换为 WOFF2 格式并生成CSS',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog='''
|
||||||
|
使用示例:
|
||||||
|
%(prog)s # 交互式模式,处理当前目录
|
||||||
|
%(prog)s Monocraft # 处理相对路径目录
|
||||||
|
%(prog)s Monocraft -l basic # 使用基础压缩级别
|
||||||
|
%(prog)s Monocraft -l basic -c monocraft.css # 压缩并生成CSS文件
|
||||||
|
%(prog)s /path/to/fonts -l medium -c fonts.css # 使用绝对路径
|
||||||
|
|
||||||
|
压缩级别说明:
|
||||||
|
basic - 基础压缩:保留大部分功能,适合网页使用
|
||||||
|
medium - 中等压缩:平衡文件大小和功能
|
||||||
|
aggressive - 激进压缩:最小文件大小,可能影响显示效果
|
||||||
|
|
||||||
|
CSS生成说明:
|
||||||
|
使用 -c/--css 选项生成CSS文件,自动使用相对路径引用字体文件
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'directory',
|
||||||
|
nargs='?',
|
||||||
|
default=None,
|
||||||
|
help='字体文件目录路径(支持相对/绝对路径,默认为当前脚本所在目录)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-l', '--level',
|
||||||
|
choices=['basic', 'medium', 'aggressive'],
|
||||||
|
default=None,
|
||||||
|
help='压缩级别:basic(基础)、medium(中等)、aggressive(激进)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-c', '--css',
|
||||||
|
default=None,
|
||||||
|
help='生成CSS文件路径(相对于脚本位置或绝对路径)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--version',
|
||||||
|
action='version',
|
||||||
|
version='%(prog)s 2.0'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("通用字体压缩工具 v2.0")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 检查依赖
|
||||||
|
if not check_dependencies():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取脚本所在目录
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# 确定字体目录
|
||||||
|
if args.directory:
|
||||||
|
# 支持相对路径和绝对路径
|
||||||
|
if os.path.isabs(args.directory):
|
||||||
|
font_directory = args.directory
|
||||||
|
else:
|
||||||
|
font_directory = os.path.join(script_dir, args.directory)
|
||||||
|
font_directory = os.path.abspath(font_directory)
|
||||||
|
else:
|
||||||
|
# 默认使用当前脚本所在目录
|
||||||
|
font_directory = script_dir
|
||||||
|
|
||||||
|
# 检查目录是否存在
|
||||||
|
if not os.path.exists(font_directory):
|
||||||
|
print(f"\n错误: 目录不存在: {font_directory}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n字体目录: {font_directory}")
|
||||||
|
|
||||||
|
# 确定压缩级别
|
||||||
|
compression_level = args.level
|
||||||
|
|
||||||
|
if compression_level is None:
|
||||||
|
# 交互式选择压缩级别
|
||||||
|
print("\n请选择压缩级别:")
|
||||||
|
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||||
|
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||||
|
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
choice = input("\n请输入选择 (1-3): ").strip()
|
||||||
|
if choice == "1":
|
||||||
|
compression_level = "basic"
|
||||||
|
break
|
||||||
|
elif choice == "2":
|
||||||
|
compression_level = "medium"
|
||||||
|
break
|
||||||
|
elif choice == "3":
|
||||||
|
compression_level = "aggressive"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("无效选择,请输入 1、2 或 3")
|
||||||
|
|
||||||
|
# 开始批量压缩
|
||||||
|
print()
|
||||||
|
generated_files = compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||||
|
|
||||||
|
# 生成CSS文件
|
||||||
|
if args.css and generated_files:
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("生成CSS文件...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 确定CSS输出路径
|
||||||
|
if os.path.isabs(args.css):
|
||||||
|
css_path = args.css
|
||||||
|
else:
|
||||||
|
css_path = os.path.join(script_dir, args.css)
|
||||||
|
css_path = os.path.abspath(css_path)
|
||||||
|
|
||||||
|
# 确保输出目录存在
|
||||||
|
css_dir = os.path.dirname(css_path)
|
||||||
|
if css_dir and not os.path.exists(css_dir):
|
||||||
|
os.makedirs(css_dir)
|
||||||
|
|
||||||
|
# 生成CSS
|
||||||
|
generate_css(generated_files, css_path, script_dir)
|
||||||
|
elif args.css and not generated_files:
|
||||||
|
print("\n警告: 没有成功生成WOFF2文件,跳过CSS生成")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("全部完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/* 导入所有CSS文件 */
|
/* 导入所有CSS文件 */
|
||||||
@import 'normalize.css';
|
@import 'normalize.css';
|
||||||
@import 'variables.css';
|
|
||||||
@import "harmony_fonts.css";
|
@import "harmony_fonts.css";
|
||||||
@import 'scrollbar.css';
|
|
||||||
@import 'hack_fonts.css';
|
@import 'hack_fonts.css';
|
||||||
@import 'opensans_fonts.css';
|
@import 'opensans_fonts.css';
|
||||||
|
@import "monocraft_fonts.css";
|
||||||
|
@import 'variables.css';
|
||||||
|
@import 'scrollbar.css';
|
||||||
|
@import 'styles.css';
|
||||||
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/* 自动生成的字体文件 */
|
||||||
|
/* 由 font_compressor.py 生成 */
|
||||||
|
|
||||||
|
/* Monocraft 字体家族 */
|
||||||
|
|
||||||
|
/* Monocraft ExtraLight Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft ExtraLight Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft ExtraLight */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft ExtraLight */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Light Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Light Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Light */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Light */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Regular Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Regular Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft SemiBold Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft SemiBold Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft SemiBold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft SemiBold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Bold Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Bold Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Bold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Bold */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Black Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Black Italic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Black */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/otf/Monocraft-Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monocraft Black */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monocraft';
|
||||||
|
src: url('../fonts/Monocraft/ttf/Monocraft-Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
3
frontend/src/assets/styles/styles.css
Normal file
3
frontend/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
@@ -1,255 +1,191 @@
|
|||||||
:root {
|
:root {
|
||||||
/* 编辑器区域 */
|
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
--text-primary: #9BB586; /* 内容区域字体颜色 */
|
|
||||||
|
|
||||||
/* 深色主题颜色变量 */
|
|
||||||
--dark-toolbar-bg: #2d2d2d;
|
|
||||||
--dark-toolbar-border: #404040;
|
|
||||||
--dark-toolbar-text: #ffffff;
|
|
||||||
--dark-toolbar-text-secondary: #cccccc;
|
|
||||||
--dark-toolbar-button-hover: #404040;
|
|
||||||
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
|
||||||
--dark-bg-secondary: #0E1217;
|
|
||||||
--dark-text-secondary: #a0aec0;
|
|
||||||
--dark-text-muted: #666;
|
|
||||||
--dark-border-color: #2d3748;
|
|
||||||
--dark-settings-bg: #2a2a2a;
|
|
||||||
--dark-settings-card-bg: #333333;
|
|
||||||
--dark-settings-text: #ffffff;
|
|
||||||
--dark-settings-text-secondary: #cccccc;
|
|
||||||
--dark-settings-border: #444444;
|
|
||||||
--dark-settings-input-bg: #3a3a3a;
|
|
||||||
--dark-settings-input-border: #555555;
|
|
||||||
--dark-settings-hover: #404040;
|
|
||||||
--dark-scrollbar-track: #2a2a2a;
|
|
||||||
--dark-scrollbar-thumb: #555555;
|
|
||||||
--dark-scrollbar-thumb-hover: #666666;
|
|
||||||
--dark-selection-bg: rgba(181, 206, 168, 0.1);
|
|
||||||
--dark-selection-text: #b5cea8;
|
|
||||||
--dark-danger-color: #ff6b6b;
|
|
||||||
--dark-bg-primary: #1a1a1a;
|
|
||||||
--dark-bg-hover: #2a2a2a;
|
|
||||||
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
|
|
||||||
--dark-loading-color: #fff;
|
|
||||||
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
|
||||||
--dark-loading-done-color: #6f6;
|
|
||||||
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
|
||||||
|
|
||||||
/* 浅色主题颜色变量 */
|
|
||||||
--light-toolbar-bg: #f8f9fa;
|
|
||||||
--light-toolbar-border: #e9ecef;
|
|
||||||
--light-toolbar-text: #212529;
|
|
||||||
--light-toolbar-text-secondary: #495057;
|
|
||||||
--light-toolbar-button-hover: #e9ecef;
|
|
||||||
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
|
||||||
--light-bg-secondary: #f7fef7;
|
|
||||||
--light-text-secondary: #374151;
|
|
||||||
--light-text-muted: #6b7280;
|
|
||||||
--light-border-color: #e5e7eb;
|
|
||||||
--light-settings-bg: #ffffff;
|
|
||||||
--light-settings-card-bg: #f8f9fa;
|
|
||||||
--light-settings-text: #212529;
|
|
||||||
--light-settings-text-secondary: #6c757d;
|
|
||||||
--light-settings-border: #dee2e6;
|
|
||||||
--light-settings-input-bg: #ffffff;
|
|
||||||
--light-settings-input-border: #ced4da;
|
|
||||||
--light-settings-hover: #e9ecef;
|
|
||||||
--light-scrollbar-track: #f1f3f4;
|
|
||||||
--light-scrollbar-thumb: #c1c1c1;
|
|
||||||
--light-scrollbar-thumb-hover: #a8a8a8;
|
|
||||||
--light-selection-bg: rgba(59, 130, 246, 0.15);
|
|
||||||
--light-selection-text: #2563eb;
|
|
||||||
--light-danger-color: #dc3545;
|
|
||||||
--light-bg-primary: #ffffff;
|
|
||||||
--light-bg-hover: #f1f3f4;
|
|
||||||
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
|
||||||
--light-loading-color: #1a3c1a;
|
|
||||||
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
|
||||||
--light-loading-done-color: #008800;
|
|
||||||
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
|
||||||
|
|
||||||
/* 默认使用深色主题 */
|
|
||||||
--toolbar-bg: var(--dark-toolbar-bg);
|
|
||||||
--toolbar-border: var(--dark-toolbar-border);
|
|
||||||
--toolbar-text: var(--dark-toolbar-text);
|
|
||||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
|
||||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
|
||||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
|
||||||
--tab-active-line: var(--dark-tab-active-line);
|
|
||||||
--bg-secondary: var(--dark-bg-secondary);
|
|
||||||
--text-secondary: var(--dark-text-secondary);
|
|
||||||
--text-muted: var(--dark-text-muted);
|
|
||||||
--border-color: var(--dark-border-color);
|
|
||||||
--settings-bg: var(--dark-settings-bg);
|
|
||||||
--settings-card-bg: var(--dark-settings-card-bg);
|
|
||||||
--settings-text: var(--dark-settings-text);
|
|
||||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
|
||||||
--settings-border: var(--dark-settings-border);
|
|
||||||
--settings-input-bg: var(--dark-settings-input-bg);
|
|
||||||
--settings-input-border: var(--dark-settings-input-border);
|
|
||||||
--settings-hover: var(--dark-settings-hover);
|
|
||||||
--scrollbar-track: var(--dark-scrollbar-track);
|
|
||||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
|
||||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
|
||||||
--selection-bg: var(--dark-selection-bg);
|
|
||||||
--selection-text: var(--dark-selection-text);
|
|
||||||
--text-danger: var(--dark-danger-color);
|
|
||||||
--bg-primary: var(--dark-bg-primary);
|
|
||||||
--bg-hover: var(--dark-bg-hover);
|
|
||||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
|
||||||
--voidraft-loading-color: var(--dark-loading-color);
|
|
||||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
|
||||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
|
||||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
|
||||||
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 监听系统深色主题 */
|
/* 默认/暗色主题 */
|
||||||
@media (prefers-color-scheme: dark) {
|
:root,
|
||||||
:root[data-theme="auto"] {
|
:root[data-theme="dark"],
|
||||||
--toolbar-bg: var(--dark-toolbar-bg);
|
:root[data-theme="auto"] {
|
||||||
--toolbar-border: var(--dark-toolbar-border);
|
color-scheme: dark;
|
||||||
--toolbar-text: var(--dark-toolbar-text);
|
|
||||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
--text-primary: #ffffff;
|
||||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
|
||||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
--toolbar-bg: #2d2d2d;
|
||||||
--tab-active-line: var(--dark-tab-active-line);
|
--toolbar-border: #404040;
|
||||||
--bg-secondary: var(--dark-bg-secondary);
|
--toolbar-text: #ffffff;
|
||||||
--text-secondary: var(--dark-text-secondary);
|
--toolbar-text-secondary: #cccccc;
|
||||||
--text-muted: var(--dark-text-muted);
|
--toolbar-button-hover: #404040;
|
||||||
--border-color: var(--dark-border-color);
|
--toolbar-separator: #404040;
|
||||||
--settings-bg: var(--dark-settings-bg);
|
|
||||||
--settings-card-bg: var(--dark-settings-card-bg);
|
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||||
--settings-text: var(--dark-settings-text);
|
--bg-secondary: #0e1217;
|
||||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
--bg-primary: #1a1a1a;
|
||||||
--settings-border: var(--dark-settings-border);
|
--bg-hover: #2a2a2a;
|
||||||
--settings-input-bg: var(--dark-settings-input-bg);
|
|
||||||
--settings-input-border: var(--dark-settings-input-border);
|
--text-secondary: #a0aec0;
|
||||||
--settings-hover: var(--dark-settings-hover);
|
--text-muted: #666666;
|
||||||
--scrollbar-track: var(--dark-scrollbar-track);
|
--text-danger: #ff6b6b;
|
||||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
|
||||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
--border-color: #2d3748;
|
||||||
--selection-bg: var(--dark-selection-bg);
|
|
||||||
--selection-text: var(--dark-selection-text);
|
--settings-bg: #2a2a2a;
|
||||||
--text-danger: var(--dark-danger-color);
|
--settings-card-bg: #333333;
|
||||||
--bg-primary: var(--dark-bg-primary);
|
--settings-text: #ffffff;
|
||||||
--bg-hover: var(--dark-bg-hover);
|
--settings-text-secondary: #cccccc;
|
||||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
--settings-border: #444444;
|
||||||
--voidraft-loading-color: var(--dark-loading-color);
|
--settings-input-bg: #3a3a3a;
|
||||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
--settings-input-border: #555555;
|
||||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
--settings-hover: #404040;
|
||||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
|
||||||
}
|
--scrollbar-track: #2a2a2a;
|
||||||
|
--scrollbar-thumb: #555555;
|
||||||
|
--scrollbar-thumb-hover: #666666;
|
||||||
|
|
||||||
|
--selection-bg: rgba(181, 206, 168, 0.1);
|
||||||
|
--selection-text: #b5cea8;
|
||||||
|
|
||||||
|
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
|
||||||
|
--voidraft-loading-color: #ffffff;
|
||||||
|
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||||
|
--voidraft-loading-done-color: #66ff66;
|
||||||
|
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||||
|
|
||||||
|
/* Markdown 代码块样式 - 暗色主题 */
|
||||||
|
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||||
|
--cm-codeblock-radius: 0.4rem;
|
||||||
|
|
||||||
|
|
||||||
|
/* Markdown 内联代码样式 */
|
||||||
|
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||||
|
|
||||||
|
/* Markdown 上标/下标样式 */
|
||||||
|
--cm-superscript-color: inherit;
|
||||||
|
--cm-subscript-color: inherit;
|
||||||
|
|
||||||
|
/* Markdown 高亮样式 */
|
||||||
|
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 监听系统浅色主题 */
|
/* 亮色主题 */
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--text-primary: #000000;
|
||||||
|
|
||||||
|
--toolbar-bg: #f8f9fa;
|
||||||
|
--toolbar-border: #e9ecef;
|
||||||
|
--toolbar-text: #212529;
|
||||||
|
--toolbar-text-secondary: #495057;
|
||||||
|
--toolbar-button-hover: #e9ecef;
|
||||||
|
--toolbar-separator: #e9ecef;
|
||||||
|
|
||||||
|
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||||
|
--bg-secondary: #f7fef7;
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-hover: #f1f3f4;
|
||||||
|
|
||||||
|
--text-secondary: #374151;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--text-danger: #dc3545;
|
||||||
|
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
|
||||||
|
--settings-bg: #ffffff;
|
||||||
|
--settings-card-bg: #f8f9fa;
|
||||||
|
--settings-text: #212529;
|
||||||
|
--settings-text-secondary: #6c757d;
|
||||||
|
--settings-border: #dee2e6;
|
||||||
|
--settings-input-bg: #ffffff;
|
||||||
|
--settings-input-border: #ced4da;
|
||||||
|
--settings-hover: #e9ecef;
|
||||||
|
|
||||||
|
--scrollbar-track: #f1f3f4;
|
||||||
|
--scrollbar-thumb: #c1c1c1;
|
||||||
|
--scrollbar-thumb-hover: #a8a8a8;
|
||||||
|
|
||||||
|
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||||
|
--selection-text: #2563eb;
|
||||||
|
|
||||||
|
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||||
|
--voidraft-loading-color: #1a3c1a;
|
||||||
|
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||||
|
--voidraft-loading-done-color: #008800;
|
||||||
|
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||||
|
|
||||||
|
/* Markdown 代码块样式 - 亮色主题 */
|
||||||
|
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||||
|
--cm-codeblock-radius: 0.4rem;
|
||||||
|
|
||||||
|
/* Markdown 内联代码样式 */
|
||||||
|
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||||
|
|
||||||
|
/* Markdown 上标/下标样式 */
|
||||||
|
--cm-superscript-color: inherit;
|
||||||
|
--cm-subscript-color: inherit;
|
||||||
|
|
||||||
|
/* Markdown 高亮样式 */
|
||||||
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跟随系统的浅色偏好 */
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root[data-theme="auto"] {
|
:root[data-theme="auto"] {
|
||||||
--toolbar-bg: var(--light-toolbar-bg);
|
color-scheme: light;
|
||||||
--toolbar-border: var(--light-toolbar-border);
|
|
||||||
--toolbar-text: var(--light-toolbar-text);
|
--text-primary: #000000;
|
||||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
|
||||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
--toolbar-bg: #f8f9fa;
|
||||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
--toolbar-border: #e9ecef;
|
||||||
--tab-active-line: var(--light-tab-active-line);
|
--toolbar-text: #212529;
|
||||||
--bg-secondary: var(--light-bg-secondary);
|
--toolbar-text-secondary: #495057;
|
||||||
--text-secondary: var(--light-text-secondary);
|
--toolbar-button-hover: #e9ecef;
|
||||||
--text-muted: var(--light-text-muted);
|
--toolbar-separator: #e9ecef;
|
||||||
--border-color: var(--light-border-color);
|
|
||||||
--settings-bg: var(--light-settings-bg);
|
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||||
--settings-card-bg: var(--light-settings-card-bg);
|
--bg-secondary: #f7fef7;
|
||||||
--settings-text: var(--light-settings-text);
|
--bg-primary: #ffffff;
|
||||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
--bg-hover: #f1f3f4;
|
||||||
--settings-border: var(--light-settings-border);
|
|
||||||
--settings-input-bg: var(--light-settings-input-bg);
|
--text-secondary: #374151;
|
||||||
--settings-input-border: var(--light-settings-input-border);
|
--text-muted: #6b7280;
|
||||||
--settings-hover: var(--light-settings-hover);
|
--text-danger: #dc3545;
|
||||||
--scrollbar-track: var(--light-scrollbar-track);
|
|
||||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
--border-color: #e5e7eb;
|
||||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
|
||||||
--selection-bg: var(--light-selection-bg);
|
--settings-bg: #ffffff;
|
||||||
--selection-text: var(--light-selection-text);
|
--settings-card-bg: #f8f9fa;
|
||||||
--text-danger: var(--light-danger-color);
|
--settings-text: #212529;
|
||||||
--bg-primary: var(--light-bg-primary);
|
--settings-text-secondary: #6c757d;
|
||||||
--bg-hover: var(--light-bg-hover);
|
--settings-border: #dee2e6;
|
||||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
--settings-input-bg: #ffffff;
|
||||||
--voidraft-loading-color: var(--light-loading-color);
|
--settings-input-border: #ced4da;
|
||||||
--voidraft-loading-glow: var(--light-loading-glow);
|
--settings-hover: #e9ecef;
|
||||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
|
||||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
--scrollbar-track: #f1f3f4;
|
||||||
|
--scrollbar-thumb: #c1c1c1;
|
||||||
|
--scrollbar-thumb-hover: #a8a8a8;
|
||||||
|
|
||||||
|
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||||
|
--selection-text: #2563eb;
|
||||||
|
|
||||||
|
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||||
|
--voidraft-loading-color: #1a3c1a;
|
||||||
|
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||||
|
--voidraft-loading-done-color: #008800;
|
||||||
|
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||||
|
|
||||||
|
/* Markdown 代码块样式 - 亮色主题 */
|
||||||
|
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||||
|
--cm-codeblock-radius: 0.4rem;
|
||||||
|
|
||||||
|
/* Markdown 内联代码样式 */
|
||||||
|
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||||
|
|
||||||
|
/* Markdown 上标/下标样式 */
|
||||||
|
--cm-superscript-color: inherit;
|
||||||
|
--cm-subscript-color: inherit;
|
||||||
|
|
||||||
|
/* Markdown 高亮样式 */
|
||||||
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手动选择浅色主题 */
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--toolbar-bg: var(--light-toolbar-bg);
|
|
||||||
--toolbar-border: var(--light-toolbar-border);
|
|
||||||
--toolbar-text: var(--light-toolbar-text);
|
|
||||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
|
||||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
|
||||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
|
||||||
--tab-active-line: var(--light-tab-active-line);
|
|
||||||
--bg-secondary: var(--light-bg-secondary);
|
|
||||||
--text-secondary: var(--light-text-secondary);
|
|
||||||
--text-muted: var(--light-text-muted);
|
|
||||||
--border-color: var(--light-border-color);
|
|
||||||
--settings-bg: var(--light-settings-bg);
|
|
||||||
--settings-card-bg: var(--light-settings-card-bg);
|
|
||||||
--settings-text: var(--light-settings-text);
|
|
||||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
|
||||||
--settings-border: var(--light-settings-border);
|
|
||||||
--settings-input-bg: var(--light-settings-input-bg);
|
|
||||||
--settings-input-border: var(--light-settings-input-border);
|
|
||||||
--settings-hover: var(--light-settings-hover);
|
|
||||||
--scrollbar-track: var(--light-scrollbar-track);
|
|
||||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
|
||||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
|
||||||
--selection-bg: var(--light-selection-bg);
|
|
||||||
--selection-text: var(--light-selection-text);
|
|
||||||
--text-danger: var(--light-danger-color);
|
|
||||||
--bg-primary: var(--light-bg-primary);
|
|
||||||
--bg-hover: var(--light-bg-hover);
|
|
||||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
|
||||||
--voidraft-loading-color: var(--light-loading-color);
|
|
||||||
--voidraft-loading-glow: var(--light-loading-glow);
|
|
||||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
|
||||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 手动选择深色主题 */
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--toolbar-bg: var(--dark-toolbar-bg);
|
|
||||||
--toolbar-border: var(--dark-toolbar-border);
|
|
||||||
--toolbar-text: var(--dark-toolbar-text);
|
|
||||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
|
||||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
|
||||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
|
||||||
--tab-active-line: var(--dark-tab-active-line);
|
|
||||||
--bg-secondary: var(--dark-bg-secondary);
|
|
||||||
--text-secondary: var(--dark-text-secondary);
|
|
||||||
--text-muted: var(--dark-text-muted);
|
|
||||||
--border-color: var(--dark-border-color);
|
|
||||||
--settings-bg: var(--dark-settings-bg);
|
|
||||||
--settings-card-bg: var(--dark-settings-card-bg);
|
|
||||||
--settings-text: var(--dark-settings-text);
|
|
||||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
|
||||||
--settings-border: var(--dark-settings-border);
|
|
||||||
--settings-input-bg: var(--dark-settings-input-bg);
|
|
||||||
--settings-input-border: var(--dark-settings-input-border);
|
|
||||||
--settings-hover: var(--dark-settings-hover);
|
|
||||||
--scrollbar-track: var(--dark-scrollbar-track);
|
|
||||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
|
||||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
|
||||||
--selection-bg: var(--dark-selection-bg);
|
|
||||||
--selection-text: var(--dark-selection-text);
|
|
||||||
--text-danger: var(--dark-danger-color);
|
|
||||||
--bg-primary: var(--dark-bg-primary);
|
|
||||||
--bg-hover: var(--dark-bg-hover);
|
|
||||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
|
||||||
--voidraft-loading-color: var(--dark-loading-color);
|
|
||||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
|
||||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
|
||||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AppearanceConfig,
|
AuthMethod,
|
||||||
EditingConfig,
|
|
||||||
GeneralConfig,
|
|
||||||
LanguageType,
|
LanguageType,
|
||||||
SystemThemeType,
|
SystemThemeType,
|
||||||
TabType,
|
TabType,
|
||||||
UpdatesConfig,
|
UpdateSourceType
|
||||||
UpdateSourceType,
|
|
||||||
GitBackupConfig,
|
|
||||||
AuthMethod
|
|
||||||
} from '@/../bindings/voidraft/internal/models/models';
|
} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {FONT_OPTIONS} from './fonts';
|
import {FONT_OPTIONS} from './fonts';
|
||||||
|
|
||||||
// 配置键映射和限制的类型定义
|
|
||||||
export type GeneralConfigKeyMap = {
|
|
||||||
readonly [K in keyof GeneralConfig]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EditingConfigKeyMap = {
|
|
||||||
readonly [K in keyof EditingConfig]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppearanceConfigKeyMap = {
|
|
||||||
readonly [K in keyof AppearanceConfig]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdatesConfigKeyMap = {
|
|
||||||
readonly [K in keyof UpdatesConfig]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BackupConfigKeyMap = {
|
|
||||||
readonly [K in keyof GitBackupConfig]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
||||||
|
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
|
||||||
|
|
||||||
// 配置键映射
|
// 统一配置键映射(平级展开)
|
||||||
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
export const CONFIG_KEY_MAP = {
|
||||||
|
// general
|
||||||
alwaysOnTop: 'general.alwaysOnTop',
|
alwaysOnTop: 'general.alwaysOnTop',
|
||||||
dataPath: 'general.dataPath',
|
dataPath: 'general.dataPath',
|
||||||
enableSystemTray: 'general.enableSystemTray',
|
enableSystemTray: 'general.enableSystemTray',
|
||||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
|||||||
enableWindowSnap: 'general.enableWindowSnap',
|
enableWindowSnap: 'general.enableWindowSnap',
|
||||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||||
enableTabs: 'general.enableTabs',
|
enableTabs: 'general.enableTabs',
|
||||||
} as const;
|
// editing
|
||||||
|
|
||||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
|
||||||
fontSize: 'editing.fontSize',
|
fontSize: 'editing.fontSize',
|
||||||
fontFamily: 'editing.fontFamily',
|
fontFamily: 'editing.fontFamily',
|
||||||
fontWeight: 'editing.fontWeight',
|
fontWeight: 'editing.fontWeight',
|
||||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
|||||||
enableTabIndent: 'editing.enableTabIndent',
|
enableTabIndent: 'editing.enableTabIndent',
|
||||||
tabSize: 'editing.tabSize',
|
tabSize: 'editing.tabSize',
|
||||||
tabType: 'editing.tabType',
|
tabType: 'editing.tabType',
|
||||||
autoSaveDelay: 'editing.autoSaveDelay'
|
autoSaveDelay: 'editing.autoSaveDelay',
|
||||||
} as const;
|
// appearance
|
||||||
|
|
||||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
|
||||||
language: 'appearance.language',
|
language: 'appearance.language',
|
||||||
systemTheme: 'appearance.systemTheme',
|
systemTheme: 'appearance.systemTheme',
|
||||||
currentTheme: 'appearance.currentTheme'
|
currentTheme: 'appearance.currentTheme',
|
||||||
} as const;
|
// updates
|
||||||
|
|
||||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
|
||||||
version: 'updates.version',
|
version: 'updates.version',
|
||||||
autoUpdate: 'updates.autoUpdate',
|
autoUpdate: 'updates.autoUpdate',
|
||||||
primarySource: 'updates.primarySource',
|
primarySource: 'updates.primarySource',
|
||||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
|||||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||||
updateTimeout: 'updates.updateTimeout',
|
updateTimeout: 'updates.updateTimeout',
|
||||||
github: 'updates.github',
|
github: 'updates.github',
|
||||||
gitea: 'updates.gitea'
|
gitea: 'updates.gitea',
|
||||||
} as const;
|
// backup
|
||||||
|
|
||||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
|
||||||
enabled: 'backup.enabled',
|
enabled: 'backup.enabled',
|
||||||
repo_url: 'backup.repo_url',
|
repo_url: 'backup.repo_url',
|
||||||
auth_method: 'backup.auth_method',
|
auth_method: 'backup.auth_method',
|
||||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
|||||||
auto_backup: 'backup.auto_backup',
|
auto_backup: 'backup.auto_backup',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||||
|
|
||||||
// 配置限制
|
// 配置限制
|
||||||
export const CONFIG_LIMITS = {
|
export const CONFIG_LIMITS = {
|
||||||
fontSize: {min: 12, max: 28, default: 13},
|
fontSize: {min: 12, max: 28, default: 13},
|
||||||
|
|||||||
1945
frontend/src/common/constant/emojies.ts
Normal file
1945
frontend/src/common/constant/emojies.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
@@ -46,7 +50,7 @@ export const FONT_OPTIONS = [
|
|||||||
label: 'System UI',
|
label: 'System UI',
|
||||||
value: 'system-ui'
|
value: 'system-ui'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chinese fonts
|
// Chinese fonts
|
||||||
{
|
{
|
||||||
label: 'Microsoft YaHei',
|
label: 'Microsoft YaHei',
|
||||||
@@ -56,7 +60,7 @@ export const FONT_OPTIONS = [
|
|||||||
label: 'PingFang SC',
|
label: 'PingFang SC',
|
||||||
value: '"PingFang SC"'
|
value: '"PingFang SC"'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Popular programming fonts
|
// Popular programming fonts
|
||||||
{
|
{
|
||||||
label: 'JetBrains Mono',
|
label: 'JetBrains Mono',
|
||||||
|
|||||||
@@ -1,45 +1,3 @@
|
|||||||
/**
|
|
||||||
* 默认翻译配置
|
|
||||||
*/
|
|
||||||
export const DEFAULT_TRANSLATION_CONFIG = {
|
|
||||||
minSelectionLength: 2,
|
|
||||||
maxTranslationLength: 5000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译相关的错误消息
|
|
||||||
*/
|
|
||||||
export const TRANSLATION_ERRORS = {
|
|
||||||
NO_TEXT: 'no text to translate',
|
|
||||||
TRANSLATION_FAILED: 'translation failed',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译结果接口
|
|
||||||
*/
|
|
||||||
export interface TranslationResult {
|
|
||||||
translatedText: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语言信息接口
|
|
||||||
*/
|
|
||||||
export interface LanguageInfo {
|
|
||||||
Code: string; // 语言代码
|
|
||||||
Name: string; // 语言名称
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻译器扩展配置
|
|
||||||
*/
|
|
||||||
export interface TranslatorConfig {
|
|
||||||
/** 最小选择字符数才显示翻译按钮 */
|
|
||||||
minSelectionLength: number;
|
|
||||||
/** 最大翻译字符数 */
|
|
||||||
maxTranslationLength: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 翻译图标SVG
|
* 翻译图标SVG
|
||||||
*/
|
*/
|
||||||
|
|||||||
329
frontend/src/common/utils/domDiff.test.ts
Normal file
329
frontend/src/common/utils/domDiff.test.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* DOM Diff 算法单元测试
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { morphNode, morphHTML, morphWithKeys } from './domDiff';
|
||||||
|
|
||||||
|
describe('DOM Diff Algorithm', () => {
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.removeChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 基础功能', () => {
|
||||||
|
test('应该更新文本节点内容', () => {
|
||||||
|
const fromNode = document.createTextNode('Hello');
|
||||||
|
const toNode = document.createTextNode('World');
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(fromNode.nodeValue).toBe('World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该保持相同的文本节点不变', () => {
|
||||||
|
const fromNode = document.createTextNode('Hello');
|
||||||
|
const toNode = document.createTextNode('Hello');
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
const originalNode = fromNode;
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(fromNode).toBe(originalNode);
|
||||||
|
expect(fromNode.nodeValue).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该替换不同类型的节点', () => {
|
||||||
|
const fromNode = document.createElement('span');
|
||||||
|
fromNode.textContent = 'Hello';
|
||||||
|
const toNode = document.createElement('div');
|
||||||
|
toNode.textContent = 'World';
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(container.firstChild?.nodeName).toBe('DIV');
|
||||||
|
expect(container.firstChild?.textContent).toBe('World');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 属性更新', () => {
|
||||||
|
test('应该添加新属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'test');
|
||||||
|
toEl.setAttribute('id', 'myid');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('test');
|
||||||
|
expect(fromEl.getAttribute('id')).toBe('myid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该更新已存在的属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.setAttribute('class', 'old');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'new');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除不存在的属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.setAttribute('class', 'test');
|
||||||
|
fromEl.setAttribute('id', 'myid');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'test');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('test');
|
||||||
|
expect(fromEl.hasAttribute('id')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 子节点更新', () => {
|
||||||
|
test('应该添加新子节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = '<li>1</li><li>2</li>';
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(3);
|
||||||
|
expect(fromEl.children[2].textContent).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除多余的子节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = '<li>1</li><li>2</li>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(2);
|
||||||
|
expect(fromEl.textContent).toBe('12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该更新子节点内容', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.innerHTML = '<p>Old</p>';
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.innerHTML = '<p>New</p>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const originalP = fromEl.querySelector('p');
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
// 应该保持同一个 p 元素,只更新内容
|
||||||
|
expect(fromEl.querySelector('p')).toBe(originalP);
|
||||||
|
expect(fromEl.querySelector('p')?.textContent).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphHTML - HTML 字符串更新', () => {
|
||||||
|
test('应该从 HTML 字符串更新元素', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.innerHTML = '<p>Old</p>';
|
||||||
|
container.appendChild(element);
|
||||||
|
|
||||||
|
morphHTML(element, '<p>New</p>');
|
||||||
|
|
||||||
|
expect(element.innerHTML).toBe('<p>New</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理复杂的 HTML 结构', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.innerHTML = '<h1>Title</h1><p>Paragraph</p>';
|
||||||
|
container.appendChild(element);
|
||||||
|
|
||||||
|
morphHTML(element, '<h1>New Title</h1><p>New Paragraph</p><span>Extra</span>');
|
||||||
|
|
||||||
|
expect(element.children.length).toBe(3);
|
||||||
|
expect(element.querySelector('h1')?.textContent).toBe('New Title');
|
||||||
|
expect(element.querySelector('p')?.textContent).toBe('New Paragraph');
|
||||||
|
expect(element.querySelector('span')?.textContent).toBe('Extra');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphWithKeys - 基于 key 的智能 diff', () => {
|
||||||
|
test('应该保持相同 key 的节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A Updated</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const originalA = fromEl.querySelector('[data-key="a"]');
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.querySelector('[data-key="a"]')).toBe(originalA);
|
||||||
|
expect(originalA?.textContent).toBe('A Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该重新排序节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
const keys = Array.from(fromEl.children).map(child => child.getAttribute('data-key'));
|
||||||
|
expect(keys).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该添加新的 key 节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(3);
|
||||||
|
expect(fromEl.querySelector('[data-key="c"]')?.textContent).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除不存在的 key 节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(2);
|
||||||
|
expect(fromEl.querySelector('[data-key="b"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('性能测试', () => {
|
||||||
|
test('应该高效处理大量节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `Item ${i}`;
|
||||||
|
fromEl.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `Updated Item ${i}`;
|
||||||
|
toEl.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
expect(endTime - startTime).toBeLessThan(100); // 应该在 100ms 内完成
|
||||||
|
expect(fromEl.children.length).toBe(1000);
|
||||||
|
expect(fromEl.children[0].textContent).toBe('Updated Item 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界情况', () => {
|
||||||
|
test('应该处理空节点', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
expect(() => morphNode(fromEl, toEl)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理只有文本的节点', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.textContent = 'Hello';
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.textContent = 'World';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.textContent).toBe('World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理嵌套的复杂结构', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<div class="outer">
|
||||||
|
<div class="inner">
|
||||||
|
<span>Text</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<div class="outer modified">
|
||||||
|
<div class="inner">
|
||||||
|
<span>Updated Text</span>
|
||||||
|
<strong>New</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.querySelector('.outer')?.classList.contains('modified')).toBe(true);
|
||||||
|
expect(fromEl.querySelector('span')?.textContent).toBe('Updated Text');
|
||||||
|
expect(fromEl.querySelector('strong')?.textContent).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
180
frontend/src/common/utils/domDiff.ts
Normal file
180
frontend/src/common/utils/domDiff.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 轻量级 DOM Diff 算法实现
|
||||||
|
* 基于 morphdom 思路,只更新变化的节点,保持未变化的节点不动
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较并更新两个 DOM 节点
|
||||||
|
* @param fromNode 原节点
|
||||||
|
* @param toNode 目标节点
|
||||||
|
*/
|
||||||
|
export function morphNode(fromNode: Node, toNode: Node): void {
|
||||||
|
// 节点类型不同,直接替换
|
||||||
|
if (fromNode.nodeType !== toNode.nodeType || fromNode.nodeName !== toNode.nodeName) {
|
||||||
|
fromNode.parentNode?.replaceChild(toNode.cloneNode(true), fromNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本节点:比较内容
|
||||||
|
if (fromNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (fromNode.nodeValue !== toNode.nodeValue) {
|
||||||
|
fromNode.nodeValue = toNode.nodeValue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元素节点:更新属性和子节点
|
||||||
|
if (fromNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const fromEl = fromNode as Element;
|
||||||
|
const toEl = toNode as Element;
|
||||||
|
|
||||||
|
// 更新属性
|
||||||
|
morphAttributes(fromEl, toEl);
|
||||||
|
|
||||||
|
// 更新子节点
|
||||||
|
morphChildren(fromEl, toEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新元素属性
|
||||||
|
*/
|
||||||
|
function morphAttributes(fromEl: Element, toEl: Element): void {
|
||||||
|
// 移除旧属性
|
||||||
|
const fromAttrs = fromEl.attributes;
|
||||||
|
for (let i = fromAttrs.length - 1; i >= 0; i--) {
|
||||||
|
const attr = fromAttrs[i];
|
||||||
|
if (!toEl.hasAttribute(attr.name)) {
|
||||||
|
fromEl.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加/更新新属性
|
||||||
|
const toAttrs = toEl.attributes;
|
||||||
|
for (let i = 0; i < toAttrs.length; i++) {
|
||||||
|
const attr = toAttrs[i];
|
||||||
|
const fromValue = fromEl.getAttribute(attr.name);
|
||||||
|
if (fromValue !== attr.value) {
|
||||||
|
fromEl.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新子节点(核心 diff 算法)
|
||||||
|
*/
|
||||||
|
function morphChildren(fromEl: Element, toEl: Element): void {
|
||||||
|
const fromChildren = Array.from(fromEl.childNodes);
|
||||||
|
const toChildren = Array.from(toEl.childNodes);
|
||||||
|
|
||||||
|
const fromLen = fromChildren.length;
|
||||||
|
const toLen = toChildren.length;
|
||||||
|
const minLen = Math.min(fromLen, toLen);
|
||||||
|
|
||||||
|
// 1. 更新公共部分
|
||||||
|
for (let i = 0; i < minLen; i++) {
|
||||||
|
morphNode(fromChildren[i], toChildren[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 移除多余的旧节点
|
||||||
|
if (fromLen > toLen) {
|
||||||
|
for (let i = fromLen - 1; i >= toLen; i--) {
|
||||||
|
fromEl.removeChild(fromChildren[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加新节点
|
||||||
|
if (toLen > fromLen) {
|
||||||
|
for (let i = fromLen; i < toLen; i++) {
|
||||||
|
fromEl.appendChild(toChildren[i].cloneNode(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优化版:使用 key 进行更智能的 diff(可选)
|
||||||
|
* 适用于有 data-key 属性的元素
|
||||||
|
*/
|
||||||
|
export function morphWithKeys(fromEl: Element, toEl: Element): void {
|
||||||
|
const toChildren = Array.from(toEl.children) as Element[];
|
||||||
|
|
||||||
|
// 构建 from 的 key 映射
|
||||||
|
const fromKeyMap = new Map<string, Element>();
|
||||||
|
Array.from(fromEl.children).forEach((child) => {
|
||||||
|
const key = child.getAttribute('data-key');
|
||||||
|
if (key) {
|
||||||
|
fromKeyMap.set(key, child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedKeys = new Set<string>();
|
||||||
|
|
||||||
|
// 按照 toChildren 的顺序处理
|
||||||
|
toChildren.forEach((toChild, toIndex) => {
|
||||||
|
const key = toChild.getAttribute('data-key');
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
processedKeys.add(key);
|
||||||
|
const fromChild = fromKeyMap.get(key);
|
||||||
|
|
||||||
|
if (fromChild) {
|
||||||
|
// 找到对应节点,更新内容
|
||||||
|
morphNode(fromChild, toChild);
|
||||||
|
|
||||||
|
// 确保节点在正确的位置
|
||||||
|
const currentNode = fromEl.children[toIndex];
|
||||||
|
if (currentNode !== fromChild) {
|
||||||
|
// 将 fromChild 移动到正确位置
|
||||||
|
fromEl.insertBefore(fromChild, currentNode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新节点,插入到正确位置
|
||||||
|
const currentNode = fromEl.children[toIndex];
|
||||||
|
fromEl.insertBefore(toChild.cloneNode(true), currentNode || null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除不再存在的节点(从后往前删除,避免索引问题)
|
||||||
|
const childrenToRemove: Element[] = [];
|
||||||
|
fromKeyMap.forEach((child, key) => {
|
||||||
|
if (!processedKeys.has(key)) {
|
||||||
|
childrenToRemove.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
childrenToRemove.forEach(child => {
|
||||||
|
fromEl.removeChild(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高级 API:直接从 HTML 字符串更新元素
|
||||||
|
*/
|
||||||
|
export function morphHTML(element: Element, htmlString: string): void {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = htmlString;
|
||||||
|
|
||||||
|
// 更新元素的子节点列表
|
||||||
|
morphChildren(element, tempContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新(使用 DocumentFragment)
|
||||||
|
*/
|
||||||
|
export function batchMorph(element: Element, htmlString: string): void {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = htmlString;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
Array.from(tempContainer.childNodes).forEach(node => {
|
||||||
|
fragment.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空原内容
|
||||||
|
while (element.firstChild) {
|
||||||
|
element.removeChild(element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
element.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
font-family: var(--voidraft-font-mono),serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-word {
|
.loading-word {
|
||||||
@@ -175,4 +175,4 @@ onBeforeUnmount(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
.menu-item:hover & {
|
.menu-item:hover & {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
|||||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const editorStore = readonly(useEditorStore());
|
const editorStore = useEditorStore();
|
||||||
|
|
||||||
// 组件状态
|
// 组件状态
|
||||||
const showLanguageMenu = shallowRef(false);
|
const showLanguageMenu = shallowRef(false);
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/langu
|
|||||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
|
||||||
const editorStore = readonly(useEditorStore());
|
const editorStore = useEditorStore();
|
||||||
const configStore = readonly(useConfigStore());
|
const configStore = useConfigStore();
|
||||||
const updateStore = readonly(useUpdateStore());
|
const updateStore = useUpdateStore();
|
||||||
const windowStore = readonly(useWindowStore());
|
const windowStore = useWindowStore();
|
||||||
const systemStore = readonly(useSystemStore());
|
const systemStore = useSystemStore();
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ const isCurrentWindowOnTop = computed(() => {
|
|||||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 切换窗口置顶状态
|
// 切换窗口置顶状态
|
||||||
const toggleAlwaysOnTop = async () => {
|
const toggleAlwaysOnTop = async () => {
|
||||||
const currentlyOnTop = isCurrentWindowOnTop.value;
|
const currentlyOnTop = isCurrentWindowOnTop.value;
|
||||||
@@ -60,9 +61,10 @@ const formatCurrentBlock = () => {
|
|||||||
formatBlockContent(editorStore.editorView);
|
formatBlockContent(editorStore.editorView);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化按钮状态更新 - 使用更高效的检查逻辑
|
|
||||||
const updateFormatButtonState = () => {
|
// 统一更新按钮状态
|
||||||
const view = editorStore.editorView;
|
const updateButtonStates = () => {
|
||||||
|
const view: any = editorStore.editorView;
|
||||||
if (!view) {
|
if (!view) {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
return;
|
return;
|
||||||
@@ -78,17 +80,19 @@ const updateFormatButtonState = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const language = getLanguage(activeBlock.language.name as any);
|
const languageName = activeBlock.language.name;
|
||||||
|
const language = getLanguage(languageName as any);
|
||||||
|
|
||||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error checking format capability:', error);
|
console.warn('Error checking block capabilities:', error);
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建带1s防抖的更新函数
|
// 创建带1s防抖的更新函数
|
||||||
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce(
|
const { debouncedFn: debouncedUpdateButtonStates, cancel: cancelDebounce } = createDebounce(
|
||||||
updateFormatButtonState,
|
updateButtonStates,
|
||||||
{ delay: 1000 }
|
{ delay: 1000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,9 +106,9 @@ const setupEditorListeners = (view: any) => {
|
|||||||
|
|
||||||
// 使用对象缓存事件处理器,避免重复创建
|
// 使用对象缓存事件处理器,避免重复创建
|
||||||
const eventHandlers = {
|
const eventHandlers = {
|
||||||
click: updateFormatButtonState,
|
click: updateButtonStates,
|
||||||
keyup: debouncedUpdateFormat,
|
keyup: debouncedUpdateButtonStates,
|
||||||
focus: updateFormatButtonState
|
focus: updateButtonStates
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const events = Object.entries(eventHandlers).map(([type, handler]) => ({
|
const events = Object.entries(eventHandlers).map(([type, handler]) => ({
|
||||||
@@ -131,7 +135,7 @@ watch(
|
|||||||
|
|
||||||
if (newView) {
|
if (newView) {
|
||||||
// 初始更新状态
|
// 初始更新状态
|
||||||
updateFormatButtonState();
|
updateButtonStates();
|
||||||
// 设置新监听器
|
// 设置新监听器
|
||||||
cleanupListeners = setupEditorListeners(newView);
|
cleanupListeners = setupEditorListeners(newView);
|
||||||
} else {
|
} else {
|
||||||
@@ -145,8 +149,8 @@ watch(
|
|||||||
// 组件生命周期
|
// 组件生命周期
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoaded.value = true;
|
isLoaded.value = true;
|
||||||
// 首次更新格式化状态
|
// 首次更新按钮状态
|
||||||
updateFormatButtonState();
|
updateButtonStates();
|
||||||
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
|
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,6 +516,42 @@ const statsData = computed(() => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: rgba(100, 149, 237, 0.2);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: #6495ed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
transition: stroke 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg {
|
||||||
|
stroke: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export default {
|
|||||||
searchLanguage: 'Search language...',
|
searchLanguage: 'Search language...',
|
||||||
noLanguageFound: 'No language found',
|
noLanguageFound: 'No language found',
|
||||||
formatHint: 'Click Format Block (Ctrl+Shift+F)',
|
formatHint: 'Click Format Block (Ctrl+Shift+F)',
|
||||||
|
previewMarkdown: 'Preview Markdown',
|
||||||
|
closePreview: 'Close Preview',
|
||||||
// Document selector
|
// Document selector
|
||||||
selectDocument: 'Select Document',
|
selectDocument: 'Select Document',
|
||||||
searchOrCreateDocument: 'Search or enter new document name...',
|
searchOrCreateDocument: 'Search or enter new document name...',
|
||||||
@@ -159,53 +161,6 @@ export default {
|
|||||||
customThemeColors: 'Custom Theme Colors',
|
customThemeColors: 'Custom Theme Colors',
|
||||||
resetToDefault: 'Reset to Default',
|
resetToDefault: 'Reset to Default',
|
||||||
colorValue: 'Color Value',
|
colorValue: 'Color Value',
|
||||||
themeColors: {
|
|
||||||
basic: 'Basic Colors',
|
|
||||||
text: 'Text Colors',
|
|
||||||
syntax: 'Syntax Highlighting',
|
|
||||||
interface: 'Interface Elements',
|
|
||||||
border: 'Borders & Dividers',
|
|
||||||
search: 'Search & Matching',
|
|
||||||
// Base Colors
|
|
||||||
background: 'Main Background',
|
|
||||||
backgroundSecondary: 'Secondary Background',
|
|
||||||
surface: 'Panel Background',
|
|
||||||
dropdownBackground: 'Dropdown Background',
|
|
||||||
dropdownBorder: 'Dropdown Border',
|
|
||||||
// Text Colors
|
|
||||||
foreground: 'Primary Text',
|
|
||||||
foregroundSecondary: 'Secondary Text',
|
|
||||||
comment: 'Comments',
|
|
||||||
// Syntax Highlighting - Core
|
|
||||||
keyword: 'Keywords',
|
|
||||||
string: 'Strings',
|
|
||||||
function: 'Functions',
|
|
||||||
number: 'Numbers',
|
|
||||||
operator: 'Operators',
|
|
||||||
variable: 'Variables',
|
|
||||||
type: 'Types',
|
|
||||||
// Syntax Highlighting - Extended
|
|
||||||
constant: 'Constants',
|
|
||||||
storage: 'Storage Type',
|
|
||||||
parameter: 'Parameters',
|
|
||||||
class: 'Class Names',
|
|
||||||
heading: 'Headings',
|
|
||||||
invalid: 'Invalid/Error',
|
|
||||||
regexp: 'Regular Expressions',
|
|
||||||
// Interface Elements
|
|
||||||
cursor: 'Cursor',
|
|
||||||
selection: 'Selection Background',
|
|
||||||
selectionBlur: 'Unfocused Selection',
|
|
||||||
activeLine: 'Active Line Highlight',
|
|
||||||
lineNumber: 'Line Numbers',
|
|
||||||
activeLineNumber: 'Active Line Number',
|
|
||||||
// Borders & Dividers
|
|
||||||
borderColor: 'Border Color',
|
|
||||||
borderLight: 'Light Border',
|
|
||||||
// Search & Matching
|
|
||||||
searchMatch: 'Search Match',
|
|
||||||
matchingBracket: 'Matching Bracket'
|
|
||||||
},
|
|
||||||
lineHeight: 'Line Height',
|
lineHeight: 'Line Height',
|
||||||
tabSettings: 'Tab Settings',
|
tabSettings: 'Tab Settings',
|
||||||
tabSize: 'Tab Size',
|
tabSize: 'Tab Size',
|
||||||
@@ -339,4 +294,4 @@ export default {
|
|||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
clickToClean: 'Click to clean memory'
|
clickToClean: 'Click to clean memory'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export default {
|
|||||||
searchLanguage: '搜索语言...',
|
searchLanguage: '搜索语言...',
|
||||||
noLanguageFound: '未找到匹配的语言',
|
noLanguageFound: '未找到匹配的语言',
|
||||||
formatHint: '点击格式化区块(Ctrl+Shift+F)',
|
formatHint: '点击格式化区块(Ctrl+Shift+F)',
|
||||||
|
previewMarkdown: '预览 Markdown',
|
||||||
|
closePreview: '关闭预览',
|
||||||
// 文档选择器
|
// 文档选择器
|
||||||
selectDocument: '选择文档',
|
selectDocument: '选择文档',
|
||||||
searchOrCreateDocument: '搜索或输入新文档名...',
|
searchOrCreateDocument: '搜索或输入新文档名...',
|
||||||
@@ -200,54 +202,6 @@ export default {
|
|||||||
customThemeColors: '自定义主题颜色',
|
customThemeColors: '自定义主题颜色',
|
||||||
resetToDefault: '重置为默认',
|
resetToDefault: '重置为默认',
|
||||||
colorValue: '颜色值',
|
colorValue: '颜色值',
|
||||||
themeColors: {
|
|
||||||
basic: '基础色调',
|
|
||||||
text: '文本颜色',
|
|
||||||
syntax: '语法高亮',
|
|
||||||
interface: '界面元素',
|
|
||||||
border: '边框分割线',
|
|
||||||
search: '搜索匹配',
|
|
||||||
// 基础色调
|
|
||||||
background: '主背景色',
|
|
||||||
backgroundSecondary: '次要背景色',
|
|
||||||
surface: '面板背景',
|
|
||||||
dropdownBackground: '下拉菜单背景',
|
|
||||||
dropdownBorder: '下拉菜单边框',
|
|
||||||
// 文本颜色
|
|
||||||
foreground: '主文本色',
|
|
||||||
foregroundSecondary: '次要文本色',
|
|
||||||
comment: '注释色',
|
|
||||||
// 语法高亮 - 核心
|
|
||||||
keyword: '关键字',
|
|
||||||
string: '字符串',
|
|
||||||
function: '函数名',
|
|
||||||
number: '数字',
|
|
||||||
operator: '操作符',
|
|
||||||
variable: '变量',
|
|
||||||
type: '类型',
|
|
||||||
// 语法高亮 - 扩展
|
|
||||||
constant: '常量',
|
|
||||||
storage: '存储类型',
|
|
||||||
parameter: '参数',
|
|
||||||
class: '类名',
|
|
||||||
heading: '标题',
|
|
||||||
invalid: '无效内容',
|
|
||||||
regexp: '正则表达式',
|
|
||||||
// 界面元素
|
|
||||||
cursor: '光标',
|
|
||||||
selection: '选中背景',
|
|
||||||
selectionBlur: '失焦选中背景',
|
|
||||||
activeLine: '当前行高亮',
|
|
||||||
lineNumber: '行号',
|
|
||||||
activeLineNumber: '活动行号',
|
|
||||||
// 边框和分割线
|
|
||||||
borderColor: '边框色',
|
|
||||||
borderLight: '浅色边框',
|
|
||||||
// 搜索和匹配
|
|
||||||
searchMatch: '搜索匹配',
|
|
||||||
matchingBracket: '匹配括号'
|
|
||||||
},
|
|
||||||
|
|
||||||
hotkeyPreview: '预览:',
|
hotkeyPreview: '预览:',
|
||||||
none: '无',
|
none: '无',
|
||||||
backup: {
|
backup: {
|
||||||
@@ -342,4 +296,4 @@ export default {
|
|||||||
memory: '内存',
|
memory: '内存',
|
||||||
clickToClean: '点击清理内存'
|
clickToClean: '点击清理内存'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
|||||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {
|
import {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AppearanceConfig,
|
|
||||||
AuthMethod,
|
AuthMethod,
|
||||||
EditingConfig,
|
EditingConfig,
|
||||||
GeneralConfig,
|
|
||||||
GitBackupConfig,
|
|
||||||
LanguageType,
|
LanguageType,
|
||||||
SystemThemeType,
|
SystemThemeType,
|
||||||
TabType,
|
TabType
|
||||||
UpdatesConfig
|
|
||||||
} from '@/../bindings/voidraft/internal/models/models';
|
} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||||
import {
|
import {
|
||||||
APPEARANCE_CONFIG_KEY_MAP,
|
CONFIG_KEY_MAP,
|
||||||
BACKUP_CONFIG_KEY_MAP,
|
|
||||||
CONFIG_LIMITS,
|
CONFIG_LIMITS,
|
||||||
|
ConfigKey,
|
||||||
|
ConfigSection,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
EDITING_CONFIG_KEY_MAP,
|
NumberConfigKey
|
||||||
GENERAL_CONFIG_KEY_MAP,
|
|
||||||
NumberConfigKey,
|
|
||||||
UPDATES_CONFIG_KEY_MAP
|
|
||||||
} from '@/common/constant/config';
|
} from '@/common/constant/config';
|
||||||
import * as runtime from '@wailsio/runtime';
|
import * as runtime from '@wailsio/runtime';
|
||||||
|
|
||||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
// Font options (no longer localized)
|
// Font options (no longer localized)
|
||||||
const fontOptions = computed(() => FONT_OPTIONS);
|
const fontOptions = computed(() => FONT_OPTIONS);
|
||||||
|
|
||||||
// 计算属性 - 使用工厂函数简化
|
// 计算属性
|
||||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||||
const limits = Object.fromEntries(
|
const limits = Object.fromEntries(
|
||||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
||||||
|
|
||||||
// 通用配置更新方法
|
// 统一配置更新方法
|
||||||
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
|
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
|
||||||
// 确保配置已加载
|
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
if (!state.configLoaded && !state.isLoading) {
|
||||||
await initConfig();
|
await initConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
if (!backendKey) {
|
if (!backendKey) {
|
||||||
throw new Error(`No backend key mapping found for general.${key.toString()}`);
|
throw new Error(`No backend key mapping found for ${String(key)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general')
|
||||||
|
const section = backendKey.split('.')[0] as ConfigSection;
|
||||||
|
|
||||||
await ConfigService.Set(backendKey, value);
|
await ConfigService.Set(backendKey, value);
|
||||||
state.config.general[key] = value;
|
(state.config[section] as any)[key] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
|
// 只更新本地状态,不保存到后端
|
||||||
// 确保配置已加载
|
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
await initConfig();
|
const section = backendKey.split('.')[0] as ConfigSection;
|
||||||
}
|
(state.config[section] as any)[key] = value;
|
||||||
|
|
||||||
const backendKey = EDITING_CONFIG_KEY_MAP[key];
|
|
||||||
if (!backendKey) {
|
|
||||||
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConfigService.Set(backendKey, value);
|
|
||||||
state.config.editing[key] = value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
// 保存指定配置到后端
|
||||||
// 确保配置已加载
|
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
const backendKey = CONFIG_KEY_MAP[key];
|
||||||
await initConfig();
|
const section = backendKey.split('.')[0] as ConfigSection;
|
||||||
}
|
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||||
|
|
||||||
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
|
|
||||||
if (!backendKey) {
|
|
||||||
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConfigService.Set(backendKey, value);
|
|
||||||
state.config.appearance[key] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
|
|
||||||
// 确保配置已加载
|
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
|
||||||
await initConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
|
|
||||||
if (!backendKey) {
|
|
||||||
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConfigService.Set(backendKey, value);
|
|
||||||
state.config.updates[key] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
|
|
||||||
// 确保配置已加载
|
|
||||||
if (!state.configLoaded && !state.isLoading) {
|
|
||||||
await initConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
|
|
||||||
if (!backendKey) {
|
|
||||||
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConfigService.Set(backendKey, value);
|
|
||||||
state.config.backup[key] = value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||||
reset: async () => await updateEditingConfig(key, limit.default)
|
reset: async () => await updateConfig(key, limit.default),
|
||||||
|
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
|
||||||
|
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
||||||
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
|
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
|
||||||
|
|
||||||
// 枚举值切换器
|
// 枚举值切换器
|
||||||
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||||
async () => {
|
async () => {
|
||||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||||
const nextIndex = (currentIndex + 1) % values.length;
|
const nextIndex = (currentIndex + 1) % values.length;
|
||||||
return await updateEditingConfig(key, values[nextIndex]);
|
return await updateConfig(key, values[nextIndex]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置配置
|
// 重置配置
|
||||||
@@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
// 语言设置方法
|
// 语言设置方法
|
||||||
const setLanguage = async (language: LanguageType): Promise<void> => {
|
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||||
await updateAppearanceConfig('language', language);
|
await updateConfig('language', language);
|
||||||
|
|
||||||
// 同步更新前端语言
|
|
||||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||||
locale.value = frontendLocale as any;
|
locale.value = frontendLocale as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 系统主题设置方法
|
// 系统主题设置方法
|
||||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
await updateConfig('systemTheme', systemTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 当前主题设置方法
|
// 当前主题设置方法
|
||||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||||
await updateAppearanceConfig('currentTheme', themeName);
|
await updateConfig('currentTheme', themeName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const togglers = {
|
const togglers = {
|
||||||
tabIndent: createEditingToggler('enableTabIndent'),
|
tabIndent: createEditingToggler('enableTabIndent'),
|
||||||
alwaysOnTop: async () => {
|
alwaysOnTop: async () => {
|
||||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||||
// 立即应用窗口置顶状态
|
|
||||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||||
},
|
},
|
||||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 字符串配置设置器
|
|
||||||
const setters = {
|
|
||||||
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
|
|
||||||
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
|
|
||||||
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
|
|
||||||
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
config: computed(() => state.config),
|
config: computed(() => state.config),
|
||||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
decreaseFontSize: adjusters.fontSize.decrease,
|
decreaseFontSize: adjusters.fontSize.decrease,
|
||||||
resetFontSize: adjusters.fontSize.reset,
|
resetFontSize: adjusters.fontSize.reset,
|
||||||
setFontSize: adjusters.fontSize.set,
|
setFontSize: adjusters.fontSize.set,
|
||||||
|
// 字体大小操作
|
||||||
|
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||||
|
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||||
|
saveFontSize: () => saveConfig('fontSize'),
|
||||||
|
|
||||||
// Tab操作
|
// Tab操作
|
||||||
toggleTabIndent: togglers.tabIndent,
|
toggleTabIndent: togglers.tabIndent,
|
||||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||||
...adjusters.tabSize,
|
...adjusters.tabSize,
|
||||||
increaseTabSize: adjusters.tabSize.increase,
|
increaseTabSize: adjusters.tabSize.increase,
|
||||||
decreaseTabSize: adjusters.tabSize.decrease,
|
decreaseTabSize: adjusters.tabSize.decrease,
|
||||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
// 窗口操作
|
// 窗口操作
|
||||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||||
|
|
||||||
// 字体操作
|
// 字体操作
|
||||||
setFontFamily: setters.fontFamily,
|
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||||
setFontWeight: setters.fontWeight,
|
setFontWeight: (value: string) => updateConfig('fontWeight', value),
|
||||||
|
|
||||||
// 路径操作
|
// 路径操作
|
||||||
setDataPath: setters.dataPath,
|
setDataPath: (value: string) => updateConfig('dataPath', value),
|
||||||
|
|
||||||
// 保存配置相关方法
|
// 保存配置相关方法
|
||||||
setAutoSaveDelay: setters.autoSaveDelay,
|
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
|
||||||
|
|
||||||
// 热键配置相关方法
|
// 热键配置相关方法
|
||||||
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
|
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||||
|
|
||||||
// 系统托盘配置相关方法
|
// 系统托盘配置相关方法
|
||||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||||
|
|
||||||
// 开机启动配置相关方法
|
// 开机启动配置相关方法
|
||||||
setStartAtLogin: async (value: boolean) => {
|
setStartAtLogin: async (value: boolean) => {
|
||||||
// 先更新配置文件
|
await updateConfig('startAtLogin', value);
|
||||||
await updateGeneralConfig('startAtLogin', value);
|
|
||||||
// 再调用系统设置API
|
|
||||||
await StartupService.SetEnabled(value);
|
await StartupService.SetEnabled(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 窗口吸附配置相关方法
|
// 窗口吸附配置相关方法
|
||||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
|
||||||
|
|
||||||
// 加载动画配置相关方法
|
// 加载动画配置相关方法
|
||||||
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
|
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
|
||||||
|
|
||||||
// 标签页配置相关方法
|
// 标签页配置相关方法
|
||||||
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
|
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
|
||||||
|
|
||||||
// 更新配置相关方法
|
// 更新配置相关方法
|
||||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
|
||||||
|
|
||||||
// 备份配置相关方法
|
// 备份配置相关方法
|
||||||
setEnableBackup: async (value: boolean) => {
|
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||||
await updateBackupConfig('enabled', value);
|
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||||
},
|
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||||
setAutoBackup: async (value: boolean) => {
|
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||||
await updateBackupConfig('auto_backup', value);
|
setUsername: (value: string) => updateConfig('username', value),
|
||||||
},
|
setPassword: (value: string) => updateConfig('password', value),
|
||||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
setToken: (value: string) => updateConfig('token', value),
|
||||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
|
||||||
setToken: async (value: string) => await updateBackupConfig('token', value),
|
|
||||||
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
|
|
||||||
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
|
|
||||||
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
|
|||||||
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
||||||
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {useTabStore} from "@/stores/tabStore";
|
import {useTabStore} from "@/stores/tabStore";
|
||||||
|
import type {EditorViewState} from '@/stores/editorStore';
|
||||||
|
|
||||||
export const useDocumentStore = defineStore('document', () => {
|
export const useDocumentStore = defineStore('document', () => {
|
||||||
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
|
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
|
||||||
@@ -13,6 +14,10 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
const currentDocumentId = ref<number | null>(null);
|
const currentDocumentId = ref<number | null>(null);
|
||||||
const currentDocument = ref<Document | null>(null);
|
const currentDocument = ref<Document | null>(null);
|
||||||
|
|
||||||
|
// === 编辑器状态持久化 ===
|
||||||
|
// 修复:使用统一的 EditorViewState 类型定义
|
||||||
|
const documentStates = ref<Record<number, EditorViewState>>({});
|
||||||
|
|
||||||
// === UI状态 ===
|
// === UI状态 ===
|
||||||
const showDocumentSelector = ref(false);
|
const showDocumentSelector = ref(false);
|
||||||
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
||||||
@@ -218,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
documentList,
|
documentList,
|
||||||
currentDocumentId,
|
currentDocumentId,
|
||||||
currentDocument,
|
currentDocument,
|
||||||
|
documentStates,
|
||||||
showDocumentSelector,
|
showDocumentSelector,
|
||||||
selectorError,
|
selectorError,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -240,6 +246,6 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
persist: {
|
persist: {
|
||||||
key: 'voidraft-document',
|
key: 'voidraft-document',
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
pick: ['currentDocumentId', 'documents']
|
pick: ['currentDocumentId', 'documents', 'documentStates']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4,8 +4,7 @@ import {EditorView} from '@codemirror/view';
|
|||||||
import {EditorState, Extension} from '@codemirror/state';
|
import {EditorState, Extension} from '@codemirror/state';
|
||||||
import {useConfigStore} from './configStore';
|
import {useConfigStore} from './configStore';
|
||||||
import {useDocumentStore} from './documentStore';
|
import {useDocumentStore} from './documentStore';
|
||||||
import {useThemeStore} from './themeStore';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {ExtensionID, SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {ensureSyntaxTree} from "@codemirror/language";
|
import {ensureSyntaxTree} from "@codemirror/language";
|
||||||
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
|
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
|
||||||
@@ -14,16 +13,24 @@ 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 {createDynamicExtensions, getExtensionManager, setExtensionManagerView, removeExtensionManagerView} from '@/views/editor/manager';
|
import {
|
||||||
|
createDynamicExtensions,
|
||||||
|
getExtensionManager,
|
||||||
|
removeExtensionManagerView,
|
||||||
|
setExtensionManagerView
|
||||||
|
} from '@/views/editor/manager';
|
||||||
import {useExtensionStore} from './extensionStore';
|
import {useExtensionStore} from './extensionStore';
|
||||||
import createCodeBlockExtension from "@/views/editor/extensions/codeblock";
|
import createCodeBlockExtension, {blockState} from "@/views/editor/extensions/codeblock";
|
||||||
import {LruCache} from '@/common/utils/lruCache';
|
import {LruCache} from '@/common/utils/lruCache';
|
||||||
import {AsyncManager} from '@/common/utils/asyncManager';
|
import {AsyncManager} from '@/common/utils/asyncManager';
|
||||||
import {generateContentHash} from "@/common/utils/hashUtils";
|
import {generateContentHash} from "@/common/utils/hashUtils";
|
||||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||||
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
import markdownExtensions from "@/views/editor/extensions/markdown";
|
||||||
|
|
||||||
export interface DocumentStats {
|
export interface DocumentStats {
|
||||||
lines: number;
|
lines: number;
|
||||||
@@ -31,6 +38,11 @@ export interface DocumentStats {
|
|||||||
selectedCharacters: number;
|
selectedCharacters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
|
||||||
|
export interface EditorViewState {
|
||||||
|
cursorPos: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorInstance {
|
interface EditorInstance {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@@ -43,13 +55,14 @@ interface EditorInstance {
|
|||||||
lastContentHash: string;
|
lastContentHash: string;
|
||||||
lastParsed: Date;
|
lastParsed: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
// 修复:使用统一的类型,可选但不是 undefined | {...}
|
||||||
|
editorState?: EditorViewState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = defineStore('editor', () => {
|
export const useEditorStore = defineStore('editor', () => {
|
||||||
// === 依赖store ===
|
// === 依赖store ===
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const extensionStore = useExtensionStore();
|
const extensionStore = useExtensionStore();
|
||||||
|
|
||||||
// === 核心状态 ===
|
// === 核心状态 ===
|
||||||
@@ -65,6 +78,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
// 编辑器加载状态
|
// 编辑器加载状态
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
// 修复:使用操作计数器精确管理加载状态
|
||||||
|
const loadingOperations = ref(0);
|
||||||
|
|
||||||
// 异步操作管理器
|
// 异步操作管理器
|
||||||
const operationManager = new AsyncManager<number>();
|
const operationManager = new AsyncManager<number>();
|
||||||
@@ -72,8 +87,92 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 自动保存设置 - 从配置动态获取
|
// 自动保存设置 - 从配置动态获取
|
||||||
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
|
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
|
||||||
|
|
||||||
|
// 创建防抖的语法树缓存清理函数
|
||||||
|
const debouncedClearSyntaxCache = createDebounce((instance) => {
|
||||||
|
if (instance) {
|
||||||
|
instance.syntaxTreeCache = null;
|
||||||
|
}
|
||||||
|
}, { delay: 500 }); // 500ms 内的多次输入只清理一次
|
||||||
|
|
||||||
// === 私有方法 ===
|
// === 私有方法 ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在代码块分隔符区域内
|
||||||
|
*/
|
||||||
|
const isPositionInDelimiter = (view: EditorView, pos: number): boolean => {
|
||||||
|
try {
|
||||||
|
const blocks = view.state.field(blockState, false);
|
||||||
|
if (!blocks) return false;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整光标位置到有效的内容区域
|
||||||
|
* 如果位置在分隔符内,移动到该块的内容开始位置
|
||||||
|
*/
|
||||||
|
const adjustCursorPosition = (view: EditorView, pos: number): number => {
|
||||||
|
try {
|
||||||
|
const blocks = view.state.field(blockState, false);
|
||||||
|
if (!blocks || blocks.length === 0) return pos;
|
||||||
|
|
||||||
|
// 如果位置在分隔符内,移动到该块的内容开始位置
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
|
||||||
|
return block.content.from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
} catch {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复编辑器的光标位置(自动滚动到光标处)
|
||||||
|
*/
|
||||||
|
const restoreEditorState = (instance: EditorInstance, documentId: number): void => {
|
||||||
|
const savedState = instance.editorState;
|
||||||
|
|
||||||
|
if (savedState) {
|
||||||
|
// 有保存的状态,恢复光标位置
|
||||||
|
let pos = Math.min(savedState.cursorPos, instance.view.state.doc.length);
|
||||||
|
|
||||||
|
// 确保位置不在分隔符上
|
||||||
|
if (isPositionInDelimiter(instance.view, pos)) {
|
||||||
|
pos = adjustCursorPosition(instance.view, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复:设置光标位置并居中滚动(更好的用户体验)
|
||||||
|
instance.view.dispatch({
|
||||||
|
selection: {anchor: pos, head: pos},
|
||||||
|
effects: EditorView.scrollIntoView(pos, {
|
||||||
|
y: "center", // 垂直居中显示
|
||||||
|
yMargin: 100 // 上下留一些边距
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 首次打开或没有记录,光标在文档末尾
|
||||||
|
const docLength = instance.view.state.doc.length;
|
||||||
|
instance.view.dispatch({
|
||||||
|
selection: {anchor: docLength, head: docLength},
|
||||||
|
effects: EditorView.scrollIntoView(docLength, {
|
||||||
|
y: "center",
|
||||||
|
yMargin: 100
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 缓存化的语法树确保方法
|
// 缓存化的语法树确保方法
|
||||||
const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => {
|
const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => {
|
||||||
const instance = editorCache.get(documentId);
|
const instance = editorCache.get(documentId);
|
||||||
@@ -143,6 +242,13 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
fontWeight: configStore.config.editing.fontWeight
|
fontWeight: configStore.config.editing.fontWeight
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wheelZoomExtension = createWheelZoomExtension({
|
||||||
|
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||||
|
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||||
|
onSave: () => configStore.saveFontSize(),
|
||||||
|
saveDelay: 500
|
||||||
|
});
|
||||||
|
|
||||||
// 统计扩展
|
// 统计扩展
|
||||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||||
|
|
||||||
@@ -157,6 +263,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
const httpExtension = createHttpClientExtension();
|
const httpExtension = createHttpClientExtension();
|
||||||
|
|
||||||
|
|
||||||
// 再次检查操作有效性
|
// 再次检查操作有效性
|
||||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||||
throw new Error('Operation cancelled');
|
throw new Error('Operation cancelled');
|
||||||
@@ -185,11 +292,13 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
themeExtension,
|
themeExtension,
|
||||||
...tabExtensions,
|
...tabExtensions,
|
||||||
fontExtension,
|
fontExtension,
|
||||||
|
wheelZoomExtension,
|
||||||
statsExtension,
|
statsExtension,
|
||||||
contentChangeExtension,
|
contentChangeExtension,
|
||||||
codeBlockExtension,
|
codeBlockExtension,
|
||||||
...dynamicExtensions,
|
...dynamicExtensions,
|
||||||
...httpExtension
|
...httpExtension,
|
||||||
|
markdownExtensions
|
||||||
];
|
];
|
||||||
|
|
||||||
// 创建编辑器状态
|
// 创建编辑器状态
|
||||||
@@ -198,19 +307,9 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
extensions
|
extensions
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建编辑器视图
|
return new EditorView({
|
||||||
const view = new EditorView({
|
|
||||||
state
|
state
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将光标定位到文档末尾并滚动到该位置
|
|
||||||
const docLength = view.state.doc.length;
|
|
||||||
view.dispatch({
|
|
||||||
selection: {anchor: docLength, head: docLength},
|
|
||||||
scrollIntoView: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return view;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加编辑器到缓存
|
// 添加编辑器到缓存
|
||||||
@@ -222,7 +321,9 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
isDirty: false,
|
isDirty: false,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
autoSaveTimer: createTimerManager(),
|
autoSaveTimer: createTimerManager(),
|
||||||
syntaxTreeCache: null
|
syntaxTreeCache: null,
|
||||||
|
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
|
||||||
|
editorState: documentStore.documentStates[documentId]
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用LRU缓存的onEvict回调处理被驱逐的实例
|
// 使用LRU缓存的onEvict回调处理被驱逐的实例
|
||||||
@@ -260,10 +361,19 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 创建新的编辑器实例
|
// 创建新的编辑器实例
|
||||||
const view = await createEditorInstance(content, operationId, documentId);
|
const view = await createEditorInstance(content, operationId, documentId);
|
||||||
|
|
||||||
// 最终检查操作有效性
|
// 完善取消操作时的清理逻辑
|
||||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||||
// 如果操作已取消,清理创建的实例
|
// 如果操作已取消,彻底清理创建的实例
|
||||||
view.destroy();
|
try {
|
||||||
|
// 移除 DOM 元素(如果已添加到文档)
|
||||||
|
if (view.dom && view.dom.parentElement) {
|
||||||
|
view.dom.remove();
|
||||||
|
}
|
||||||
|
// 销毁编辑器视图
|
||||||
|
view.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cleaning up cancelled editor:', error);
|
||||||
|
}
|
||||||
throw new Error('Operation cancelled');
|
throw new Error('Operation cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +393,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
currentEditor.value.dom.remove();
|
currentEditor.value.dom.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保容器为空
|
|
||||||
containerElement.value.innerHTML = '';
|
|
||||||
|
|
||||||
// 将目标编辑器DOM添加到容器
|
// 将目标编辑器DOM添加到容器
|
||||||
containerElement.value.appendChild(instance.view.dom);
|
containerElement.value.appendChild(instance.view.dom);
|
||||||
currentEditor.value = instance.view;
|
currentEditor.value = instance.view;
|
||||||
@@ -293,20 +400,18 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 设置扩展管理器视图
|
// 设置扩展管理器视图
|
||||||
setExtensionManagerView(instance.view, documentId);
|
setExtensionManagerView(instance.view, documentId);
|
||||||
|
|
||||||
// 重新测量和聚焦编辑器
|
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// 将光标定位到文档末尾并滚动到该位置
|
requestAnimationFrame(() => {
|
||||||
const docLength = instance.view.state.doc.length;
|
// 恢复编辑器状态(光标位置和滚动位置)
|
||||||
instance.view.dispatch({
|
restoreEditorState(instance, documentId);
|
||||||
selection: {anchor: docLength, head: docLength},
|
|
||||||
scrollIntoView: true
|
// 聚焦编辑器
|
||||||
|
instance.view.focus();
|
||||||
|
|
||||||
|
// 使用缓存的语法树确保方法
|
||||||
|
ensureSyntaxTreeCached(instance.view, documentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 滚动到文档底部(将光标位置滚动到可见区域)
|
|
||||||
instance.view.focus();
|
|
||||||
|
|
||||||
// 使用缓存的语法树确保方法
|
|
||||||
ensureSyntaxTreeCached(instance.view, documentId);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error showing editor:', error);
|
console.error('Error showing editor:', error);
|
||||||
@@ -340,17 +445,20 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 内容变化处理
|
// 内容变化处理
|
||||||
const onContentChange = (documentId: number) => {
|
const onContentChange = () => {
|
||||||
|
const documentId = documentStore.currentDocumentId;
|
||||||
|
if (!documentId) return;
|
||||||
const instance = editorCache.get(documentId);
|
const instance = editorCache.get(documentId);
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
|
|
||||||
|
// 立即设置脏标记和修改时间(切换文档时需要判断)
|
||||||
instance.isDirty = true;
|
instance.isDirty = true;
|
||||||
instance.lastModified = new Date();
|
instance.lastModified = new Date();
|
||||||
|
|
||||||
// 清理语法树缓存,下次访问时重新构建
|
// 优使用防抖清理语法树缓存
|
||||||
instance.syntaxTreeCache = null;
|
debouncedClearSyntaxCache.debouncedFn(instance);
|
||||||
|
|
||||||
// 设置自动保存定时器
|
// 设置自动保存定时器(已经是防抖效果:每次重置定时器)
|
||||||
instance.autoSaveTimer.set(() => {
|
instance.autoSaveTimer.set(() => {
|
||||||
saveEditorContent(documentId);
|
saveEditorContent(documentId);
|
||||||
}, getAutoSaveDelay());
|
}, getAutoSaveDelay());
|
||||||
@@ -370,7 +478,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
// 加载编辑器
|
// 加载编辑器
|
||||||
const loadEditor = async (documentId: number, content: string) => {
|
const loadEditor = async (documentId: number, content: string) => {
|
||||||
// 设置加载状态
|
// 修复:使用计数器精确管理加载状态
|
||||||
|
loadingOperations.value++;
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
// 开始新的操作
|
// 开始新的操作
|
||||||
@@ -419,6 +528,9 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
instance.isDirty = false;
|
instance.isDirty = false;
|
||||||
// 清理语法树缓存,因为内容已更新
|
// 清理语法树缓存,因为内容已更新
|
||||||
instance.syntaxTreeCache = null;
|
instance.syntaxTreeCache = null;
|
||||||
|
// 修复:内容变了,清空光标位置,避免越界
|
||||||
|
instance.editorState = undefined;
|
||||||
|
delete documentStore.documentStates[documentId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,15 +552,20 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 完成操作
|
// 完成操作
|
||||||
operationManager.completeOperation(operationId);
|
operationManager.completeOperation(operationId);
|
||||||
|
|
||||||
// 延迟一段时间后再取消加载状态
|
// 修复:使用计数器精确管理加载状态,避免快速切换时状态不准确
|
||||||
|
loadingOperations.value--;
|
||||||
|
// 延迟一段时间后再取消加载状态,但要确保所有操作都完成了
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoading.value = false;
|
if (loadingOperations.value <= 0) {
|
||||||
|
loadingOperations.value = 0;
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
}, EDITOR_CONFIG.LOADING_DELAY);
|
}, EDITOR_CONFIG.LOADING_DELAY);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 移除编辑器
|
// 移除编辑器
|
||||||
const removeEditor = (documentId: number) => {
|
const removeEditor = async (documentId: number) => {
|
||||||
const instance = editorCache.get(documentId);
|
const instance = editorCache.get(documentId);
|
||||||
if (instance) {
|
if (instance) {
|
||||||
try {
|
try {
|
||||||
@@ -457,6 +574,20 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
operationManager.cancelAllOperations();
|
operationManager.cancelAllOperations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修复:移除前先保存内容(如果有未保存的修改)
|
||||||
|
if (instance.isDirty) {
|
||||||
|
await saveEditorContent(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存光标位置
|
||||||
|
if (instance.view && instance.view.state) {
|
||||||
|
const currentState: EditorViewState = {
|
||||||
|
cursorPos: instance.view.state.selection.main.head
|
||||||
|
};
|
||||||
|
// 保存到 documentStore 用于持久化
|
||||||
|
documentStore.documentStates[documentId] = currentState;
|
||||||
|
}
|
||||||
|
|
||||||
// 清除自动保存定时器
|
// 清除自动保存定时器
|
||||||
instance.autoSaveTimer.clear();
|
instance.autoSaveTimer.clear();
|
||||||
|
|
||||||
@@ -510,6 +641,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 应用Tab设置
|
// 应用Tab设置
|
||||||
const applyTabSettings = () => {
|
const applyTabSettings = () => {
|
||||||
editorCache.values().forEach(instance => {
|
editorCache.values().forEach(instance => {
|
||||||
@@ -538,6 +670,16 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
operationManager.cancelAllOperations();
|
operationManager.cancelAllOperations();
|
||||||
|
|
||||||
editorCache.clear((_documentId, instance) => {
|
editorCache.clear((_documentId, instance) => {
|
||||||
|
// 修复:清空前只保存光标位置
|
||||||
|
if (instance.view) {
|
||||||
|
const currentState: EditorViewState = {
|
||||||
|
cursorPos: instance.view.state.selection.main.head
|
||||||
|
};
|
||||||
|
// 同时保存到实例和 documentStore
|
||||||
|
instance.editorState = currentState;
|
||||||
|
documentStore.documentStates[instance.documentId] = currentState;
|
||||||
|
}
|
||||||
|
|
||||||
// 清除自动保存定时器
|
// 清除自动保存定时器
|
||||||
instance.autoSaveTimer.clear();
|
instance.autoSaveTimer.clear();
|
||||||
|
|
||||||
@@ -551,7 +693,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 销毁编辑器
|
// 销毁编辑器
|
||||||
instance.view.destroy();
|
instance.view.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
currentEditor.value = null;
|
currentEditor.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -568,23 +710,38 @@ 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();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听文档切换
|
// 监听文档切换
|
||||||
watch(() => documentStore.currentDocument, async (newDoc) => {
|
watch(() => documentStore.currentDocument, async (newDoc, oldDoc) => {
|
||||||
if (newDoc && containerElement.value) {
|
if (newDoc && containerElement.value) {
|
||||||
// 使用 nextTick 确保DOM更新完成后再加载编辑器
|
// 修复:在切换到新文档前,只保存旧文档的光标位置
|
||||||
await nextTick(() => {
|
if (oldDoc && oldDoc.id !== newDoc.id && currentEditor.value) {
|
||||||
loadEditor(newDoc.id, newDoc.content);
|
const oldInstance = editorCache.get(oldDoc.id);
|
||||||
});
|
if (oldInstance) {
|
||||||
|
const currentState: EditorViewState = {
|
||||||
|
cursorPos: currentEditor.value.state.selection.main.head
|
||||||
|
};
|
||||||
|
// 同时保存到实例和 documentStore
|
||||||
|
oldInstance.editorState = currentState;
|
||||||
|
documentStore.documentStates[oldDoc.id] = currentState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待 DOM 更新完成,再加载新文档的编辑器
|
||||||
|
await nextTick();
|
||||||
|
loadEditor(newDoc.id, newDoc.content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -630,4 +787,4 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
editorView: currentEditor,
|
editorView: currentEditor,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,195 +1,159 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
|
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
|
||||||
import { ThemeService } from '@/../bindings/voidraft/internal/services';
|
import { ThemeService } from '@/../bindings/voidraft/internal/services';
|
||||||
import { useConfigStore } from './configStore';
|
import { useConfigStore } from './configStore';
|
||||||
import { useEditorStore } from './editorStore';
|
import { useEditorStore } from './editorStore';
|
||||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||||
|
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
||||||
|
|
||||||
|
type ThemeOption = { name: string; type: ThemeType };
|
||||||
|
|
||||||
|
const resolveThemeName = (name?: string) =>
|
||||||
|
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
|
||||||
|
|
||||||
|
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
|
||||||
|
themePresetList
|
||||||
|
.filter(preset => preset.type === type)
|
||||||
|
.map(preset => ({ name: preset.name, type: preset.type }));
|
||||||
|
|
||||||
|
const darkThemeOptions = createThemeOptions(ThemeType.ThemeTypeDark);
|
||||||
|
const lightThemeOptions = createThemeOptions(ThemeType.ThemeTypeLight);
|
||||||
|
|
||||||
|
const cloneColors = (colors: ThemeColorConfig): ThemeColors =>
|
||||||
|
JSON.parse(JSON.stringify(colors)) as ThemeColors;
|
||||||
|
|
||||||
|
const getPresetColors = (name: string): ThemeColors => {
|
||||||
|
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
|
||||||
|
const colors = cloneThemeColors(preset.colors);
|
||||||
|
colors.themeName = name;
|
||||||
|
return colors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
|
||||||
|
const safeName = resolveThemeName(themeName);
|
||||||
|
try {
|
||||||
|
const theme = await ThemeService.GetThemeByName(safeName);
|
||||||
|
if (theme?.colors) {
|
||||||
|
const colors = cloneColors(theme.colors);
|
||||||
|
colors.themeName = safeName;
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load theme override:', error);
|
||||||
|
}
|
||||||
|
return getPresetColors(safeName);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 主题管理 Store
|
|
||||||
* 职责:管理主题状态、颜色配置和预设主题列表
|
|
||||||
*/
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
// 所有主题列表
|
|
||||||
const allThemes = ref<Theme[]>([]);
|
|
||||||
|
|
||||||
// 当前主题的颜色配置
|
|
||||||
const currentColors = ref<ThemeColors | null>(null);
|
const currentColors = ref<ThemeColors | null>(null);
|
||||||
|
|
||||||
// 计算属性:当前系统主题模式
|
const currentTheme = computed(
|
||||||
const currentTheme = computed(() =>
|
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算属性:当前是否为深色模式
|
const isDarkMode = computed(
|
||||||
const isDarkMode = computed(() =>
|
() =>
|
||||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算属性:根据类型获取主题列表
|
const availableThemes = computed<ThemeOption[]>(() =>
|
||||||
const darkThemes = computed(() =>
|
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
|
|
||||||
);
|
|
||||||
|
|
||||||
const lightThemes = computed(() =>
|
|
||||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 计算属性:当前可用的主题列表
|
|
||||||
const availableThemes = computed(() =>
|
|
||||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 应用主题到 DOM
|
|
||||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||||
const themeMap = {
|
const themeMap = {
|
||||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||||
[SystemThemeType.SystemThemeLight]: 'light'
|
[SystemThemeType.SystemThemeLight]: 'light',
|
||||||
};
|
};
|
||||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从数据库加载所有主题
|
const loadThemeColors = async (themeName?: string) => {
|
||||||
const loadAllThemes = async () => {
|
const targetName = resolveThemeName(
|
||||||
try {
|
themeName || configStore.config?.appearance?.currentTheme
|
||||||
const themes = await ThemeService.GetAllThemes();
|
);
|
||||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
currentColors.value = await fetchThemeColors(targetName);
|
||||||
return allThemes.value;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load themes from database:', error);
|
|
||||||
allThemes.value = [];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化主题颜色
|
|
||||||
const initializeThemeColors = async () => {
|
|
||||||
// 加载所有主题
|
|
||||||
await loadAllThemes();
|
|
||||||
|
|
||||||
// 从配置获取当前主题名称并加载
|
|
||||||
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
|
|
||||||
|
|
||||||
const theme = allThemes.value.find(t => t.name === currentThemeName);
|
|
||||||
|
|
||||||
if (!theme) {
|
|
||||||
console.error(`Theme not found: ${currentThemeName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接设置当前主题颜色
|
|
||||||
currentColors.value = theme.colors as ThemeColors;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化主题
|
|
||||||
const initializeTheme = async () => {
|
const initializeTheme = async () => {
|
||||||
const theme = currentTheme.value;
|
applyThemeToDOM(currentTheme.value);
|
||||||
applyThemeToDOM(theme);
|
await loadThemeColors();
|
||||||
await initializeThemeColors();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置系统主题模式(深色/浅色/自动)
|
|
||||||
const setTheme = async (theme: SystemThemeType) => {
|
const setTheme = async (theme: SystemThemeType) => {
|
||||||
await configStore.setSystemTheme(theme);
|
await configStore.setSystemTheme(theme);
|
||||||
applyThemeToDOM(theme);
|
applyThemeToDOM(theme);
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换到指定的预设主题
|
|
||||||
const switchToTheme = async (themeName: string) => {
|
const switchToTheme = async (themeName: string) => {
|
||||||
const theme = allThemes.value.find(t => t.name === themeName);
|
if (!themePresetMap[themeName]) {
|
||||||
if (!theme) {
|
|
||||||
console.error('Theme not found:', themeName);
|
console.error('Theme not found:', themeName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接设置当前主题颜色
|
await loadThemeColors(themeName);
|
||||||
currentColors.value = theme.colors as ThemeColors;
|
|
||||||
|
|
||||||
// 持久化到配置
|
|
||||||
await configStore.setCurrentTheme(themeName);
|
await configStore.setCurrentTheme(themeName);
|
||||||
|
|
||||||
// 刷新编辑器
|
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新当前主题的颜色配置
|
|
||||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||||
if (!currentColors.value) return;
|
if (!currentColors.value) return;
|
||||||
Object.assign(currentColors.value, colors);
|
Object.assign(currentColors.value, colors);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存当前主题颜色到数据库
|
|
||||||
const saveCurrentTheme = async () => {
|
const saveCurrentTheme = async () => {
|
||||||
if (!currentColors.value) {
|
if (!currentColors.value) {
|
||||||
throw new Error('No theme selected');
|
throw new Error('No theme selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||||
if (!theme) {
|
currentColors.value.themeName = themeName;
|
||||||
throw new Error('Theme not found');
|
|
||||||
}
|
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
|
||||||
|
|
||||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
await loadThemeColors(themeName);
|
||||||
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置当前主题为预设配置
|
|
||||||
const resetCurrentTheme = async () => {
|
const resetCurrentTheme = async () => {
|
||||||
if (!currentColors.value) {
|
if (!currentColors.value) {
|
||||||
throw new Error('No theme selected');
|
throw new Error('No theme selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用后端重置
|
|
||||||
await ThemeService.ResetTheme(0, currentColors.value.name);
|
|
||||||
|
|
||||||
// 重新加载所有主题
|
|
||||||
await loadAllThemes();
|
|
||||||
|
|
||||||
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||||
|
await ThemeService.ResetTheme(themeName);
|
||||||
if (updatedTheme) {
|
|
||||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
await loadThemeColors(themeName);
|
||||||
}
|
|
||||||
|
|
||||||
refreshEditorTheme();
|
refreshEditorTheme();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新编辑器主题
|
|
||||||
const refreshEditorTheme = () => {
|
const refreshEditorTheme = () => {
|
||||||
applyThemeToDOM(currentTheme.value);
|
applyThemeToDOM(currentTheme.value);
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
editorStore?.applyThemeSettings();
|
editorStore?.applyThemeSettings();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
|
||||||
allThemes,
|
|
||||||
darkThemes,
|
|
||||||
lightThemes,
|
|
||||||
availableThemes,
|
availableThemes,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
currentColors,
|
currentColors,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
|
|
||||||
// 方法
|
|
||||||
setTheme,
|
setTheme,
|
||||||
switchToTheme,
|
switchToTheme,
|
||||||
initializeTheme,
|
initializeTheme,
|
||||||
loadAllThemes,
|
|
||||||
updateCurrentColors,
|
updateCurrentColors,
|
||||||
saveCurrentTheme,
|
saveCurrentTheme,
|
||||||
resetCurrentTheme,
|
resetCurrentTheme,
|
||||||
refreshEditorTheme,
|
refreshEditorTheme,
|
||||||
applyThemeToDOM,
|
applyThemeToDOM,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import {defineStore} from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
import {ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
|
/**
|
||||||
|
* 翻译结果接口
|
||||||
|
*/
|
||||||
|
export interface TranslationResult {
|
||||||
|
translatedText: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言信息接口
|
||||||
|
*/
|
||||||
|
export interface LanguageInfo {
|
||||||
|
Code: string; // 语言代码
|
||||||
|
Name: string; // 语言名称
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 翻译相关的错误消息
|
||||||
|
*/
|
||||||
|
export const TRANSLATION_ERRORS = {
|
||||||
|
NO_TEXT: 'no text to translate',
|
||||||
|
TRANSLATION_FAILED: 'translation failed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const useTranslationStore = defineStore('translation', () => {
|
export const useTranslationStore = defineStore('translation', () => {
|
||||||
// 基础状态
|
// 基础状态
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
|||||||
import {useEditorStore} from '@/stores/editorStore';
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
import {useDocumentStore} from '@/stores/documentStore';
|
import {useDocumentStore} from '@/stores/documentStore';
|
||||||
import {useConfigStore} from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
|
||||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||||
import {useWindowStore} from "@/stores/windowStore";
|
import {useWindowStore} from '@/stores/windowStore';
|
||||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||||
import {useTabStore} from "@/stores/tabStore";
|
import {useTabStore} from '@/stores/tabStore';
|
||||||
|
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||||
|
import {contextMenuManager} from './contextMenu/manager';
|
||||||
|
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||||
|
import {translatorManager} from './extensions/translator/manager';
|
||||||
|
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
|
|||||||
|
|
||||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||||
|
|
||||||
// 创建滚轮缩放处理器
|
|
||||||
const wheelHandler = createWheelZoomHandler(
|
|
||||||
configStore.increaseFontSize,
|
|
||||||
configStore.decreaseFontSize
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!editorElement.value) return;
|
if (!editorElement.value) return;
|
||||||
|
|
||||||
// 从URL查询参数中获取documentId
|
|
||||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||||
|
|
||||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
|
||||||
await documentStore.initialize(urlDocumentId);
|
await documentStore.initialize(urlDocumentId);
|
||||||
|
|
||||||
// 设置编辑器容器
|
|
||||||
editorStore.setEditorContainer(editorElement.value);
|
editorStore.setEditorContainer(editorElement.value);
|
||||||
|
|
||||||
await tabStore.initializeTab();
|
await tabStore.initializeTab();
|
||||||
|
|
||||||
// 添加滚轮事件监听
|
|
||||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// 移除滚轮事件监听
|
contextMenuManager.destroy();
|
||||||
if (editorElement.value) {
|
translatorManager.destroy();
|
||||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
|
||||||
}
|
|
||||||
editorStore.clearAllEditors();
|
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<div ref="editorElement" class="editor"></div>
|
<!-- 加载动画 -->
|
||||||
<Toolbar/>
|
|
||||||
<transition name="loading-fade">
|
<transition name="loading-fade">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||||
</transition>
|
</transition>
|
||||||
|
<!-- 编辑器区域 -->
|
||||||
|
<div ref="editorElement" class="editor"></div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<Toolbar/>
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<ContextMenu :portal-target="editorElement"/>
|
||||||
|
<!-- 翻译器弹窗 -->
|
||||||
|
<TranslatorDialog :portal-target="editorElement"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载动画过渡效果
|
|
||||||
.loading-fade-enter-active,
|
.loading-fade-enter-active,
|
||||||
.loading-fade-leave-active {
|
.loading-fade-leave-active {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
@@ -98,4 +94,4 @@ onBeforeUnmount(() => {
|
|||||||
.loading-fade-leave-to {
|
.loading-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,39 +1,48 @@
|
|||||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
|
||||||
import { useDocumentStore } from '@/stores/documentStore';
|
import type {Text} from '@codemirror/state';
|
||||||
import { useEditorStore } from '@/stores/editorStore';
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内容变化监听插件 - 集成文档和编辑器管理
|
|
||||||
*/
|
*/
|
||||||
export function createContentChangePlugin() {
|
export function createContentChangePlugin() {
|
||||||
return ViewPlugin.fromClass(
|
return ViewPlugin.fromClass(
|
||||||
class ContentChangePlugin {
|
class ContentChangePlugin {
|
||||||
private documentStore = useDocumentStore();
|
private readonly editorStore = useEditorStore();
|
||||||
private editorStore = useEditorStore();
|
private lastDoc: Text;
|
||||||
private lastContent = '';
|
private rafId: number | null = null;
|
||||||
|
private pendingNotification = false;
|
||||||
|
|
||||||
constructor(private view: EditorView) {
|
constructor(private view: EditorView) {
|
||||||
this.lastContent = view.state.doc.toString();
|
this.lastDoc = view.state.doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||||
|
return;
|
||||||
const newContent = this.view.state.doc.toString();
|
|
||||||
if (newContent === this.lastContent) return;
|
|
||||||
|
|
||||||
this.lastContent = newContent;
|
|
||||||
|
|
||||||
// 通知编辑器管理器内容已变化
|
|
||||||
const currentDocId = this.documentStore.currentDocumentId;
|
|
||||||
if (currentDocId) {
|
|
||||||
this.editorStore.onContentChange(currentDocId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lastDoc = update.state.doc;
|
||||||
|
this.scheduleNotification();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
this.pendingNotification = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNotification() {
|
||||||
|
if (this.pendingNotification) return;
|
||||||
|
|
||||||
|
this.pendingNotification = true;
|
||||||
|
this.rafId = requestAnimationFrame(() => {
|
||||||
|
this.pendingNotification = false;
|
||||||
|
this.rafId = null;
|
||||||
|
this.editorStore.onContentChange();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
// 处理滚轮缩放字体的事件处理函数
|
import {EditorView} from '@codemirror/view';
|
||||||
export const createWheelZoomHandler = (
|
import type {Extension} from '@codemirror/state';
|
||||||
increaseFontSize: () => void,
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
decreaseFontSize: () => void
|
|
||||||
) => {
|
type FontAdjuster = () => void;
|
||||||
return (event: WheelEvent) => {
|
type SaveCallback = () => Promise<void> | void;
|
||||||
// 检查是否按住了Ctrl键
|
|
||||||
if (event.ctrlKey) {
|
export interface WheelZoomOptions {
|
||||||
// 阻止默认行为(防止页面缩放)
|
/** 增加字体大小的回调(立即执行) */
|
||||||
|
increaseFontSize: FontAdjuster;
|
||||||
|
/** 减少字体大小的回调(立即执行) */
|
||||||
|
decreaseFontSize: FontAdjuster;
|
||||||
|
/** 保存回调(防抖执行),在滚动结束后调用 */
|
||||||
|
onSave?: SaveCallback;
|
||||||
|
/** 保存防抖延迟(毫秒),默认 300ms */
|
||||||
|
saveDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
|
||||||
|
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
|
||||||
|
|
||||||
|
// 如果有 onSave 回调,创建防抖版本
|
||||||
|
const {debouncedFn: debouncedSave} = onSave
|
||||||
|
? createDebounce(() => {
|
||||||
|
try {
|
||||||
|
const result = onSave();
|
||||||
|
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||||
|
(result as Promise<void>).catch((error) => {
|
||||||
|
console.error('Failed to save font size:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save font size:', error);
|
||||||
|
}
|
||||||
|
}, {delay: saveDelay})
|
||||||
|
: {debouncedFn: null};
|
||||||
|
|
||||||
|
return EditorView.domEventHandlers({
|
||||||
|
wheel(event) {
|
||||||
|
if (!event.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 根据滚轮方向增大或减小字体
|
// 立即更新字体大小
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
// 向上滚动,增大字体
|
|
||||||
increaseFontSize();
|
increaseFontSize();
|
||||||
} else {
|
} else if (event.deltaY > 0) {
|
||||||
// 向下滚动,减小字体
|
|
||||||
decreaseFontSize();
|
decreaseFontSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防抖保存
|
||||||
|
if (debouncedSave) {
|
||||||
|
debouncedSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { contextMenuManager } from './manager';
|
||||||
|
import type { RenderMenuItem } from './menuSchema';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
portalTarget?: HTMLElement | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const menuState = contextMenuManager.useState();
|
||||||
|
const menuRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const isVisible = computed(() => menuState.value.visible);
|
||||||
|
const items = computed(() => menuState.value.items);
|
||||||
|
const position = computed(() => menuState.value.position);
|
||||||
|
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
position,
|
||||||
|
(newPosition) => {
|
||||||
|
adjustedPosition.value = { ...newPosition };
|
||||||
|
if (isVisible.value) {
|
||||||
|
nextTick(adjustMenuWithinViewport);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(isVisible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
nextTick(adjustMenuWithinViewport);
|
||||||
|
// 显示时添加 outside 点击监听
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
} else {
|
||||||
|
// 隐藏时移除监听
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuStyle = computed(() => ({
|
||||||
|
left: `${adjustedPosition.value.x}px`,
|
||||||
|
top: `${adjustedPosition.value.y}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function adjustMenuWithinViewport() {
|
||||||
|
await nextTick();
|
||||||
|
const menuEl = menuRef.value;
|
||||||
|
if (!menuEl) return;
|
||||||
|
|
||||||
|
const rect = menuEl.getBoundingClientRect();
|
||||||
|
let nextX = adjustedPosition.value.x;
|
||||||
|
let nextY = adjustedPosition.value.y;
|
||||||
|
|
||||||
|
if (rect.right > window.innerWidth) {
|
||||||
|
nextX = Math.max(0, window.innerWidth - rect.width - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.bottom > window.innerHeight) {
|
||||||
|
nextY = Math.max(0, window.innerHeight - rect.height - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustedPosition.value = { x: nextX, y: nextY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemClick(item: RenderMenuItem) {
|
||||||
|
if (item.type !== "action" || item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextMenuManager.runCommand(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
// 如果点击在菜单内部,不关闭
|
||||||
|
if (menuRef.value?.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextMenuManager.hide();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport :to="teleportTarget">
|
||||||
|
<template v-if="isVisible">
|
||||||
|
<div
|
||||||
|
ref="menuRef"
|
||||||
|
class="cm-context-menu show"
|
||||||
|
:style="menuStyle"
|
||||||
|
role="menu"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<template v-for="item in items" :key="item.id">
|
||||||
|
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="cm-context-menu-item"
|
||||||
|
:class="{ 'is-disabled': item.disabled }"
|
||||||
|
role="menuitem"
|
||||||
|
:aria-disabled="item.disabled ? 'true' : 'false'"
|
||||||
|
@click="handleItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="cm-context-menu-item-label">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
|
||||||
|
{{ item.shortcut }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cm-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--settings-card-bg, #1c1c1e);
|
||||||
|
color: var(--settings-text, #f6f6f6);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
transform-origin: top left;
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item:hover {
|
||||||
|
background-color: var(--toolbar-button-hover);
|
||||||
|
color: var(--toolbar-text, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item-shortcut {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* 编辑器上下文菜单样式
|
|
||||||
* 支持系统主题自动适配
|
|
||||||
*/
|
|
||||||
|
|
||||||
.cm-context-menu {
|
|
||||||
position: fixed;
|
|
||||||
background-color: var(--settings-card-bg);
|
|
||||||
color: var(--settings-text);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 0;
|
|
||||||
/* 优化阴影效果,只在右下角显示自然的阴影 */
|
|
||||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 320px;
|
|
||||||
z-index: 9999;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95);
|
|
||||||
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
|
||||||
overflow: visible; /* 确保子菜单可以显示在外部 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
position: relative;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item:hover {
|
|
||||||
background-color: var(--toolbar-button-hover);
|
|
||||||
color: var(--toolbar-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-shortcut {
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--settings-input-bg);
|
|
||||||
color: var(--settings-text-secondary);
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-ripple {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--selection-bg);
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(0);
|
|
||||||
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 菜单分组标题样式 */
|
|
||||||
.cm-context-menu-group-title {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 菜单分隔线样式 */
|
|
||||||
.cm-context-menu-divider {
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--border-color);
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 子菜单样式 */
|
|
||||||
.cm-context-submenu-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-with-submenu {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-with-submenu::after {
|
|
||||||
content: "›";
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-submenu {
|
|
||||||
position: fixed; /* 改为fixed定位,避免受父元素影响 */
|
|
||||||
min-width: 180px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateX(10px);
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
z-index: 10000;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: var(--settings-card-bg);
|
|
||||||
color: var(--settings-text);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 4px 0;
|
|
||||||
/* 子菜单也使用相同的阴影效果 */
|
|
||||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 深色主题下的特殊样式 */
|
|
||||||
:root[data-theme="dark"] .cm-context-menu {
|
|
||||||
/* 深色主题下阴影更深,但仍然只在右下角 */
|
|
||||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] .cm-context-submenu {
|
|
||||||
/* 深色主题下子菜单阴影 */
|
|
||||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] .cm-context-menu-divider {
|
|
||||||
background-color: var(--dark-border-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 动画相关类 */
|
|
||||||
.cm-context-menu.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-context-menu.hide {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
/**
|
|
||||||
* 上下文菜单视图实现
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { MenuItem } from "../contextMenu";
|
|
||||||
import "./contextMenu.css";
|
|
||||||
|
|
||||||
// 为Window对象添加cmSubmenus属性
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
cmSubmenus?: Map<string, HTMLElement>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 菜单项元素池,用于复用DOM元素
|
|
||||||
*/
|
|
||||||
class MenuItemPool {
|
|
||||||
private pool: HTMLElement[] = [];
|
|
||||||
private maxPoolSize = 50; // 最大池大小
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取或创建菜单项元素
|
|
||||||
*/
|
|
||||||
get(): HTMLElement {
|
|
||||||
if (this.pool.length > 0) {
|
|
||||||
return this.pool.pop()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItem = document.createElement("div");
|
|
||||||
menuItem.className = "cm-context-menu-item";
|
|
||||||
return menuItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回收菜单项元素
|
|
||||||
*/
|
|
||||||
release(element: HTMLElement): void {
|
|
||||||
if (this.pool.length < this.maxPoolSize) {
|
|
||||||
// 清理元素状态
|
|
||||||
element.className = "cm-context-menu-item";
|
|
||||||
element.innerHTML = "";
|
|
||||||
element.style.cssText = "";
|
|
||||||
|
|
||||||
// 移除所有事件监听器(通过克隆节点)
|
|
||||||
const cleanElement = element.cloneNode(false) as HTMLElement;
|
|
||||||
this.pool.push(cleanElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空池
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
this.pool.length = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上下文菜单管理器
|
|
||||||
*/
|
|
||||||
class ContextMenuManager {
|
|
||||||
private static instance: ContextMenuManager;
|
|
||||||
|
|
||||||
private menuElement: HTMLElement | null = null;
|
|
||||||
private submenuPool: Map<string, HTMLElement> = new Map();
|
|
||||||
private menuItemPool = new MenuItemPool();
|
|
||||||
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
|
||||||
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
||||||
private currentView: EditorView | null = null;
|
|
||||||
private activeSubmenus: Set<HTMLElement> = new Set();
|
|
||||||
private ripplePool: HTMLElement[] = [];
|
|
||||||
|
|
||||||
// 事件委托处理器
|
|
||||||
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
|
||||||
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.initializeEventHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单例实例
|
|
||||||
*/
|
|
||||||
static getInstance(): ContextMenuManager {
|
|
||||||
if (!ContextMenuManager.instance) {
|
|
||||||
ContextMenuManager.instance = new ContextMenuManager();
|
|
||||||
}
|
|
||||||
return ContextMenuManager.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化事件处理器
|
|
||||||
*/
|
|
||||||
private initializeEventHandlers(): void {
|
|
||||||
// 点击事件委托
|
|
||||||
this.menuClickHandler = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
|
||||||
|
|
||||||
if (menuItem && menuItem.dataset.command) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// 添加点击动画
|
|
||||||
this.addRippleEffect(menuItem, e);
|
|
||||||
|
|
||||||
// 执行命令
|
|
||||||
const commandName = menuItem.dataset.command;
|
|
||||||
const command = this.getCommandByName(commandName);
|
|
||||||
if (command && this.currentView) {
|
|
||||||
command(this.currentView);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏菜单
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 鼠标事件委托
|
|
||||||
this.menuMouseHandler = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
|
||||||
|
|
||||||
if (!menuItem) return;
|
|
||||||
|
|
||||||
if (e.type === 'mouseenter') {
|
|
||||||
this.handleMenuItemMouseEnter(menuItem);
|
|
||||||
} else if (e.type === 'mouseleave') {
|
|
||||||
this.handleMenuItemMouseLeave(menuItem, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 键盘事件处理器
|
|
||||||
this.keyDownHandler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 点击外部关闭处理器
|
|
||||||
this.clickOutsideHandler = (e: MouseEvent) => {
|
|
||||||
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取或创建主菜单元素
|
|
||||||
*/
|
|
||||||
private getOrCreateMenuElement(): HTMLElement {
|
|
||||||
if (!this.menuElement) {
|
|
||||||
this.menuElement = document.createElement("div");
|
|
||||||
this.menuElement.className = "cm-context-menu";
|
|
||||||
this.menuElement.style.display = "none";
|
|
||||||
document.body.appendChild(this.menuElement);
|
|
||||||
|
|
||||||
// 阻止菜单内右键点击冒泡
|
|
||||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加事件委托
|
|
||||||
this.menuElement.addEventListener('click', this.menuClickHandler!);
|
|
||||||
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
|
||||||
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
|
||||||
}
|
|
||||||
return this.menuElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建或获取子菜单元素
|
|
||||||
*/
|
|
||||||
private getOrCreateSubmenu(id: string): HTMLElement {
|
|
||||||
if (!this.submenuPool.has(id)) {
|
|
||||||
const submenu = document.createElement("div");
|
|
||||||
submenu.className = "cm-context-menu cm-context-submenu";
|
|
||||||
submenu.style.display = "none";
|
|
||||||
document.body.appendChild(submenu);
|
|
||||||
this.submenuPool.set(id, submenu);
|
|
||||||
|
|
||||||
// 阻止子菜单点击事件冒泡
|
|
||||||
submenu.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加事件委托
|
|
||||||
submenu.addEventListener('click', this.menuClickHandler!);
|
|
||||||
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
|
||||||
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
|
||||||
}
|
|
||||||
return this.submenuPool.get(id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建菜单项DOM元素
|
|
||||||
*/
|
|
||||||
private createMenuItemElement(item: MenuItem): HTMLElement {
|
|
||||||
const menuItem = this.menuItemPool.get();
|
|
||||||
|
|
||||||
// 如果有子菜单,添加相应类
|
|
||||||
if (item.submenu && item.submenu.length > 0) {
|
|
||||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建内容容器
|
|
||||||
const contentContainer = document.createElement("div");
|
|
||||||
contentContainer.className = "cm-context-menu-item-label";
|
|
||||||
|
|
||||||
// 标签文本
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.textContent = item.label;
|
|
||||||
contentContainer.appendChild(label);
|
|
||||||
menuItem.appendChild(contentContainer);
|
|
||||||
|
|
||||||
// 快捷键提示(如果有)
|
|
||||||
if (item.shortcut) {
|
|
||||||
const shortcut = document.createElement("span");
|
|
||||||
shortcut.className = "cm-context-menu-item-shortcut";
|
|
||||||
shortcut.textContent = item.shortcut;
|
|
||||||
menuItem.appendChild(shortcut);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储命令信息用于事件委托
|
|
||||||
if (item.command) {
|
|
||||||
menuItem.dataset.command = this.registerCommand(item.command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理子菜单
|
|
||||||
if (item.submenu && item.submenu.length > 0) {
|
|
||||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
|
||||||
menuItem.dataset.submenuId = submenuId;
|
|
||||||
|
|
||||||
const submenu = this.getOrCreateSubmenu(submenuId);
|
|
||||||
this.populateSubmenu(submenu, item.submenu);
|
|
||||||
|
|
||||||
// 记录子菜单
|
|
||||||
if (!window.cmSubmenus) {
|
|
||||||
window.cmSubmenus = new Map();
|
|
||||||
}
|
|
||||||
window.cmSubmenus.set(submenuId, submenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 填充子菜单内容
|
|
||||||
*/
|
|
||||||
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
|
|
||||||
// 清空现有内容
|
|
||||||
while (submenu.firstChild) {
|
|
||||||
submenu.removeChild(submenu.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加子菜单项
|
|
||||||
items.forEach(item => {
|
|
||||||
const subMenuItemElement = this.createMenuItemElement(item);
|
|
||||||
submenu.appendChild(subMenuItemElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始状态设置为隐藏
|
|
||||||
submenu.style.opacity = '0';
|
|
||||||
submenu.style.pointerEvents = 'none';
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
submenu.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 命令注册和管理
|
|
||||||
*/
|
|
||||||
private commands: Map<string, (view: EditorView) => void> = new Map();
|
|
||||||
private commandCounter = 0;
|
|
||||||
|
|
||||||
private registerCommand(command: (view: EditorView) => void): string {
|
|
||||||
const commandId = `cmd_${this.commandCounter++}`;
|
|
||||||
this.commands.set(commandId, command);
|
|
||||||
return commandId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
|
||||||
return this.commands.get(commandId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理菜单项鼠标进入事件
|
|
||||||
*/
|
|
||||||
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
|
||||||
const submenuId = menuItem.dataset.submenuId;
|
|
||||||
if (!submenuId) return;
|
|
||||||
|
|
||||||
const submenu = this.submenuPool.get(submenuId);
|
|
||||||
if (!submenu) return;
|
|
||||||
|
|
||||||
const rect = menuItem.getBoundingClientRect();
|
|
||||||
|
|
||||||
// 计算子菜单位置
|
|
||||||
submenu.style.left = `${rect.right}px`;
|
|
||||||
submenu.style.top = `${rect.top}px`;
|
|
||||||
|
|
||||||
// 检查子菜单是否会超出屏幕
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const submenuRect = submenu.getBoundingClientRect();
|
|
||||||
if (submenuRect.right > window.innerWidth) {
|
|
||||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submenuRect.bottom > window.innerHeight) {
|
|
||||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
|
||||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 显示子菜单
|
|
||||||
submenu.style.opacity = '1';
|
|
||||||
submenu.style.pointerEvents = 'auto';
|
|
||||||
submenu.style.visibility = 'visible';
|
|
||||||
submenu.style.transform = 'translateX(0)';
|
|
||||||
|
|
||||||
this.activeSubmenus.add(submenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理菜单项鼠标离开事件
|
|
||||||
*/
|
|
||||||
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
|
|
||||||
const submenuId = menuItem.dataset.submenuId;
|
|
||||||
if (!submenuId) return;
|
|
||||||
|
|
||||||
const submenu = this.submenuPool.get(submenuId);
|
|
||||||
if (!submenu) return;
|
|
||||||
|
|
||||||
// 检查是否移动到子菜单上
|
|
||||||
const toElement = e.relatedTarget as HTMLElement;
|
|
||||||
if (submenu.contains(toElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hideSubmenu(submenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏子菜单
|
|
||||||
*/
|
|
||||||
private hideSubmenu(submenu: HTMLElement): void {
|
|
||||||
submenu.style.opacity = '0';
|
|
||||||
submenu.style.pointerEvents = 'none';
|
|
||||||
submenu.style.transform = 'translateX(10px)';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (submenu.style.opacity === '0') {
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
this.activeSubmenus.delete(submenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加点击波纹效果
|
|
||||||
*/
|
|
||||||
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
|
|
||||||
let ripple: HTMLElement;
|
|
||||||
|
|
||||||
if (this.ripplePool.length > 0) {
|
|
||||||
ripple = this.ripplePool.pop()!;
|
|
||||||
} else {
|
|
||||||
ripple = document.createElement("div");
|
|
||||||
ripple.className = "cm-context-menu-item-ripple";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算相对位置
|
|
||||||
const rect = menuItem.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
|
|
||||||
ripple.style.left = (x - 50) + "px";
|
|
||||||
ripple.style.top = (y - 50) + "px";
|
|
||||||
ripple.style.transform = "scale(0)";
|
|
||||||
ripple.style.opacity = "1";
|
|
||||||
|
|
||||||
menuItem.appendChild(ripple);
|
|
||||||
|
|
||||||
// 执行动画
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
ripple.style.transform = "scale(1)";
|
|
||||||
ripple.style.opacity = "0";
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (ripple.parentNode === menuItem) {
|
|
||||||
menuItem.removeChild(ripple);
|
|
||||||
this.ripplePool.push(ripple);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查点击是否在菜单内
|
|
||||||
*/
|
|
||||||
private isClickInsideMenu(target: Node): boolean {
|
|
||||||
if (this.menuElement && this.menuElement.contains(target)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否在子菜单内
|
|
||||||
for (const submenu of this.activeSubmenus) {
|
|
||||||
if (submenu.contains(target)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定位菜单元素
|
|
||||||
*/
|
|
||||||
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
|
|
||||||
const windowWidth = window.innerWidth;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let left = clientX;
|
|
||||||
let top = clientY;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const menuWidth = menu.offsetWidth;
|
|
||||||
const menuHeight = menu.offsetHeight;
|
|
||||||
|
|
||||||
if (left + menuWidth > windowWidth) {
|
|
||||||
left = windowWidth - menuWidth - 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top + menuHeight > windowHeight) {
|
|
||||||
top = windowHeight - menuHeight - 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.style.left = `${left}px`;
|
|
||||||
menu.style.top = `${top}px`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示上下文菜单
|
|
||||||
*/
|
|
||||||
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
|
||||||
this.currentView = view;
|
|
||||||
|
|
||||||
// 获取或创建菜单元素
|
|
||||||
const menu = this.getOrCreateMenuElement();
|
|
||||||
|
|
||||||
// 隐藏所有子菜单
|
|
||||||
this.hideAllSubmenus();
|
|
||||||
|
|
||||||
// 清空现有菜单项并回收到池中
|
|
||||||
while (menu.firstChild) {
|
|
||||||
const child = menu.firstChild as HTMLElement;
|
|
||||||
if (child.classList.contains('cm-context-menu-item')) {
|
|
||||||
this.menuItemPool.release(child);
|
|
||||||
}
|
|
||||||
menu.removeChild(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空命令注册
|
|
||||||
this.commands.clear();
|
|
||||||
this.commandCounter = 0;
|
|
||||||
|
|
||||||
// 添加主菜单项
|
|
||||||
items.forEach(item => {
|
|
||||||
const menuItemElement = this.createMenuItemElement(item);
|
|
||||||
menu.appendChild(menuItemElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 显示菜单
|
|
||||||
menu.style.display = "block";
|
|
||||||
|
|
||||||
// 定位菜单
|
|
||||||
this.positionMenu(menu, clientX, clientY);
|
|
||||||
|
|
||||||
// 添加全局事件监听器
|
|
||||||
document.addEventListener("click", this.clickOutsideHandler!, true);
|
|
||||||
document.addEventListener("keydown", this.keyDownHandler!);
|
|
||||||
|
|
||||||
// 触发显示动画
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (menu) {
|
|
||||||
menu.classList.add("show");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏所有子菜单
|
|
||||||
*/
|
|
||||||
private hideAllSubmenus(): void {
|
|
||||||
this.activeSubmenus.forEach(submenu => {
|
|
||||||
this.hideSubmenu(submenu);
|
|
||||||
});
|
|
||||||
this.activeSubmenus.clear();
|
|
||||||
|
|
||||||
if (window.cmSubmenus) {
|
|
||||||
window.cmSubmenus.forEach((submenu) => {
|
|
||||||
submenu.style.opacity = '0';
|
|
||||||
submenu.style.pointerEvents = 'none';
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
submenu.style.transform = 'translateX(10px)';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏上下文菜单
|
|
||||||
*/
|
|
||||||
hide(): void {
|
|
||||||
// 隐藏所有子菜单
|
|
||||||
this.hideAllSubmenus();
|
|
||||||
|
|
||||||
if (this.menuElement) {
|
|
||||||
// 添加淡出动画
|
|
||||||
this.menuElement.classList.remove("show");
|
|
||||||
this.menuElement.classList.add("hide");
|
|
||||||
|
|
||||||
// 等待动画完成后隐藏
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.menuElement) {
|
|
||||||
this.menuElement.style.display = "none";
|
|
||||||
this.menuElement.classList.remove("hide");
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除全局事件监听器
|
|
||||||
if (this.clickOutsideHandler) {
|
|
||||||
document.removeEventListener("click", this.clickOutsideHandler, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.keyDownHandler) {
|
|
||||||
document.removeEventListener("keydown", this.keyDownHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentView = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 销毁管理器
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.hide();
|
|
||||||
|
|
||||||
if (this.menuElement) {
|
|
||||||
document.body.removeChild(this.menuElement);
|
|
||||||
this.menuElement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submenuPool.forEach(submenu => {
|
|
||||||
if (submenu.parentNode) {
|
|
||||||
document.body.removeChild(submenu);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.submenuPool.clear();
|
|
||||||
|
|
||||||
this.menuItemPool.clear();
|
|
||||||
this.commands.clear();
|
|
||||||
this.activeSubmenus.clear();
|
|
||||||
this.ripplePool.length = 0;
|
|
||||||
|
|
||||||
if (window.cmSubmenus) {
|
|
||||||
window.cmSubmenus.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取单例实例
|
|
||||||
const contextMenuManager = ContextMenuManager.getInstance();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示上下文菜单
|
|
||||||
*/
|
|
||||||
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
|
||||||
contextMenuManager.show(view, clientX, clientY, items);
|
|
||||||
}
|
|
||||||
@@ -1,174 +1,141 @@
|
|||||||
/**
|
import { EditorView } from '@codemirror/view';
|
||||||
* 编辑器上下文菜单实现
|
import { Extension } from '@codemirror/state';
|
||||||
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
|
import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
|
||||||
*/
|
import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||||
|
import { undo, redo } from '@codemirror/commands';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { useSystemStore } from '@/stores/systemStore';
|
||||||
|
import { showContextMenu } from './manager';
|
||||||
|
import {
|
||||||
|
buildRegisteredMenu,
|
||||||
|
createMenuContext,
|
||||||
|
registerMenuNodes
|
||||||
|
} from './menuSchema';
|
||||||
|
import type { MenuSchemaNode } from './menuSchema';
|
||||||
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { Extension } from "@codemirror/state";
|
|
||||||
import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
|
|
||||||
import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
|
|
||||||
import { useKeybindingStore } from "@/stores/keybindingStore";
|
|
||||||
import {
|
|
||||||
undo, redo
|
|
||||||
} from "@codemirror/commands";
|
|
||||||
import i18n from "@/i18n";
|
|
||||||
import {useSystemStore} from "@/stores/systemStore";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 菜单项类型定义
|
|
||||||
*/
|
|
||||||
export interface MenuItem {
|
|
||||||
/** 菜单项显示文本 */
|
|
||||||
label: string;
|
|
||||||
|
|
||||||
/** 点击时执行的命令 (如果有子菜单,可以为null) */
|
|
||||||
command?: (view: EditorView) => boolean;
|
|
||||||
|
|
||||||
/** 快捷键提示文本 (可选) */
|
|
||||||
shortcut?: string;
|
|
||||||
|
|
||||||
/** 子菜单项 (可选) */
|
|
||||||
submenu?: MenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入相关功能
|
|
||||||
import { showContextMenu } from "./contextMenuView";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取翻译文本
|
|
||||||
* @param key 翻译键
|
|
||||||
* @returns 翻译后的文本
|
|
||||||
*/
|
|
||||||
function t(key: string): string {
|
function t(key: string): string {
|
||||||
return i18n.global.t(key);
|
return i18n.global.t(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取快捷键显示文本
|
function formatKeyBinding(keyBinding: string): string {
|
||||||
* @param command 命令ID
|
const systemStore = useSystemStore();
|
||||||
* @returns 快捷键显示文本
|
const isMac = systemStore.isMacOS;
|
||||||
*/
|
|
||||||
function getShortcutText(command: KeyBindingCommand): string {
|
return keyBinding
|
||||||
|
.replace("Mod", isMac ? "Cmd" : "Ctrl")
|
||||||
|
.replace("Shift", "Shift")
|
||||||
|
.replace("Alt", isMac ? "Option" : "Alt")
|
||||||
|
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
|
||||||
|
.replace(/-/g, " + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutCache = new Map<KeyBindingCommand, string>();
|
||||||
|
|
||||||
|
|
||||||
|
function getShortcutText(command?: KeyBindingCommand): string {
|
||||||
|
if (command === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = shortcutCache.get(command);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keybindingStore = useKeybindingStore();
|
const keybindingStore = useKeybindingStore();
|
||||||
|
const binding = keybindingStore.keyBindings.find(
|
||||||
// 如果找到该命令的快捷键配置
|
(kb) => kb.command === command && kb.enabled
|
||||||
const binding = keybindingStore.keyBindings.find(kb =>
|
|
||||||
kb.command === command && kb.enabled
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (binding && binding.key) {
|
if (binding?.key) {
|
||||||
// 格式化快捷键显示
|
const formatted = formatKeyBinding(binding.key);
|
||||||
return formatKeyBinding(binding.key);
|
shortcutCache.set(command, formatted);
|
||||||
|
return formatted;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("An error occurred while getting the shortcut:", error);
|
console.warn("An error occurred while getting the shortcut:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortcutCache.set(command, "");
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化快捷键显示
|
|
||||||
* @param keyBinding 快捷键字符串
|
|
||||||
* @returns 格式化后的显示文本
|
|
||||||
*/
|
|
||||||
function formatKeyBinding(keyBinding: string): string {
|
|
||||||
// 获取系统信息
|
|
||||||
const systemStore = useSystemStore();
|
|
||||||
const isMac = systemStore.isMacOS;
|
|
||||||
|
|
||||||
// 替换修饰键名称为更友好的显示
|
|
||||||
return keyBinding
|
|
||||||
.replace("Mod", isMac ? "⌘" : "Ctrl")
|
|
||||||
.replace("Shift", isMac ? "⇧" : "Shift")
|
|
||||||
.replace("Alt", isMac ? "⌥" : "Alt")
|
|
||||||
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
|
|
||||||
.replace(/-/g, " + ");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||||
/**
|
|
||||||
* 创建编辑菜单项
|
|
||||||
*/
|
|
||||||
function createEditItems(): MenuItem[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockCopy"),
|
id: "copy",
|
||||||
|
labelKey: "keybindings.commands.blockCopy",
|
||||||
command: copyCommand,
|
command: copyCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
|
||||||
|
enabled: (context) => context.hasSelection
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockCut"),
|
id: "cut",
|
||||||
|
labelKey: "keybindings.commands.blockCut",
|
||||||
command: cutCommand,
|
command: cutCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
shortcutCommand: KeyBindingCommand.BlockCutCommand,
|
||||||
|
visible: (context) => context.isEditable,
|
||||||
|
enabled: (context) => context.hasSelection && context.isEditable
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockPaste"),
|
id: "paste",
|
||||||
|
labelKey: "keybindings.commands.blockPaste",
|
||||||
command: pasteCommand,
|
command: pasteCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||||
}
|
visible: (context) => context.isEditable
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建历史操作菜单项
|
|
||||||
*/
|
|
||||||
function createHistoryItems(): MenuItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.historyUndo"),
|
|
||||||
command: undo,
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.historyRedo"),
|
id: "undo",
|
||||||
|
labelKey: "keybindings.commands.historyUndo",
|
||||||
|
command: undo,
|
||||||
|
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
|
||||||
|
visible: (context) => context.isEditable
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redo",
|
||||||
|
labelKey: "keybindings.commands.historyRedo",
|
||||||
command: redo,
|
command: redo,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||||
|
visible: (context) => context.isEditable
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let builtinMenuRegistered = false;
|
||||||
|
|
||||||
/**
|
function ensureBuiltinMenuRegistered(): void {
|
||||||
* 创建主菜单项
|
if (builtinMenuRegistered) return;
|
||||||
*/
|
registerMenuNodes(getBuiltinMenuNodes());
|
||||||
function createMainMenuItems(): MenuItem[] {
|
builtinMenuRegistered = true;
|
||||||
// 基本编辑操作放在主菜单
|
|
||||||
const basicItems = createEditItems();
|
|
||||||
|
|
||||||
// 历史操作放在主菜单
|
|
||||||
const historyItems = createHistoryItems();
|
|
||||||
|
|
||||||
// 构建主菜单
|
|
||||||
return [
|
|
||||||
...basicItems,
|
|
||||||
...historyItems
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建编辑器上下文菜单
|
|
||||||
*/
|
|
||||||
export function createEditorContextMenu(): Extension {
|
export function createEditorContextMenu(): Extension {
|
||||||
// 为编辑器添加右键事件处理
|
ensureBuiltinMenuRegistered();
|
||||||
|
|
||||||
return EditorView.domEventHandlers({
|
return EditorView.domEventHandlers({
|
||||||
contextmenu: (event, view) => {
|
contextmenu: (event, view) => {
|
||||||
// 阻止默认右键菜单
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 获取菜单项
|
const context = createMenuContext(view, event as MouseEvent);
|
||||||
const menuItems = createMainMenuItems();
|
const menuItems = buildRegisteredMenu(context, {
|
||||||
|
translate: t,
|
||||||
// 显示上下文菜单
|
formatShortcut: getShortcutText
|
||||||
|
});
|
||||||
|
|
||||||
|
if (menuItems.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default createEditorContextMenu;
|
||||||
* 默认导出
|
|
||||||
*/
|
|
||||||
export default createEditorContextMenu;
|
|
||||||
|
|||||||
108
frontend/src/views/editor/contextMenu/manager.ts
Normal file
108
frontend/src/views/editor/contextMenu/manager.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { EditorView } from '@codemirror/view';
|
||||||
|
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||||
|
import type { RenderMenuItem } from './menuSchema';
|
||||||
|
|
||||||
|
interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
visible: boolean;
|
||||||
|
position: MenuPosition;
|
||||||
|
items: RenderMenuItem[];
|
||||||
|
view: EditorView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContextMenuManager {
|
||||||
|
private state: ShallowRef<ContextMenuState> = shallowRef({
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
items: [] as RenderMenuItem[],
|
||||||
|
view: null as EditorView | null
|
||||||
|
});
|
||||||
|
|
||||||
|
useState() {
|
||||||
|
return readonly(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
|
||||||
|
const currentState = this.state.value;
|
||||||
|
|
||||||
|
// 如果菜单已经显示,且位置很接近(20px范围内),则只更新内容,避免闪烁
|
||||||
|
if (currentState.visible) {
|
||||||
|
const dx = Math.abs(currentState.position.x - clientX);
|
||||||
|
const dy = Math.abs(currentState.position.y - clientY);
|
||||||
|
const isSamePosition = dx < 20 && dy < 20;
|
||||||
|
|
||||||
|
if (isSamePosition) {
|
||||||
|
// 只更新items和view,保持visible状态和位置
|
||||||
|
this.state.value = {
|
||||||
|
...currentState,
|
||||||
|
items,
|
||||||
|
view
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则正常显示菜单
|
||||||
|
this.state.value = {
|
||||||
|
visible: true,
|
||||||
|
position: { x: clientX, y: clientY },
|
||||||
|
items,
|
||||||
|
view
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
if (!this.state.value.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPosition = this.state.value.position;
|
||||||
|
const view = this.state.value.view;
|
||||||
|
this.state.value = {
|
||||||
|
visible: false,
|
||||||
|
position: previousPosition,
|
||||||
|
items: [],
|
||||||
|
view: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (view) {
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand(item: RenderMenuItem): void {
|
||||||
|
if (item.type !== "action" || item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { view } = this.state.value;
|
||||||
|
if (item.command && view) {
|
||||||
|
item.command(view);
|
||||||
|
}
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.state.value = {
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
items: [],
|
||||||
|
view: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contextMenuManager = new ContextMenuManager();
|
||||||
|
|
||||||
|
export function showContextMenu(
|
||||||
|
view: EditorView,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
items: RenderMenuItem[]
|
||||||
|
): void {
|
||||||
|
contextMenuManager.show(view, clientX, clientY, items);
|
||||||
|
}
|
||||||
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { EditorView } from '@codemirror/view';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
|
||||||
|
export interface MenuContext {
|
||||||
|
view: EditorView;
|
||||||
|
event: MouseEvent;
|
||||||
|
hasSelection: boolean;
|
||||||
|
selectionText: string;
|
||||||
|
isEditable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuSchemaNode =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type?: "action";
|
||||||
|
labelKey: string;
|
||||||
|
command?: (view: EditorView) => boolean;
|
||||||
|
shortcutCommand?: KeyBindingCommand;
|
||||||
|
visible?: (context: MenuContext) => boolean;
|
||||||
|
enabled?: (context: MenuContext) => boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "separator";
|
||||||
|
visible?: (context: MenuContext) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RenderMenuItem {
|
||||||
|
id: string;
|
||||||
|
type: "action" | "separator";
|
||||||
|
label?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
command?: (view: EditorView) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuBuildOptions {
|
||||||
|
translate: (key: string) => string;
|
||||||
|
formatShortcut: (command?: KeyBindingCommand) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuRegistry: MenuSchemaNode[] = [];
|
||||||
|
|
||||||
|
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
|
||||||
|
const { state } = view;
|
||||||
|
const hasSelection = state.selection.ranges.some((range) => !range.empty);
|
||||||
|
const selectionText = hasSelection
|
||||||
|
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
|
||||||
|
: "";
|
||||||
|
const isEditable = !state.facet(EditorState.readOnly);
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
event,
|
||||||
|
hasSelection,
|
||||||
|
selectionText,
|
||||||
|
isEditable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
|
||||||
|
menuRegistry.push(...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegisteredMenu(
|
||||||
|
context: MenuContext,
|
||||||
|
options: MenuBuildOptions
|
||||||
|
): RenderMenuItem[] {
|
||||||
|
return menuRegistry
|
||||||
|
.map((node) => convertNode(node, context, options))
|
||||||
|
.filter((item): item is RenderMenuItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertNode(
|
||||||
|
node: MenuSchemaNode,
|
||||||
|
context: MenuContext,
|
||||||
|
options: MenuBuildOptions
|
||||||
|
): RenderMenuItem | null {
|
||||||
|
if (node.visible && !node.visible(context)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "separator") {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
type: "separator"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = node.enabled ? !node.enabled(context) : false;
|
||||||
|
const shortcut = options.formatShortcut(node.shortcutCommand);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
type: "action",
|
||||||
|
label: options.translate(node.labelKey),
|
||||||
|
shortcut: shortcut || undefined,
|
||||||
|
disabled,
|
||||||
|
command: node.command
|
||||||
|
};
|
||||||
|
}
|
||||||
56
frontend/src/views/editor/extensions/codeblock/annotation.ts
Normal file
56
frontend/src/views/editor/extensions/codeblock/annotation.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Annotation, Transaction } from "@codemirror/state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 CodeBlock 注解,用于标记内部触发的事务。
|
||||||
|
*/
|
||||||
|
export const codeBlockEvent = Annotation.define<string>();
|
||||||
|
|
||||||
|
export const LANGUAGE_CHANGE = "codeblock-language-change";
|
||||||
|
export const ADD_NEW_BLOCK = "codeblock-add-new-block";
|
||||||
|
export const MOVE_BLOCK = "codeblock-move-block";
|
||||||
|
export const DELETE_BLOCK = "codeblock-delete-block";
|
||||||
|
export const CURRENCIES_LOADED = "codeblock-currencies-loaded";
|
||||||
|
export const CONTENT_EDIT = "codeblock-content-edit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一管理的 userEvent 常量。
|
||||||
|
*/
|
||||||
|
export const USER_EVENTS = {
|
||||||
|
INPUT: "input",
|
||||||
|
DELETE: "delete",
|
||||||
|
MOVE: "move",
|
||||||
|
SELECT: "select",
|
||||||
|
DELETE_LINE: "delete.line",
|
||||||
|
DELETE_CUT: "delete.cut",
|
||||||
|
INPUT_PASTE: "input.paste",
|
||||||
|
MOVE_LINE: "move.line",
|
||||||
|
MOVE_CHARACTER: "move.character",
|
||||||
|
SELECT_BLOCK_BOUNDARY: "select.block-boundary",
|
||||||
|
INPUT_REPLACE: "input.replace",
|
||||||
|
INPUT_REPLACE_ALL: "input.replace.all",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断事务列表中是否包含指定注解。
|
||||||
|
*/
|
||||||
|
export function transactionsHasAnnotation(
|
||||||
|
transactions: readonly Transaction[],
|
||||||
|
annotation: string
|
||||||
|
) {
|
||||||
|
return transactions.some(
|
||||||
|
tr => tr.annotation(codeBlockEvent) === annotation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断事务列表中是否包含任一注解。
|
||||||
|
*/
|
||||||
|
export function transactionsHasAnnotationsAny(
|
||||||
|
transactions: readonly Transaction[],
|
||||||
|
annotations: readonly string[]
|
||||||
|
) {
|
||||||
|
return transactions.some(tr => {
|
||||||
|
const value = tr.annotation(codeBlockEvent);
|
||||||
|
return value ? annotations.includes(value) : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
* Block 命令
|
* 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;
|
||||||
@@ -49,15 +50,16 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({
|
|||||||
|
|
||||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||||
|
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
changes: {
|
changes: {
|
||||||
from: block.delimiter.from,
|
from: block.delimiter.from,
|
||||||
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;
|
||||||
@@ -74,15 +76,16 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s
|
|||||||
|
|
||||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||||
|
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
changes: {
|
changes: {
|
||||||
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;
|
||||||
@@ -99,15 +102,16 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st
|
|||||||
|
|
||||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||||
|
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
changes: {
|
changes: {
|
||||||
from: block.delimiter.from,
|
from: block.delimiter.from,
|
||||||
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;
|
||||||
@@ -124,15 +128,16 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
|||||||
|
|
||||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||||
|
|
||||||
dispatch(state.update({
|
dispatch(state.update({
|
||||||
changes: {
|
changes: {
|
||||||
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;
|
||||||
@@ -143,26 +148,19 @@ 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);
|
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
||||||
|
|
||||||
// 重置正则表达式的 lastIndex
|
dispatch({
|
||||||
DELIMITER_REGEX.lastIndex = 0;
|
changes: {
|
||||||
if (currentDelimiter.match(DELIMITER_REGEX)) {
|
from: block.delimiter.from,
|
||||||
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
to: block.delimiter.to,
|
||||||
|
insert: newDelimiter,
|
||||||
dispatch({
|
},
|
||||||
changes: {
|
annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)],
|
||||||
from: block.delimiter.from,
|
});
|
||||||
to: block.delimiter.to,
|
|
||||||
insert: newDelimiter,
|
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;
|
||||||
@@ -380,4 +380,21 @@ 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)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,4 +190,4 @@ export function getCopyPasteExtensions() {
|
|||||||
return [
|
return [
|
||||||
codeBlockCopyCut,
|
codeBlockCopyCut,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 光标保护扩展
|
||||||
|
* 防止光标通过方向键移动到分隔符区域
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { EditorSelection } from '@codemirror/state';
|
||||||
|
import { blockState } from './state';
|
||||||
|
import { Block } from './types';
|
||||||
|
import { USER_EVENTS } from './annotation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二分查找:找到包含指定位置的块
|
||||||
|
* blocks 数组按位置排序,使用二分查找 O(log n)
|
||||||
|
*/
|
||||||
|
function findBlockAtPos(blocks: Block[], pos: number): Block | null {
|
||||||
|
let left = 0;
|
||||||
|
let right = blocks.length - 1;
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
const block = blocks[mid];
|
||||||
|
|
||||||
|
if (pos < block.range.from) {
|
||||||
|
// 位置在当前块之前
|
||||||
|
right = mid - 1;
|
||||||
|
} else if (pos > block.range.to) {
|
||||||
|
// 位置在当前块之后
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
// 位置在当前块范围内
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查位置是否在分隔符区域内
|
||||||
|
*/
|
||||||
|
function isInDelimiter(view: EditorView, pos: number): boolean {
|
||||||
|
try {
|
||||||
|
const blocks = view.state.field(blockState, false);
|
||||||
|
if (!blocks || blocks.length === 0) return false;
|
||||||
|
|
||||||
|
const block = findBlockAtPos(blocks, pos);
|
||||||
|
if (!block) return false;
|
||||||
|
|
||||||
|
// 检查是否在该块的分隔符区域内
|
||||||
|
return pos >= block.delimiter.from && pos < block.delimiter.to;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整光标位置,跳过分隔符区域
|
||||||
|
*/
|
||||||
|
function adjustPosition(view: EditorView, pos: number, forward: boolean): number {
|
||||||
|
try {
|
||||||
|
const blocks = view.state.field(blockState, false);
|
||||||
|
if (!blocks || blocks.length === 0) return pos;
|
||||||
|
|
||||||
|
const block = findBlockAtPos(blocks, pos);
|
||||||
|
if (!block) return pos;
|
||||||
|
|
||||||
|
// 如果位置在分隔符内
|
||||||
|
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
|
||||||
|
// 向前移动:跳到该块内容的开始
|
||||||
|
// 向后移动:跳到前一个块的内容末尾
|
||||||
|
if (forward) {
|
||||||
|
return block.content.from;
|
||||||
|
} else {
|
||||||
|
// 找到前一个块的索引
|
||||||
|
const blockIndex = blocks.indexOf(block);
|
||||||
|
if (blockIndex > 0) {
|
||||||
|
const prevBlock = blocks[blockIndex - 1];
|
||||||
|
return prevBlock.content.to;
|
||||||
|
}
|
||||||
|
return block.delimiter.from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
} catch {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 光标保护扩展
|
||||||
|
* 拦截方向键移动,防止光标进入分隔符区域
|
||||||
|
*/
|
||||||
|
export function createCursorProtection() {
|
||||||
|
return EditorView.domEventHandlers({
|
||||||
|
keydown(event, view) {
|
||||||
|
// 只处理方向键
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前光标位置
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const currentPos = selection.head;
|
||||||
|
|
||||||
|
// 计算目标位置
|
||||||
|
let targetPos = currentPos;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
targetPos = Math.max(0, currentPos - 1);
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
targetPos = Math.min(view.state.doc.length, currentPos + 1);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
const line = view.state.doc.lineAt(currentPos);
|
||||||
|
if (line.number > 1) {
|
||||||
|
const prevLine = view.state.doc.line(line.number - 1);
|
||||||
|
const col = currentPos - line.from;
|
||||||
|
targetPos = Math.min(prevLine.from + col, prevLine.to);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
const line = view.state.doc.lineAt(currentPos);
|
||||||
|
if (line.number < view.state.doc.lines) {
|
||||||
|
const nextLine = view.state.doc.line(line.number + 1);
|
||||||
|
const col = currentPos - line.from;
|
||||||
|
targetPos = Math.min(nextLine.from + col, nextLine.to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目标位置是否在分隔符内
|
||||||
|
if (isInDelimiter(view, targetPos)) {
|
||||||
|
// 调整位置
|
||||||
|
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
|
||||||
|
const adjustedPos = adjustPosition(view, targetPos, forward);
|
||||||
|
|
||||||
|
// 移动光标到调整后的位置
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(adjustedPos),
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: USER_EVENTS.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
// 阻止默认行为
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 块开始装饰组件
|
* 块开始装饰组件
|
||||||
@@ -114,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 块背景层 - 修复高度计算问题
|
* 块背景层 - 修复高度计算问题
|
||||||
|
*
|
||||||
|
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||||
|
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||||
|
* 行的坐标也不会受影响,边界线位置正确。
|
||||||
*/
|
*/
|
||||||
const blockLayer = layer({
|
const blockLayer = layer({
|
||||||
above: false,
|
above: false,
|
||||||
@@ -134,14 +139,17 @@ const blockLayer = layer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// view.coordsAtPos 如果编辑器不可见则返回 null
|
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||||
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
|
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||||
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
|
|
||||||
|
|
||||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||||
idx++;
|
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||||
return;
|
const toLineBlock = view.lineBlockAt(toPos);
|
||||||
}
|
|
||||||
|
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||||
|
// 转换为视口坐标进行后续计算
|
||||||
|
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||||
|
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||||
|
|
||||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||||
if (idx === blocks.length - 1) {
|
if (idx === blocks.length - 1) {
|
||||||
@@ -150,7 +158,7 @@ const blockLayer = layer({
|
|||||||
|
|
||||||
// 让最后一个块直接延伸到编辑器底部
|
// 让最后一个块直接延伸到编辑器底部
|
||||||
if (contentBottom < editorHeight) {
|
if (contentBottom < editorHeight) {
|
||||||
const extraHeight = editorHeight - contentBottom-10;
|
const extraHeight = editorHeight - contentBottom - 10;
|
||||||
toCoordsBottom += extraHeight;
|
toCoordsBottom += extraHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,10 +188,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 +203,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) {
|
||||||
@@ -261,4 +274,4 @@ export function getBlockDecorationExtensions(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +130,9 @@ 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;
|
||||||
@@ -100,4 +102,4 @@ export const formatBlockContent = (view) => {
|
|||||||
// 执行异步格式化
|
// 执行异步格式化
|
||||||
performFormat();
|
performFormat();
|
||||||
return true; // 立即返回 true,表示命令已开始执行
|
return true; // 立即返回 true,表示命令已开始执行
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {getCodeBlockLanguageExtension} from './lang-parser';
|
|||||||
import {createLanguageDetection} from './lang-detect';
|
import {createLanguageDetection} from './lang-detect';
|
||||||
import {SupportedLanguage} from './types';
|
import {SupportedLanguage} from './types';
|
||||||
import {getMathBlockExtensions} from './mathBlock';
|
import {getMathBlockExtensions} from './mathBlock';
|
||||||
|
import {createCursorProtection} from './cursorProtection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代码块扩展配置选项
|
* 代码块扩展配置选项
|
||||||
@@ -108,6 +109,9 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
|
|||||||
showBackground
|
showBackground
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 光标保护(防止方向键移动到分隔符上)
|
||||||
|
createCursorProtection(),
|
||||||
|
|
||||||
// 块选择功能
|
// 块选择功能
|
||||||
...getBlockSelectExtensions(),
|
...getBlockSelectExtensions(),
|
||||||
|
|
||||||
@@ -129,6 +133,12 @@ export {
|
|||||||
type CreateBlockOptions,
|
type CreateBlockOptions,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// 导出解析器函数
|
||||||
|
export {
|
||||||
|
getActiveBlock,
|
||||||
|
getBlockFromPos
|
||||||
|
} from './parser';
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
export {
|
export {
|
||||||
blockState,
|
blockState,
|
||||||
@@ -207,6 +217,11 @@ export {
|
|||||||
getMathBlockExtensions
|
getMathBlockExtensions
|
||||||
} from './mathBlock';
|
} from './mathBlock';
|
||||||
|
|
||||||
|
// 光标保护功能
|
||||||
|
export {
|
||||||
|
createCursorProtection
|
||||||
|
} from './cursorProtection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认导出
|
* 默认导出
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
import {jsonLanguage} from "@codemirror/lang-json";
|
import {jsonLanguage} from "@codemirror/lang-json";
|
||||||
import {pythonLanguage} from "@codemirror/lang-python";
|
import {pythonLanguage} from "@codemirror/lang-python";
|
||||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||||
import {htmlLanguage} from "@codemirror/lang-html";
|
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||||
import {StandardSQL} from "@codemirror/lang-sql";
|
import {StandardSQL} from "@codemirror/lang-sql";
|
||||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||||
|
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||||
|
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||||
|
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||||
|
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||||
|
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||||
import {javaLanguage} from "@codemirror/lang-java";
|
import {javaLanguage} from "@codemirror/lang-java";
|
||||||
import {phpLanguage} from "@codemirror/lang-php";
|
import {phpLanguage} from "@codemirror/lang-php";
|
||||||
import {cssLanguage} from "@codemirror/lang-css";
|
import {cssLanguage} from "@codemirror/lang-css";
|
||||||
@@ -22,9 +27,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
|||||||
import {sassLanguage} from "@codemirror/lang-sass";
|
import {sassLanguage} from "@codemirror/lang-sass";
|
||||||
import {lessLanguage} from "@codemirror/lang-less";
|
import {lessLanguage} from "@codemirror/lang-less";
|
||||||
import {angularLanguage} from "@codemirror/lang-angular";
|
import {angularLanguage} from "@codemirror/lang-angular";
|
||||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||||
import {StreamLanguage} from "@codemirror/language";
|
import {StreamLanguage} from "@codemirror/language";
|
||||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||||
@@ -64,6 +69,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
|||||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||||
|
import {languages} from "@codemirror/language-data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言信息类
|
* 语言信息类
|
||||||
@@ -110,7 +116,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
parser: "sql",
|
parser: "sql",
|
||||||
plugins: [sqlPrettierPlugin]
|
plugins: [sqlPrettierPlugin]
|
||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
|
base: markdownLanguage,
|
||||||
|
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
||||||
|
completeHTMLTags: true,
|
||||||
|
pasteURLAsLink: true,
|
||||||
|
htmlTagLanguage: html({
|
||||||
|
matchClosingTags: true,
|
||||||
|
autoCloseTags: true
|
||||||
|
}),
|
||||||
|
addKeymap: true,
|
||||||
|
codeLanguages: languages,
|
||||||
|
|
||||||
|
}).language.parser, ["md"], {
|
||||||
parser: "markdown",
|
parser: "markdown",
|
||||||
plugins: [markdownPrettierPlugin]
|
plugins: [markdownPrettierPlugin]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -157,4 +160,4 @@ export const moveLineUp = ({ state, dispatch }: { state: any; dispatch: any }):
|
|||||||
*/
|
*/
|
||||||
export const moveLineDown = ({ state, dispatch }: { state: any; dispatch: any }): boolean => {
|
export const moveLineDown = ({ state, dispatch }: { state: any; dispatch: any }): boolean => {
|
||||||
return moveLine(state, dispatch, true);
|
return moveLine(state, dispatch, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
|
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language';
|
||||||
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage, Document } from './lang-parser/parser.terms.js';
|
import type { Tree } from '@lezer/common';
|
||||||
|
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
|
||||||
import {
|
import {
|
||||||
SupportedLanguage,
|
SupportedLanguage,
|
||||||
DELIMITER_REGEX,
|
DELIMITER_REGEX,
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -104,203 +100,78 @@ export let firstBlockDelimiterSize: number | undefined;
|
|||||||
*/
|
*/
|
||||||
export function getBlocksFromString(state: EditorState): Block[] {
|
export function getBlocksFromString(state: EditorState): Block[] {
|
||||||
const blocks: Block[] = [];
|
const blocks: 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("∞∞∞")) {
|
|
||||||
// 文档直接以分隔符开始,调整为标准格式
|
if (pos === -1) {
|
||||||
pos = 0;
|
firstBlockDelimiterSize = 0;
|
||||||
} else if (content.startsWith("\n∞∞∞")) {
|
return [createPlainTextBlock(0, doc.length)];
|
||||||
// 文档以换行符+分隔符开始,这是标准格式,从位置0开始解析
|
}
|
||||||
pos = 0;
|
|
||||||
} else {
|
if (pos > 0) {
|
||||||
// 如果文档不以分隔符开始,查找第一个分隔符
|
blocks.push(createPlainTextBlock(0, pos));
|
||||||
const firstDelimPos = content.indexOf(delim);
|
}
|
||||||
|
|
||||||
if (firstDelimPos === -1) {
|
while (pos !== -1 && pos < doc.length) {
|
||||||
// 如果没有找到分隔符,整个文档作为一个文本块
|
const blockStart = pos;
|
||||||
firstBlockDelimiterSize = 0;
|
const langStart = blockStart + delimiter.length;
|
||||||
return [{
|
const delimiterEnd = content.indexOf(DELIMITER_SUFFIX, langStart);
|
||||||
language: {
|
if (delimiterEnd === -1) break;
|
||||||
name: 'text',
|
|
||||||
auto: false,
|
const delimiterText = content.slice(blockStart, delimiterEnd + suffixLength);
|
||||||
},
|
const delimiterInfo = parseDelimiter(delimiterText);
|
||||||
content: {
|
if (!delimiterInfo) break;
|
||||||
from: 0,
|
|
||||||
to: doc.length,
|
const contentStart = delimiterEnd + suffixLength;
|
||||||
},
|
const nextDelimiter = content.indexOf(delimiter, contentStart);
|
||||||
delimiter: {
|
const contentEnd = nextDelimiter === -1 ? doc.length : nextDelimiter;
|
||||||
from: 0,
|
|
||||||
to: 0,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
from: 0,
|
|
||||||
to: doc.length,
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建第一个块(分隔符之前的内容)
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文档中的所有块
|
* 获取文档中的所有块
|
||||||
*/
|
*/
|
||||||
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) {
|
||||||
@@ -219,4 +221,4 @@ export function getBlockSelectExtensions() {
|
|||||||
return [
|
return [
|
||||||
emptyBlockSelected,
|
emptyBlockSelected,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +47,9 @@ 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;
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user