Compare commits
41 Commits
http
...
4b0f39d747
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3393bc84e3 | |||
| 286b0159d7 | |||
| cc98e556c6 | |||
| 5902f482d9 | |||
| 551e7e2cfd | |||
| e0179b5838 | |||
| df79267e16 | |||
| 1f0254822f | |||
| e9b6fef3cd |
325
.github/workflows/build-release.yml
vendored
Normal file
325
.github/workflows/build-release.yml
vendored
Normal file
@@ -0,0 +1,325 @@
|
||||
name: Build and Release Voidraft
|
||||
|
||||
on:
|
||||
# 推送标签时触发(用于正式发布)
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # 触发条件:推送 v 开头的标签,如 v1.0.0
|
||||
branches:
|
||||
- main # 仅当标签在 main 分支时触发
|
||||
|
||||
# 手动触发(用于测试)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: '是否创建 Release(测试时选 false)'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
platforms:
|
||||
description: '要构建的平台(用逗号分隔:windows,linux,macos-intel,macos-arm)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'windows,linux'
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096" # 防止 Node.js 内存溢出
|
||||
|
||||
jobs:
|
||||
# 准备构建配置
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
should_release: ${{ steps.check-release.outputs.should_release }}
|
||||
steps:
|
||||
- name: 确定构建平台
|
||||
id: set-matrix
|
||||
run: |
|
||||
# 如果是手动触发,根据输入决定平台
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PLATFORMS="${{ github.event.inputs.platforms }}"
|
||||
else
|
||||
# 标签触发,构建所有平台
|
||||
PLATFORMS="windows,linux,macos-intel,macos-arm"
|
||||
fi
|
||||
|
||||
echo "构建平台: $PLATFORMS"
|
||||
|
||||
# 构建矩阵 JSON
|
||||
MATRIX='{"include":[]}'
|
||||
|
||||
if [[ "$PLATFORMS" == *"windows"* ]]; then
|
||||
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"windows-latest","os":"windows","arch":"amd64","platform_dir":"windows","id":"windows"}]')
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORMS" == *"linux"* ]]; then
|
||||
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"ubuntu-22.04","os":"linux","arch":"amd64","platform_dir":"linux","id":"linux"}]')
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORMS" == *"macos-intel"* ]]; then
|
||||
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"amd64","platform_dir":"darwin","id":"macos-intel"}]')
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORMS" == *"macos-arm"* ]]; then
|
||||
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"arm64","platform_dir":"darwin","id":"macos-arm"}]')
|
||||
fi
|
||||
|
||||
# 使用 -c 确保输出紧凑的单行 JSON,符合 GitHub Actions 输出格式
|
||||
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> $GITHUB_OUTPUT
|
||||
|
||||
# 输出美化的 JSON 用于日志查看
|
||||
echo "生成的矩阵:"
|
||||
echo "$MATRIX" | jq .
|
||||
|
||||
- name: 检查是否创建 Release
|
||||
id: check-release
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# 手动触发,根据输入决定
|
||||
echo "should_release=${{ github.event.inputs.release }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 标签触发,自动创建 Release
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
if: ${{ fromJson(needs.prepare.outputs.matrix).include[0] != null }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: 设置 Go 环境
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache: true
|
||||
|
||||
- name: 设置 Node.js 环境
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# Linux 平台依赖
|
||||
- name: 安装 Linux 依赖
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Wails 3 + 项目特定依赖
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
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 平台依赖
|
||||
- name: 设置 Windows 构建环境
|
||||
if: matrix.os == 'windows'
|
||||
run: |
|
||||
# 安装 NSIS (用于创建安装程序)
|
||||
choco install nsis -y
|
||||
# 将 NSIS 添加到 PATH
|
||||
echo "C:\Program Files (x86)\NSIS" >> $GITHUB_PATH
|
||||
shell: bash
|
||||
|
||||
# macOS 平台依赖
|
||||
- name: 设置 macOS 构建环境
|
||||
if: matrix.os == 'darwin'
|
||||
run: |
|
||||
# Xcode 命令行工具通常已安装
|
||||
xcode-select --install 2>/dev/null || true
|
||||
|
||||
# 安装 Wails CLI
|
||||
- name: 安装 Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||
|
||||
# 安装前端依赖
|
||||
- name: 安装前端依赖
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
# 构建前端
|
||||
- name: 构建前端
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
# 使用 Wails Task 构建和打包应用
|
||||
- name: 构建和打包 Wails 应用
|
||||
run: wails3 task ${{ matrix.id }}:package PRODUCTION=true ARCH=${{ matrix.arch }}
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
APP_NAME: voidraft
|
||||
BIN_DIR: bin
|
||||
ROOT_DIR: .
|
||||
shell: bash
|
||||
|
||||
# 整理构建产物
|
||||
- name: 整理构建产物
|
||||
id: organize_artifacts
|
||||
run: |
|
||||
echo "=== 构建产物列表 ==="
|
||||
ls -lhR bin/ || echo "bin 目录不存在"
|
||||
|
||||
# 创建输出目录
|
||||
mkdir -p artifacts
|
||||
|
||||
# 根据平台复制产物
|
||||
if [ "${{ matrix.os }}" = "windows" ]; then
|
||||
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
|
||||
echo "macOS 平台:查找 .app bundle"
|
||||
find bin -name "*.app" -type d
|
||||
# macOS: .app bundle,打包成 zip
|
||||
if [ -d "bin/voidraft.app" ]; then
|
||||
cd bin
|
||||
zip -r ../artifacts/voidraft-darwin-${{ matrix.arch }}.app.zip voidraft.app
|
||||
cd ..
|
||||
else
|
||||
echo "未找到 .app bundle"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== 最终产物 ==="
|
||||
ls -lh artifacts/ || echo "artifacts 目录为空"
|
||||
shell: bash
|
||||
|
||||
# 上传构建产物到 Artifacts
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voidraft-${{ matrix.id }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: warn
|
||||
|
||||
# 创建 GitHub Release 并上传所有构建产物
|
||||
release:
|
||||
needs: [prepare, build]
|
||||
if: ${{ needs.prepare.outputs.should_release == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 下载所有构建产物
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: 显示下载的文件
|
||||
run: |
|
||||
echo "下载的构建产物:"
|
||||
ls -R artifacts/
|
||||
|
||||
- name: 准备 Release 文件
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec cp {} release/ \;
|
||||
ls -lh release/
|
||||
|
||||
- name: 生成 Release 说明
|
||||
id: release_notes
|
||||
run: |
|
||||
# 获取版本号
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.sha }}"
|
||||
VERSION_NAME="测试构建 ${VERSION:0:7}"
|
||||
else
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION_NAME="${VERSION}"
|
||||
fi
|
||||
|
||||
cat > release_notes.md << EOF
|
||||
## Voidraft ${VERSION_NAME}
|
||||
|
||||
### 📦 下载
|
||||
|
||||
根据你的操作系统选择对应的版本:
|
||||
|
||||
- **Windows (64位)**: \`voidraft-windows-amd64.exe\`
|
||||
- **Linux (64位)**: \`voidraft-linux-amd64\`
|
||||
- **macOS (Intel)**: \`voidraft-darwin-amd64.zip\`
|
||||
- **macOS (Apple Silicon)**: \`voidraft-darwin-arm64.zip\`
|
||||
|
||||
### 📝 更新内容
|
||||
|
||||
请查看 [提交历史](../../commits/${{ github.ref_name }}) 了解本次更新的详细内容。
|
||||
|
||||
### 💡 使用说明
|
||||
|
||||
#### Windows
|
||||
1. 下载 `voidraft-windows-amd64.exe`
|
||||
2. 直接运行即可
|
||||
|
||||
#### Linux
|
||||
1. 下载 `voidraft-linux-amd64`
|
||||
2. 添加执行权限:`chmod +x voidraft-linux-amd64`
|
||||
3. 运行:`./voidraft-linux-amd64`
|
||||
|
||||
#### macOS
|
||||
1. 下载对应架构的 zip 文件
|
||||
2. 解压后运行
|
||||
3. 如果提示无法打开,请在 系统偏好设置 > 安全性与隐私 中允许运行
|
||||
|
||||
---
|
||||
|
||||
构建时间: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
EOF
|
||||
|
||||
- name: 创建 GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
tag_name: ${{ github.event_name == 'workflow_dispatch' && format('test-{0}', github.sha) || github.ref_name }}
|
||||
name: ${{ github.event_name == 'workflow_dispatch' && format('测试构建 {0}', github.sha) || github.ref_name }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
Taskfile.yml
12
Taskfile.yml
@@ -12,25 +12,13 @@ vars:
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
|
||||
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:
|
||||
summary: Builds the application
|
||||
deps: [version]
|
||||
cmds:
|
||||
- task: "{{OS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
deps: [version]
|
||||
cmds:
|
||||
- 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.
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.9 KiB |
@@ -39,7 +39,7 @@ tasks:
|
||||
summary: Generates Windows `.syso` file
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/favicon_256x256.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
|
||||
@@ -62,19 +62,4 @@ export class ServiceOptions {
|
||||
}
|
||||
}
|
||||
|
||||
export class WebviewWindow {
|
||||
|
||||
/** Creates a new WebviewWindow instance. */
|
||||
constructor($$source: Partial<WebviewWindow> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebviewWindow instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WebviewWindow {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WebviewWindow($$parsedSource as Partial<WebviewWindow>);
|
||||
}
|
||||
}
|
||||
export type Window = any;
|
||||
|
||||
@@ -1170,7 +1170,7 @@ export class Theme {
|
||||
this["type"] = ("" as ThemeType);
|
||||
}
|
||||
if (!("colors" in $$source)) {
|
||||
this["colors"] = (new ThemeColorConfig());
|
||||
this["colors"] = ({} as ThemeColorConfig);
|
||||
}
|
||||
if (!("isDefault" in $$source)) {
|
||||
this["isDefault"] = false;
|
||||
@@ -1199,303 +1199,9 @@ export class Theme {
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
|
||||
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
|
||||
*/
|
||||
export class ThemeColorConfig {
|
||||
/**
|
||||
* 主题基本信息
|
||||
* 主题名称
|
||||
*/
|
||||
"name": string;
|
||||
|
||||
/**
|
||||
* 是否为深色主题
|
||||
*/
|
||||
"dark": boolean;
|
||||
|
||||
/**
|
||||
* 基础色调
|
||||
* 主背景色
|
||||
*/
|
||||
"background": string;
|
||||
|
||||
/**
|
||||
* 次要背景色(用于代码块交替背景)
|
||||
*/
|
||||
"backgroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 面板背景
|
||||
*/
|
||||
"surface": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单背景
|
||||
*/
|
||||
"dropdownBackground": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单边框
|
||||
*/
|
||||
"dropdownBorder": string;
|
||||
|
||||
/**
|
||||
* 文本颜色
|
||||
* 主文本色
|
||||
*/
|
||||
"foreground": string;
|
||||
|
||||
/**
|
||||
* 次要文本色
|
||||
*/
|
||||
"foregroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 注释色
|
||||
*/
|
||||
"comment": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 核心
|
||||
* 关键字
|
||||
*/
|
||||
"keyword": string;
|
||||
|
||||
/**
|
||||
* 字符串
|
||||
*/
|
||||
"string": string;
|
||||
|
||||
/**
|
||||
* 函数名
|
||||
*/
|
||||
"function": string;
|
||||
|
||||
/**
|
||||
* 数字
|
||||
*/
|
||||
"number": string;
|
||||
|
||||
/**
|
||||
* 操作符
|
||||
*/
|
||||
"operator": string;
|
||||
|
||||
/**
|
||||
* 变量
|
||||
*/
|
||||
"variable": string;
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
"type": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 扩展
|
||||
* 常量
|
||||
*/
|
||||
"constant": string;
|
||||
|
||||
/**
|
||||
* 存储类型(如 static, const)
|
||||
*/
|
||||
"storage": string;
|
||||
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
"parameter": string;
|
||||
|
||||
/**
|
||||
* 类名
|
||||
*/
|
||||
"class": string;
|
||||
|
||||
/**
|
||||
* 标题(Markdown等)
|
||||
*/
|
||||
"heading": string;
|
||||
|
||||
/**
|
||||
* 无效内容/错误
|
||||
*/
|
||||
"invalid": string;
|
||||
|
||||
/**
|
||||
* 正则表达式
|
||||
*/
|
||||
"regexp": string;
|
||||
|
||||
/**
|
||||
* 界面元素
|
||||
* 光标
|
||||
*/
|
||||
"cursor": string;
|
||||
|
||||
/**
|
||||
* 选中背景
|
||||
*/
|
||||
"selection": string;
|
||||
|
||||
/**
|
||||
* 失焦选中背景
|
||||
*/
|
||||
"selectionBlur": string;
|
||||
|
||||
/**
|
||||
* 当前行高亮
|
||||
*/
|
||||
"activeLine": string;
|
||||
|
||||
/**
|
||||
* 行号
|
||||
*/
|
||||
"lineNumber": string;
|
||||
|
||||
/**
|
||||
* 活动行号颜色
|
||||
*/
|
||||
"activeLineNumber": string;
|
||||
|
||||
/**
|
||||
* 边框和分割线
|
||||
* 边框色
|
||||
*/
|
||||
"borderColor": string;
|
||||
|
||||
/**
|
||||
* 浅色边框
|
||||
*/
|
||||
"borderLight": string;
|
||||
|
||||
/**
|
||||
* 搜索和匹配
|
||||
* 搜索匹配
|
||||
*/
|
||||
"searchMatch": string;
|
||||
|
||||
/**
|
||||
* 匹配括号
|
||||
*/
|
||||
"matchingBracket": string;
|
||||
|
||||
/** Creates a new ThemeColorConfig instance. */
|
||||
constructor($$source: Partial<ThemeColorConfig> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("dark" in $$source)) {
|
||||
this["dark"] = false;
|
||||
}
|
||||
if (!("background" in $$source)) {
|
||||
this["background"] = "";
|
||||
}
|
||||
if (!("backgroundSecondary" in $$source)) {
|
||||
this["backgroundSecondary"] = "";
|
||||
}
|
||||
if (!("surface" in $$source)) {
|
||||
this["surface"] = "";
|
||||
}
|
||||
if (!("dropdownBackground" in $$source)) {
|
||||
this["dropdownBackground"] = "";
|
||||
}
|
||||
if (!("dropdownBorder" in $$source)) {
|
||||
this["dropdownBorder"] = "";
|
||||
}
|
||||
if (!("foreground" in $$source)) {
|
||||
this["foreground"] = "";
|
||||
}
|
||||
if (!("foregroundSecondary" in $$source)) {
|
||||
this["foregroundSecondary"] = "";
|
||||
}
|
||||
if (!("comment" in $$source)) {
|
||||
this["comment"] = "";
|
||||
}
|
||||
if (!("keyword" in $$source)) {
|
||||
this["keyword"] = "";
|
||||
}
|
||||
if (!("string" in $$source)) {
|
||||
this["string"] = "";
|
||||
}
|
||||
if (!("function" in $$source)) {
|
||||
this["function"] = "";
|
||||
}
|
||||
if (!("number" in $$source)) {
|
||||
this["number"] = "";
|
||||
}
|
||||
if (!("operator" in $$source)) {
|
||||
this["operator"] = "";
|
||||
}
|
||||
if (!("variable" in $$source)) {
|
||||
this["variable"] = "";
|
||||
}
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("constant" in $$source)) {
|
||||
this["constant"] = "";
|
||||
}
|
||||
if (!("storage" in $$source)) {
|
||||
this["storage"] = "";
|
||||
}
|
||||
if (!("parameter" in $$source)) {
|
||||
this["parameter"] = "";
|
||||
}
|
||||
if (!("class" in $$source)) {
|
||||
this["class"] = "";
|
||||
}
|
||||
if (!("heading" in $$source)) {
|
||||
this["heading"] = "";
|
||||
}
|
||||
if (!("invalid" in $$source)) {
|
||||
this["invalid"] = "";
|
||||
}
|
||||
if (!("regexp" in $$source)) {
|
||||
this["regexp"] = "";
|
||||
}
|
||||
if (!("cursor" in $$source)) {
|
||||
this["cursor"] = "";
|
||||
}
|
||||
if (!("selection" in $$source)) {
|
||||
this["selection"] = "";
|
||||
}
|
||||
if (!("selectionBlur" in $$source)) {
|
||||
this["selectionBlur"] = "";
|
||||
}
|
||||
if (!("activeLine" in $$source)) {
|
||||
this["activeLine"] = "";
|
||||
}
|
||||
if (!("lineNumber" in $$source)) {
|
||||
this["lineNumber"] = "";
|
||||
}
|
||||
if (!("activeLineNumber" in $$source)) {
|
||||
this["activeLineNumber"] = "";
|
||||
}
|
||||
if (!("borderColor" in $$source)) {
|
||||
this["borderColor"] = "";
|
||||
}
|
||||
if (!("borderLight" in $$source)) {
|
||||
this["borderLight"] = "";
|
||||
}
|
||||
if (!("searchMatch" in $$source)) {
|
||||
this["searchMatch"] = "";
|
||||
}
|
||||
if (!("matchingBracket" in $$source)) {
|
||||
this["matchingBracket"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ThemeColorConfig instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): ThemeColorConfig {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
|
||||
}
|
||||
}
|
||||
export type ThemeColorConfig = { [_: string]: any };
|
||||
|
||||
/**
|
||||
* ThemeType 主题类型枚举
|
||||
@@ -1636,6 +1342,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
||||
});
|
||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType8 = HotkeyCombo.createFrom;
|
||||
const $$createType9 = ThemeColorConfig.createFrom;
|
||||
var $$createType9 = (function $$initCreateType9(...args): any {
|
||||
if ($$createType9 === $$initCreateType9) {
|
||||
$$createType9 = $$createType7;
|
||||
}
|
||||
return $$createType9(...args);
|
||||
});
|
||||
const $$createType10 = GithubConfig.createFrom;
|
||||
const $$createType11 = GiteaConfig.createFrom;
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as models$0 from "../models/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* Get 获取配置项
|
||||
*/
|
||||
@@ -34,22 +41,6 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetConfigDir 获取配置目录
|
||||
*/
|
||||
export function GetConfigDir(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2275626561) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetSettingsPath 获取设置文件路径
|
||||
*/
|
||||
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2175583370) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* MigrateConfig 执行配置迁移
|
||||
*/
|
||||
@@ -74,6 +65,14 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup initializes the service when the application starts
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3311949428, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set 设置配置项
|
||||
*/
|
||||
@@ -83,34 +82,18 @@ export function Set(key: string, value: any): Promise<void> & { cancel(): void }
|
||||
}
|
||||
|
||||
/**
|
||||
* SetBackupConfigChangeCallback 设置备份配置变更回调
|
||||
* Watch 注册配置变更监听器
|
||||
*/
|
||||
export function SetBackupConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3264871659, callback) as any;
|
||||
export function Watch(path: string, callback: $models.ObserverCallback): Promise<$models.CancelFunc> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1143583035, path, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetDataPathChangeCallback 设置数据路径配置变更回调
|
||||
* WatchWithContext 使用 Context 注册监听器
|
||||
*/
|
||||
export function SetDataPathChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(393017412, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetHotkeyChangeCallback 设置热键配置变更回调
|
||||
*/
|
||||
export function SetHotkeyChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(283872321, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
|
||||
*/
|
||||
export function SetWindowSnapConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2324961653, callback) as any;
|
||||
export function WatchWithContext(path: string, callback: $models.ObserverCallback): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1454973098, path, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
// @ts-ignore: Unused imports
|
||||
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 注册模型与表的映射关系
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* HotkeyService Windows全局热键服务
|
||||
* HotkeyService 全局热键服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -48,8 +48,8 @@ export function IsRegistered(): Promise<boolean> & { cancel(): void } {
|
||||
/**
|
||||
* RegisterHotkey 注册全局热键
|
||||
*/
|
||||
export function RegisterHotkey(hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1103945691, hotkey) as any;
|
||||
export function RegisterHotkey(combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1103945691, combo) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup initializes the service when the application starts
|
||||
* ServiceStartup 服务启动时初始化
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3079990808, options) as any;
|
||||
@@ -80,8 +80,8 @@ export function UnregisterHotkey(): Promise<void> & { cancel(): void } {
|
||||
/**
|
||||
* UpdateHotkey 更新热键配置
|
||||
*/
|
||||
export function UpdateHotkey(enable: boolean, hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(823285555, enable, hotkey) as any;
|
||||
export function UpdateHotkey(enable: boolean, combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(823285555, enable, combo) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as http$0 from "../../../net/http/models.js";
|
||||
@@ -15,6 +12,12 @@ import * as http$0 from "../../../net/http/models.js";
|
||||
// @ts-ignore: Unused imports
|
||||
import * as time$0 from "../../../time/models.js";
|
||||
|
||||
/**
|
||||
* CancelFunc 取消订阅函数
|
||||
* 调用此函数可以取消对配置的监听
|
||||
*/
|
||||
export type CancelFunc = any;
|
||||
|
||||
/**
|
||||
* HttpRequest HTTP请求结构
|
||||
*/
|
||||
@@ -263,6 +266,11 @@ export class OSInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ObserverCallback 观察者回调函数
|
||||
*/
|
||||
export type ObserverCallback = any;
|
||||
|
||||
/**
|
||||
* SelfUpdateResult 自我更新结果
|
||||
*/
|
||||
@@ -394,62 +402,6 @@ export class SystemInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowInfo 窗口信息
|
||||
*/
|
||||
export class WindowInfo {
|
||||
"Window": application$0.WebviewWindow | null;
|
||||
"DocumentID": number;
|
||||
"Title": string;
|
||||
|
||||
/** Creates a new WindowInfo instance. */
|
||||
constructor($$source: Partial<WindowInfo> = {}) {
|
||||
if (!("Window" in $$source)) {
|
||||
this["Window"] = null;
|
||||
}
|
||||
if (!("DocumentID" in $$source)) {
|
||||
this["DocumentID"] = 0;
|
||||
}
|
||||
if (!("Title" in $$source)) {
|
||||
this["Title"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WindowInfo instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WindowInfo {
|
||||
const $$createField0_0 = $$createType8;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("Window" in $$parsedSource) {
|
||||
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
|
||||
}
|
||||
return new WindowInfo($$parsedSource as Partial<WindowInfo>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowSnapService 窗口吸附服务
|
||||
*/
|
||||
export class WindowSnapService {
|
||||
|
||||
/** Creates a new WindowSnapService instance. */
|
||||
constructor($$source: Partial<WindowSnapService> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WindowSnapService instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WindowSnapService {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WindowSnapService($$parsedSource as Partial<WindowSnapService>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
var $$createType1 = (function $$initCreateType1(...args): any {
|
||||
@@ -463,5 +415,3 @@ const $$createType3 = $Create.Map($Create.Any, $$createType2);
|
||||
const $$createType4 = OSInfo.createFrom;
|
||||
const $$createType5 = $Create.Nullable($$createType4);
|
||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType7 = application$0.WebviewWindow.createFrom;
|
||||
const $$createType8 = $Create.Nullable($$createType7);
|
||||
|
||||
@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
|
||||
import * as models$0 from "../models/models.js";
|
||||
|
||||
/**
|
||||
* GetAllThemes 获取所有主题
|
||||
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
|
||||
*/
|
||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetThemeByID 根据ID或名称获取主题
|
||||
* 如果 id > 0,按ID查询;如果 id = 0,按名称查询
|
||||
*/
|
||||
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(127385338, id, name) as any;
|
||||
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1938954770, name) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -43,10 +30,10 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetTheme 重置主题为预设配置
|
||||
* ResetTheme 删除指定主题的覆盖配置
|
||||
*/
|
||||
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
||||
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, name) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
@@ -59,7 +46,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时初始化
|
||||
* ServiceStartup 服务启动
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
||||
@@ -67,14 +54,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateTheme 更新主题
|
||||
* UpdateTheme 保存或更新主题覆盖
|
||||
*/
|
||||
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
||||
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = models$0.Theme.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* AutoShowHide 自动显示/隐藏主窗口
|
||||
*/
|
||||
export function AutoShowHide(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4044219428) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* HandleWindowClose 处理窗口关闭事件
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* WindowService 窗口管理服务(专注于窗口生命周期管理)
|
||||
* WindowService 窗口管理服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -12,15 +12,15 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
|
||||
/**
|
||||
* GetOpenWindows 获取所有打开的窗口信息
|
||||
* GetOpenWindows 获取所有打开的文档窗口
|
||||
*/
|
||||
export function GetOpenWindows(): Promise<$models.WindowInfo[]> & { cancel(): void } {
|
||||
export function GetOpenWindows(): Promise<application$0.Window[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1464997251) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
return $$createType0($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
@@ -51,13 +51,12 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowSnapService 设置窗口吸附服务引用
|
||||
* ServiceStartup 服务启动时初始化
|
||||
*/
|
||||
export function SetWindowSnapService(snapService: $models.WindowSnapService | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1105193745, snapService) as any;
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2432987694, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.WindowInfo.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType0 = $Create.Array($Create.Any);
|
||||
|
||||
4424
frontend/package-lock.json
generated
4424
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,15 @@
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"build:lang-parser": "node src/views/editor/extensions/codeblock/lang-parser/build-parser.js",
|
||||
"build:mermaid-parser": "node src/views/editor/language/mermaid/build-parsers.js",
|
||||
"test": "vitest",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"app:dev": "cd .. &&wails3 dev",
|
||||
"app:build": "cd .. && wails3 task build",
|
||||
"app:package": "cd .. && wails3 package",
|
||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
@@ -42,56 +47,64 @@
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/language-data": "^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/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.2",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
"@mdit/plugin-katex": "^0.23.2",
|
||||
"@mdit/plugin-tasklist": "^0.22.2",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"jsox": "^1.2.123",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"remarkable": "^2.0.1",
|
||||
"sass": "^1.93.3",
|
||||
"vue": "^3.5.22",
|
||||
"sass": "^1.94.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/remarkable": "^2.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.1.12",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.6",
|
||||
"vitest": "^4.0.8",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.2"
|
||||
"vue-tsc": "^3.1.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
鸿蒙字体压缩工具
|
||||
使用 fonttools 库压缩 TTF 字体文件,减小文件大小
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic"):
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return
|
||||
|
||||
# 查找所有字体文件
|
||||
font_files = find_font_files(font_directory)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" ✓ 成功: {format_file_size(original_size)} → {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" ✗ 失败: 输出文件未生成")
|
||||
else:
|
||||
print(f" ✗ 失败: 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("鸿蒙字体压缩工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取当前脚本所在目录
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 设置默认字体目录
|
||||
font_directory = current_dir
|
||||
|
||||
print(f"字体目录: {font_directory}")
|
||||
|
||||
# 让用户选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
Binary file not shown.
179
frontend/src/assets/fonts/README.md
Normal file
179
frontend/src/assets/fonts/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 字体压缩工具使用指南
|
||||
|
||||
## 📖 简介
|
||||
|
||||
`font_compressor.py` 是一个通用的字体压缩工具,可以:
|
||||
- ✅ 将 TTF、OTF、WOFF 字体文件转换为 WOFF2 格式
|
||||
- ✅ 支持相对路径和绝对路径
|
||||
- ✅ 自动生成 CSS 字体定义文件
|
||||
- ✅ 智能识别字体字重和样式
|
||||
- ✅ 批量处理整个目录(包括子目录)
|
||||
|
||||
## 🚀 前置要求
|
||||
|
||||
安装 Python 依赖包:
|
||||
|
||||
```bash
|
||||
pip install fonttools brotli
|
||||
```
|
||||
|
||||
## 📝 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```bash
|
||||
# 进入 fonts 目录
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 交互式模式处理当前目录
|
||||
python font_compressor.py
|
||||
|
||||
# 处理相对路径的 Monocraft 目录
|
||||
python font_compressor.py Monocraft
|
||||
|
||||
# 处理相对路径并指定压缩级别
|
||||
python font_compressor.py Monocraft -l basic
|
||||
```
|
||||
|
||||
### 生成 CSS 文件
|
||||
|
||||
```bash
|
||||
# 压缩 Monocraft 字体并生成 CSS 文件
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 Hack 字体并生成 CSS
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
|
||||
# 压缩 OpenSans 字体并生成 CSS
|
||||
python font_compressor.py OpenSans -l medium -c ../styles/opensans_fonts.css
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
```bash
|
||||
# 使用绝对路径
|
||||
python font_compressor.py E:\Go_WorkSpace\voidraft\frontend\src\assets\fonts\Monocraft -l basic -c monocraft.css
|
||||
|
||||
# 不同压缩级别
|
||||
python font_compressor.py Monocraft -l basic # 基础压缩,保留所有功能
|
||||
python font_compressor.py Monocraft -l medium # 中等压缩,平衡大小和功能
|
||||
python font_compressor.py Monocraft -l aggressive # 激进压缩,最小文件
|
||||
```
|
||||
|
||||
## ⚙️ 命令行参数
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `directory` | 字体目录(相对/绝对路径) | `Monocraft` 或 `/path/to/fonts` |
|
||||
| `-l, --level` | 压缩级别 (basic/medium/aggressive) | `-l basic` |
|
||||
| `-c, --css` | CSS 输出文件路径 | `-c monocraft.css` |
|
||||
| `--version` | 显示版本信息 | `--version` |
|
||||
| `-h, --help` | 显示帮助信息 | `-h` |
|
||||
|
||||
## 📊 压缩级别说明
|
||||
|
||||
### basic(基础) - 推荐
|
||||
- 保留大部分字体功能
|
||||
- 适合网页使用
|
||||
- 压缩率约 30-40%
|
||||
|
||||
### medium(中等)
|
||||
- 移除一些不常用的功能
|
||||
- 平衡文件大小和功能
|
||||
- 压缩率约 40-50%
|
||||
|
||||
### aggressive(激进)
|
||||
- 最大程度减小文件大小
|
||||
- 可能影响高级排版功能
|
||||
- 压缩率约 50-60%
|
||||
|
||||
## 📁 输出结果
|
||||
|
||||
### 字体文件
|
||||
压缩后的 `.woff2` 文件会保存在原文件相同的目录下,例如:
|
||||
- `Monocraft/ttf/Monocraft-Bold.ttf` → `Monocraft/ttf/Monocraft-Bold.woff2`
|
||||
- `Hack/hack-regular.ttf` → `Hack/hack-regular.woff2`
|
||||
|
||||
### CSS 文件
|
||||
生成的 CSS 文件会包含:
|
||||
- 自动识别的字体家族名称
|
||||
- 正确的字重和样式设置
|
||||
- 使用相对路径的字体引用
|
||||
- 按字重排序的 `@font-face` 定义
|
||||
|
||||
生成的 CSS 示例:
|
||||
|
||||
```css
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 实际使用示例
|
||||
|
||||
### 示例 1: 压缩 Monocraft 字体
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
```
|
||||
|
||||
这将:
|
||||
1. 扫描 `Monocraft/ttf` 和 `Monocraft/otf` 目录
|
||||
2. 将所有字体文件转换为 WOFF2
|
||||
3. 在 `frontend/src/assets/styles/monocraft_fonts.css` 生成 CSS 文件
|
||||
|
||||
### 示例 2: 批量处理多个字体目录
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 压缩 Monocraft
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 OpenSans
|
||||
python font_compressor.py OpenSans -l basic -c ../styles/opensans_fonts.css
|
||||
|
||||
# 压缩 Hack(已有 CSS,只需生成新版本对比)
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
```
|
||||
|
||||
## 🔍 字体信息自动识别
|
||||
|
||||
工具会自动从文件名识别:
|
||||
- **字重**:Thin(100), Light(300), Regular(400), Medium(500), SemiBold(600), Bold(700), Black(900)
|
||||
- **样式**:normal, italic
|
||||
- **字体家族**:自动去除字重和样式后缀
|
||||
|
||||
支持的命名格式:
|
||||
- `FontName-Bold.ttf`
|
||||
- `FontName_Bold.otf`
|
||||
- `FontName-BoldItalic.ttf`
|
||||
- `FontName_SemiBold_Italic.woff`
|
||||
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
```bash
|
||||
python font_compressor.py --help
|
||||
```
|
||||
|
||||
494
frontend/src/assets/fonts/font_compressor.py
Normal file
494
frontend/src/assets/fonts/font_compressor.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用字体压缩工具
|
||||
使用 fonttools 库将字体文件转换为 WOFF2 格式,减小文件大小
|
||||
支持 TTF、OTF、WOFF 等格式的字体文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str, exclude_woff2: bool = False) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
if exclude_woff2:
|
||||
font_extensions = ['.ttf', '.otf', '.woff']
|
||||
else:
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def parse_font_info(filename: str) -> Dict[str, any]:
|
||||
"""
|
||||
从字体文件名解析字体信息(字重、样式等)
|
||||
|
||||
Args:
|
||||
filename: 字体文件名(不含路径)
|
||||
|
||||
Returns:
|
||||
包含字体信息的字典
|
||||
"""
|
||||
# 移除扩展名
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# 字重映射
|
||||
weight_mapping = {
|
||||
'thin': (100, 'Thin'),
|
||||
'extralight': (200, 'ExtraLight'),
|
||||
'light': (300, 'Light'),
|
||||
'regular': (400, 'Regular'),
|
||||
'normal': (400, 'Regular'),
|
||||
'medium': (500, 'Medium'),
|
||||
'semibold': (600, 'SemiBold'),
|
||||
'bold': (700, 'Bold'),
|
||||
'extrabold': (800, 'ExtraBold'),
|
||||
'black': (900, 'Black'),
|
||||
'heavy': (900, 'Heavy'),
|
||||
}
|
||||
|
||||
# 默认值
|
||||
font_weight = 400
|
||||
font_style = 'normal'
|
||||
weight_name = 'Regular'
|
||||
|
||||
# 检查是否为斜体
|
||||
if re.search(r'italic', name_without_ext, re.IGNORECASE):
|
||||
font_style = 'italic'
|
||||
|
||||
# 检查字重
|
||||
name_lower = name_without_ext.lower()
|
||||
for weight_key, (weight_value, weight_label) in weight_mapping.items():
|
||||
if weight_key in name_lower:
|
||||
font_weight = weight_value
|
||||
weight_name = weight_label
|
||||
break
|
||||
|
||||
# 提取字体家族名称(移除字重和样式后缀)
|
||||
family_name = name_without_ext
|
||||
for weight_key, (_, weight_label) in weight_mapping.items():
|
||||
family_name = re.sub(r'[-_]?' + weight_label, '', family_name, flags=re.IGNORECASE)
|
||||
family_name = re.sub(r'[-_]?italic', '', family_name, flags=re.IGNORECASE)
|
||||
family_name = family_name.strip('-_')
|
||||
|
||||
return {
|
||||
'family': family_name,
|
||||
'weight': font_weight,
|
||||
'style': font_style,
|
||||
'weight_name': weight_name,
|
||||
'full_name': name_without_ext
|
||||
}
|
||||
|
||||
def generate_css(font_files: List[str], output_css_path: str, css_base_path: str):
|
||||
"""
|
||||
生成CSS字体文件
|
||||
|
||||
Args:
|
||||
font_files: 字体文件路径列表(woff2文件)
|
||||
output_css_path: 输出CSS文件路径
|
||||
css_base_path: CSS文件相对于字体文件的基础路径
|
||||
"""
|
||||
# 按字体家族分组
|
||||
font_groups: Dict[str, List[Dict]] = {}
|
||||
|
||||
for font_file in font_files:
|
||||
if not font_file.endswith('.woff2'):
|
||||
continue
|
||||
|
||||
filename = os.path.basename(font_file)
|
||||
font_info = parse_font_info(filename)
|
||||
|
||||
# 计算相对路径
|
||||
font_dir = os.path.dirname(font_file)
|
||||
css_dir = os.path.dirname(output_css_path)
|
||||
|
||||
try:
|
||||
# 计算从CSS文件到字体文件的相对路径
|
||||
rel_path = os.path.relpath(font_file, css_dir)
|
||||
# 统一使用正斜杠(适用于Web)
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
except ValueError:
|
||||
# 如果在不同驱动器上,使用绝对路径
|
||||
rel_path = font_file.replace('\\', '/')
|
||||
|
||||
font_info['path'] = rel_path
|
||||
|
||||
family = font_info['family']
|
||||
if family not in font_groups:
|
||||
font_groups[family] = []
|
||||
font_groups[family].append(font_info)
|
||||
|
||||
# 生成CSS内容
|
||||
css_lines = ['/* 自动生成的字体文件 */', '/* 由 font_compressor.py 生成 */', '']
|
||||
|
||||
for family, fonts in sorted(font_groups.items()):
|
||||
css_lines.append(f'/* {family} 字体家族 */')
|
||||
css_lines.append('')
|
||||
|
||||
# 按字重排序
|
||||
fonts.sort(key=lambda x: (x['weight'], x['style']))
|
||||
|
||||
for font in fonts:
|
||||
css_lines.append(f"/* {family} {font['weight_name']}{' Italic' if font['style'] == 'italic' else ''} */")
|
||||
css_lines.append('@font-face {')
|
||||
css_lines.append(f" font-family: '{family}';")
|
||||
css_lines.append(f" src: url('{font['path']}') format('woff2');")
|
||||
css_lines.append(f" font-weight: {font['weight']};")
|
||||
css_lines.append(f" font-style: {font['style']};")
|
||||
css_lines.append(' font-display: swap;')
|
||||
css_lines.append('}')
|
||||
css_lines.append('')
|
||||
|
||||
# 写入CSS文件
|
||||
with open(output_css_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(css_lines))
|
||||
|
||||
print(f"[OK] CSS文件已生成: {output_css_path}")
|
||||
print(f" 包含 {sum(len(fonts) for fonts in font_groups.values())} 个字体定义")
|
||||
print(f" 字体家族: {', '.join(sorted(font_groups.keys()))}")
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic") -> List[str]:
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
|
||||
Returns:
|
||||
生成的woff2文件路径列表
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return []
|
||||
|
||||
# 查找所有字体文件(排除已经是woff2的)
|
||||
font_files = find_font_files(font_directory, exclude_woff2=True)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return []
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
generated_woff2_files = []
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
generated_woff2_files.append(output_file)
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" [OK] 成功: {format_file_size(original_size)} -> {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" [失败] 输出文件未生成")
|
||||
else:
|
||||
print(f" [失败] 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
return generated_woff2_files
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(
|
||||
description='通用字体压缩工具 - 将字体文件转换为 WOFF2 格式并生成CSS',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
使用示例:
|
||||
%(prog)s # 交互式模式,处理当前目录
|
||||
%(prog)s Monocraft # 处理相对路径目录
|
||||
%(prog)s Monocraft -l basic # 使用基础压缩级别
|
||||
%(prog)s Monocraft -l basic -c monocraft.css # 压缩并生成CSS文件
|
||||
%(prog)s /path/to/fonts -l medium -c fonts.css # 使用绝对路径
|
||||
|
||||
压缩级别说明:
|
||||
basic - 基础压缩:保留大部分功能,适合网页使用
|
||||
medium - 中等压缩:平衡文件大小和功能
|
||||
aggressive - 激进压缩:最小文件大小,可能影响显示效果
|
||||
|
||||
CSS生成说明:
|
||||
使用 -c/--css 选项生成CSS文件,自动使用相对路径引用字体文件
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='字体文件目录路径(支持相对/绝对路径,默认为当前脚本所在目录)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-l', '--level',
|
||||
choices=['basic', 'medium', 'aggressive'],
|
||||
default=None,
|
||||
help='压缩级别:basic(基础)、medium(中等)、aggressive(激进)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--css',
|
||||
default=None,
|
||||
help='生成CSS文件路径(相对于脚本位置或绝对路径)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
action='version',
|
||||
version='%(prog)s 2.0'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("通用字体压缩工具 v2.0")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取脚本所在目录
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 确定字体目录
|
||||
if args.directory:
|
||||
# 支持相对路径和绝对路径
|
||||
if os.path.isabs(args.directory):
|
||||
font_directory = args.directory
|
||||
else:
|
||||
font_directory = os.path.join(script_dir, args.directory)
|
||||
font_directory = os.path.abspath(font_directory)
|
||||
else:
|
||||
# 默认使用当前脚本所在目录
|
||||
font_directory = script_dir
|
||||
|
||||
# 检查目录是否存在
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"\n错误: 目录不存在: {font_directory}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n字体目录: {font_directory}")
|
||||
|
||||
# 确定压缩级别
|
||||
compression_level = args.level
|
||||
|
||||
if compression_level is None:
|
||||
# 交互式选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
print()
|
||||
generated_files = compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
# 生成CSS文件
|
||||
if args.css and generated_files:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("生成CSS文件...")
|
||||
print("=" * 60)
|
||||
|
||||
# 确定CSS输出路径
|
||||
if os.path.isabs(args.css):
|
||||
css_path = args.css
|
||||
else:
|
||||
css_path = os.path.join(script_dir, args.css)
|
||||
css_path = os.path.abspath(css_path)
|
||||
|
||||
# 确保输出目录存在
|
||||
css_dir = os.path.dirname(css_path)
|
||||
if css_dir and not os.path.exists(css_dir):
|
||||
os.makedirs(css_dir)
|
||||
|
||||
# 生成CSS
|
||||
generate_css(generated_files, css_path, script_dir)
|
||||
elif args.css and not generated_files:
|
||||
print("\n警告: 没有成功生成WOFF2文件,跳过CSS生成")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("全部完成!")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,9 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'scrollbar.css';
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import 'styles.css';
|
||||
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
@@ -0,0 +1,202 @@
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
3
frontend/src/assets/styles/styles.css
Normal file
3
frontend/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
@@ -1,255 +1,148 @@
|
||||
:root {
|
||||
/* 编辑器区域 */
|
||||
--text-primary: #9BB586; /* 内容区域字体颜色 */
|
||||
|
||||
/* 深色主题颜色变量 */
|
||||
--dark-toolbar-bg: #2d2d2d;
|
||||
--dark-toolbar-border: #404040;
|
||||
--dark-toolbar-text: #ffffff;
|
||||
--dark-toolbar-text-secondary: #cccccc;
|
||||
--dark-toolbar-button-hover: #404040;
|
||||
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--dark-bg-secondary: #0E1217;
|
||||
--dark-text-secondary: #a0aec0;
|
||||
--dark-text-muted: #666;
|
||||
--dark-border-color: #2d3748;
|
||||
--dark-settings-bg: #2a2a2a;
|
||||
--dark-settings-card-bg: #333333;
|
||||
--dark-settings-text: #ffffff;
|
||||
--dark-settings-text-secondary: #cccccc;
|
||||
--dark-settings-border: #444444;
|
||||
--dark-settings-input-bg: #3a3a3a;
|
||||
--dark-settings-input-border: #555555;
|
||||
--dark-settings-hover: #404040;
|
||||
--dark-scrollbar-track: #2a2a2a;
|
||||
--dark-scrollbar-thumb: #555555;
|
||||
--dark-scrollbar-thumb-hover: #666666;
|
||||
--dark-selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--dark-selection-text: #b5cea8;
|
||||
--dark-danger-color: #ff6b6b;
|
||||
--dark-bg-primary: #1a1a1a;
|
||||
--dark-bg-hover: #2a2a2a;
|
||||
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--dark-loading-color: #fff;
|
||||
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--dark-loading-done-color: #6f6;
|
||||
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* 浅色主题颜色变量 */
|
||||
--light-toolbar-bg: #f8f9fa;
|
||||
--light-toolbar-border: #e9ecef;
|
||||
--light-toolbar-text: #212529;
|
||||
--light-toolbar-text-secondary: #495057;
|
||||
--light-toolbar-button-hover: #e9ecef;
|
||||
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--light-bg-secondary: #f7fef7;
|
||||
--light-text-secondary: #374151;
|
||||
--light-text-muted: #6b7280;
|
||||
--light-border-color: #e5e7eb;
|
||||
--light-settings-bg: #ffffff;
|
||||
--light-settings-card-bg: #f8f9fa;
|
||||
--light-settings-text: #212529;
|
||||
--light-settings-text-secondary: #6c757d;
|
||||
--light-settings-border: #dee2e6;
|
||||
--light-settings-input-bg: #ffffff;
|
||||
--light-settings-input-border: #ced4da;
|
||||
--light-settings-hover: #e9ecef;
|
||||
--light-scrollbar-track: #f1f3f4;
|
||||
--light-scrollbar-thumb: #c1c1c1;
|
||||
--light-scrollbar-thumb-hover: #a8a8a8;
|
||||
--light-selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--light-selection-text: #2563eb;
|
||||
--light-danger-color: #dc3545;
|
||||
--light-bg-primary: #ffffff;
|
||||
--light-bg-hover: #f1f3f4;
|
||||
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--light-loading-color: #1a3c1a;
|
||||
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--light-loading-done-color: #008800;
|
||||
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* 默认使用深色主题 */
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
|
||||
|
||||
color-scheme: light dark;
|
||||
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* 监听系统深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
/* 默认/暗色主题 */
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--toolbar-bg: #2d2d2d;
|
||||
--toolbar-border: #404040;
|
||||
--toolbar-text: #ffffff;
|
||||
--toolbar-text-secondary: #cccccc;
|
||||
--toolbar-button-hover: #404040;
|
||||
--toolbar-separator: #404040;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--bg-secondary: #0e1217;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-hover: #2a2a2a;
|
||||
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #666666;
|
||||
--text-danger: #ff6b6b;
|
||||
|
||||
--border-color: #2d3748;
|
||||
|
||||
--settings-bg: #2a2a2a;
|
||||
--settings-card-bg: #333333;
|
||||
--settings-text: #ffffff;
|
||||
--settings-text-secondary: #cccccc;
|
||||
--settings-border: #444444;
|
||||
--settings-input-bg: #3a3a3a;
|
||||
--settings-input-border: #555555;
|
||||
--settings-hover: #404040;
|
||||
|
||||
--scrollbar-track: #2a2a2a;
|
||||
--scrollbar-thumb: #555555;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
|
||||
--selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--selection-text: #b5cea8;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--voidraft-loading-color: #ffffff;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--voidraft-loading-done-color: #66ff66;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
}
|
||||
|
||||
/* 监听系统浅色主题 */
|
||||
/* 亮色主题 */
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动选择浅色主题 */
|
||||
:root[data-theme="light"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
}
|
||||
|
||||
/* 手动选择深色主题 */
|
||||
:root[data-theme="dark"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const FONT_OPTIONS = [
|
||||
label: 'Open Sans',
|
||||
value: '"Open Sans"'
|
||||
},
|
||||
{
|
||||
label: 'Monocraft',
|
||||
value: 'Monocraft'
|
||||
},
|
||||
// Common system fonts
|
||||
{
|
||||
label: 'Arial',
|
||||
@@ -46,7 +50,7 @@ export const FONT_OPTIONS = [
|
||||
label: 'System UI',
|
||||
value: 'system-ui'
|
||||
},
|
||||
|
||||
|
||||
// Chinese fonts
|
||||
{
|
||||
label: 'Microsoft YaHei',
|
||||
@@ -56,7 +60,7 @@ export const FONT_OPTIONS = [
|
||||
label: 'PingFang SC',
|
||||
value: '"PingFang SC"'
|
||||
},
|
||||
|
||||
|
||||
// Popular programming fonts
|
||||
{
|
||||
label: 'JetBrains Mono',
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Enclose abbreviations in <abbr> tags
|
||||
//
|
||||
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 环境接口,包含缩写定义
|
||||
*/
|
||||
interface AbbrEnv {
|
||||
abbreviations?: { [key: string]: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-abbr 插件
|
||||
* 用于支持缩写语法
|
||||
*/
|
||||
export default function abbr_plugin(md: MarkdownIt): void {
|
||||
const escapeRE = md.utils.escapeRE;
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
|
||||
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
|
||||
// you can check character classes here:
|
||||
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
|
||||
const OTHER_CHARS = ' \r\n$+<=>^`|~';
|
||||
|
||||
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
|
||||
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
|
||||
|
||||
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
let labelEnd: number;
|
||||
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
if (pos + 2 >= max) { return false; }
|
||||
|
||||
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
|
||||
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
|
||||
|
||||
const labelStart = pos;
|
||||
|
||||
for (; pos < max; pos++) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
if (ch === 0x5B /* [ */) {
|
||||
return false;
|
||||
} else if (ch === 0x5D /* ] */) {
|
||||
labelEnd = pos;
|
||||
break;
|
||||
} else if (ch === 0x5C /* \ */) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) { return true; }
|
||||
|
||||
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
|
||||
const title = state.src.slice(labelEnd! + 2, max).trim();
|
||||
if (label.length === 0) { return false; }
|
||||
if (title.length === 0) { return false; }
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { env.abbreviations = {}; }
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
if (typeof env.abbreviations[':' + label] === 'undefined') {
|
||||
env.abbreviations[':' + label] = title;
|
||||
}
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbr_replace(state: StateCore): void {
|
||||
const blockTokens = state.tokens;
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { return; }
|
||||
|
||||
const regSimple = new RegExp('(?:' +
|
||||
Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') +
|
||||
')');
|
||||
|
||||
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
|
||||
'(' + Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') + ')' +
|
||||
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
|
||||
|
||||
const reg = new RegExp(regText, 'g');
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
const currentToken = tokens[i];
|
||||
if (currentToken.type !== 'text') { continue; }
|
||||
|
||||
let pos = 0;
|
||||
const text = currentToken.content;
|
||||
reg.lastIndex = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
// fast regexp run to determine whether there are any abbreviated words
|
||||
// in the current token
|
||||
if (!regSimple.test(text)) { continue; }
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = reg.exec(text))) {
|
||||
if (m.index > 0 || m[1].length > 0) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos, m.index + m[1].length);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token_o = new state.Token('abbr_open', 'abbr', 1);
|
||||
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
|
||||
nodes.push(token_o);
|
||||
|
||||
const token_t = new state.Token('text', '', 0);
|
||||
token_t.content = m[2];
|
||||
nodes.push(token_t);
|
||||
|
||||
const token_c = new state.Token('abbr_close', 'abbr', -1);
|
||||
nodes.push(token_c);
|
||||
|
||||
reg.lastIndex -= m[3].length;
|
||||
pos = reg.lastIndex;
|
||||
}
|
||||
|
||||
if (!nodes.length) { continue; }
|
||||
|
||||
if (pos < text.length) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
|
||||
|
||||
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Process definition lists
|
||||
//
|
||||
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* markdown-it-deflist 插件
|
||||
* 用于支持定义列表语法
|
||||
*/
|
||||
export default function deflist_plugin(md: MarkdownIt): void {
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
// Search `[:~][\n ]`, returns next pos after marker on success
|
||||
// or -1 on fail.
|
||||
function skipMarker(state: StateBlock, line: number): number {
|
||||
let start = state.bMarks[line] + state.tShift[line];
|
||||
const max = state.eMarks[line];
|
||||
|
||||
if (start >= max) { return -1; }
|
||||
|
||||
// Check bullet
|
||||
const marker = state.src.charCodeAt(start++);
|
||||
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
|
||||
|
||||
const pos = state.skipSpaces(start);
|
||||
|
||||
// require space after ":"
|
||||
if (start === pos) { return -1; }
|
||||
|
||||
// no empty definitions, e.g. " : "
|
||||
if (pos >= max) { return -1; }
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
function markTightParagraphs(state: StateBlock, idx: number): void {
|
||||
const level = state.level + 2;
|
||||
|
||||
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
|
||||
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
|
||||
state.tokens[i + 2].hidden = true;
|
||||
state.tokens[i].hidden = true;
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
if (silent) {
|
||||
// quirk: validation mode validates a dd block only, not a whole deflist
|
||||
if (state.ddIndent < 0) { return false; }
|
||||
return skipMarker(state, startLine) >= 0;
|
||||
}
|
||||
|
||||
let nextLine = startLine + 1;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
|
||||
if (state.isEmpty(nextLine)) {
|
||||
nextLine++;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { return false; }
|
||||
let contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { return false; }
|
||||
|
||||
// Start list
|
||||
const listTokIdx = state.tokens.length;
|
||||
let tight = true;
|
||||
|
||||
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
|
||||
const listLines: [number, number] = [startLine, 0];
|
||||
token_dl_o.map = listLines;
|
||||
|
||||
//
|
||||
// Iterate list items
|
||||
//
|
||||
|
||||
let dtLine = startLine;
|
||||
let ddLine = nextLine;
|
||||
|
||||
// One definition list can contain multiple DTs,
|
||||
// and one DT can be followed by multiple DDs.
|
||||
//
|
||||
// Thus, there is two loops here, and label is
|
||||
// needed to break out of the second one
|
||||
//
|
||||
/* eslint no-labels:0,block-scoped-var:0 */
|
||||
OUTER:
|
||||
for (;;) {
|
||||
let prevEmptyEnd = false;
|
||||
|
||||
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
|
||||
token_dt_o.map = [dtLine, dtLine];
|
||||
|
||||
const token_i: Token = state.push('inline', '', 0);
|
||||
token_i.map = [dtLine, dtLine];
|
||||
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
|
||||
token_i.children = [];
|
||||
|
||||
state.push('dt_close', 'dt', -1);
|
||||
|
||||
for (;;) {
|
||||
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
|
||||
const itemLines: [number, number] = [nextLine, 0];
|
||||
token_dd_o.map = itemLines;
|
||||
|
||||
let pos = contentStart;
|
||||
const max = state.eMarks[ddLine];
|
||||
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
contentStart = pos;
|
||||
|
||||
const oldTight = state.tight;
|
||||
const oldDDIndent = state.ddIndent;
|
||||
const oldIndent = state.blkIndent;
|
||||
const oldTShift = state.tShift[ddLine];
|
||||
const oldSCount = state.sCount[ddLine];
|
||||
const oldParentType = state.parentType;
|
||||
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
|
||||
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
|
||||
state.sCount[ddLine] = offset;
|
||||
state.tight = true;
|
||||
state.parentType = 'deflist' as any;
|
||||
|
||||
state.md.block.tokenize(state, ddLine, endLine);
|
||||
|
||||
// If any of list item is tight, mark list as tight
|
||||
if (!state.tight || prevEmptyEnd) {
|
||||
tight = false;
|
||||
}
|
||||
// Item become loose if finish with empty line,
|
||||
// but we should filter last element, because it means list finish
|
||||
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
|
||||
|
||||
state.tShift[ddLine] = oldTShift;
|
||||
state.sCount[ddLine] = oldSCount;
|
||||
state.tight = oldTight;
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent = oldIndent;
|
||||
state.ddIndent = oldDDIndent;
|
||||
|
||||
state.push('dd_close', 'dd', -1);
|
||||
|
||||
itemLines[1] = nextLine = state.line;
|
||||
|
||||
if (nextLine >= endLine) { break OUTER; }
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
|
||||
contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
ddLine = nextLine;
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DD tag and repeat checking
|
||||
}
|
||||
|
||||
if (nextLine >= endLine) { break; }
|
||||
dtLine = nextLine;
|
||||
|
||||
if (state.isEmpty(dtLine)) { break; }
|
||||
if (state.sCount[dtLine] < state.blkIndent) { break; }
|
||||
|
||||
ddLine = dtLine + 1;
|
||||
if (ddLine >= endLine) { break; }
|
||||
if (state.isEmpty(ddLine)) { ddLine++; }
|
||||
if (ddLine >= endLine) { break; }
|
||||
|
||||
if (state.sCount[ddLine] < state.blkIndent) { break; }
|
||||
contentStart = skipMarker(state, ddLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DT and DD tags and repeat checking
|
||||
}
|
||||
|
||||
// Finilize list
|
||||
state.push('dl_close', 'dl', -1);
|
||||
|
||||
listLines[1] = nextLine;
|
||||
|
||||
state.line = nextLine;
|
||||
|
||||
// mark paragraphs tight if needed
|
||||
if (tight) {
|
||||
markTightParagraphs(state, listTokIdx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as bare } from './lib/bare';
|
||||
export { default as light } from './lib/light';
|
||||
export { default as full } from './lib/full';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emoji_html from './render';
|
||||
import emoji_replace from './replace';
|
||||
import normalize_opts, { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Bare emoji 插件(不包含预定义的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: {},
|
||||
shortcuts: {},
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
|
||||
|
||||
md.renderer.rules.emoji = emoji_html;
|
||||
|
||||
md.core.ruler.after(
|
||||
'linkify',
|
||||
'emoji',
|
||||
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
// Generated, don't edit
|
||||
import { EmojiDefs } from '../normalize_opts';
|
||||
|
||||
const emojies: EmojiDefs = {
|
||||
"grinning": "😀",
|
||||
"smiley": "😃",
|
||||
"smile": "😄",
|
||||
"grin": "😁",
|
||||
"laughing": "😆",
|
||||
"satisfied": "😆",
|
||||
"sweat_smile": "😅",
|
||||
"joy": "😂",
|
||||
"wink": "😉",
|
||||
"blush": "😊",
|
||||
"innocent": "😇",
|
||||
"heart_eyes": "😍",
|
||||
"kissing_heart": "😘",
|
||||
"kissing": "😗",
|
||||
"kissing_closed_eyes": "😚",
|
||||
"kissing_smiling_eyes": "😙",
|
||||
"yum": "😋",
|
||||
"stuck_out_tongue": "😛",
|
||||
"stuck_out_tongue_winking_eye": "😜",
|
||||
"stuck_out_tongue_closed_eyes": "😝",
|
||||
"neutral_face": "😐",
|
||||
"expressionless": "😑",
|
||||
"no_mouth": "😶",
|
||||
"smirk": "😏",
|
||||
"unamused": "😒",
|
||||
"relieved": "😌",
|
||||
"pensive": "😔",
|
||||
"sleepy": "😪",
|
||||
"sleeping": "😴",
|
||||
"mask": "😷",
|
||||
"dizzy_face": "😵",
|
||||
"sunglasses": "😎",
|
||||
"confused": "😕",
|
||||
"worried": "😟",
|
||||
"open_mouth": "😮",
|
||||
"hushed": "😯",
|
||||
"astonished": "😲",
|
||||
"flushed": "😳",
|
||||
"frowning": "😦",
|
||||
"anguished": "😧",
|
||||
"fearful": "😨",
|
||||
"cold_sweat": "😰",
|
||||
"disappointed_relieved": "😥",
|
||||
"cry": "😢",
|
||||
"sob": "😭",
|
||||
"scream": "😱",
|
||||
"confounded": "😖",
|
||||
"persevere": "😣",
|
||||
"disappointed": "😞",
|
||||
"sweat": "😓",
|
||||
"weary": "😩",
|
||||
"tired_face": "😫",
|
||||
"rage": "😡",
|
||||
"pout": "😡",
|
||||
"angry": "😠",
|
||||
"smiling_imp": "😈",
|
||||
"smiley_cat": "😺",
|
||||
"smile_cat": "😸",
|
||||
"joy_cat": "😹",
|
||||
"heart_eyes_cat": "😻",
|
||||
"smirk_cat": "😼",
|
||||
"kissing_cat": "😽",
|
||||
"scream_cat": "🙀",
|
||||
"crying_cat_face": "😿",
|
||||
"pouting_cat": "😾",
|
||||
"heart": "❤️",
|
||||
"hand": "✋",
|
||||
"raised_hand": "✋",
|
||||
"v": "✌️",
|
||||
"point_up": "☝️",
|
||||
"fist_raised": "✊",
|
||||
"fist": "✊",
|
||||
"monkey_face": "🐵",
|
||||
"cat": "🐱",
|
||||
"cow": "🐮",
|
||||
"mouse": "🐭",
|
||||
"coffee": "☕",
|
||||
"hotsprings": "♨️",
|
||||
"anchor": "⚓",
|
||||
"airplane": "✈️",
|
||||
"hourglass": "⌛",
|
||||
"watch": "⌚",
|
||||
"sunny": "☀️",
|
||||
"star": "⭐",
|
||||
"cloud": "☁️",
|
||||
"umbrella": "☔",
|
||||
"zap": "⚡",
|
||||
"snowflake": "❄️",
|
||||
"sparkles": "✨",
|
||||
"black_joker": "🃏",
|
||||
"mahjong": "🀄",
|
||||
"phone": "☎️",
|
||||
"telephone": "☎️",
|
||||
"envelope": "✉️",
|
||||
"pencil2": "✏️",
|
||||
"black_nib": "✒️",
|
||||
"scissors": "✂️",
|
||||
"wheelchair": "♿",
|
||||
"warning": "⚠️",
|
||||
"aries": "♈",
|
||||
"taurus": "♉",
|
||||
"gemini": "♊",
|
||||
"cancer": "♋",
|
||||
"leo": "♌",
|
||||
"virgo": "♍",
|
||||
"libra": "♎",
|
||||
"scorpius": "♏",
|
||||
"sagittarius": "♐",
|
||||
"capricorn": "♑",
|
||||
"aquarius": "♒",
|
||||
"pisces": "♓",
|
||||
"heavy_multiplication_x": "✖️",
|
||||
"heavy_plus_sign": "➕",
|
||||
"heavy_minus_sign": "➖",
|
||||
"heavy_division_sign": "➗",
|
||||
"bangbang": "‼️",
|
||||
"interrobang": "⁉️",
|
||||
"question": "❓",
|
||||
"grey_question": "❔",
|
||||
"grey_exclamation": "❕",
|
||||
"exclamation": "❗",
|
||||
"heavy_exclamation_mark": "❗",
|
||||
"wavy_dash": "〰️",
|
||||
"recycle": "♻️",
|
||||
"white_check_mark": "✅",
|
||||
"ballot_box_with_check": "☑️",
|
||||
"heavy_check_mark": "✔️",
|
||||
"x": "❌",
|
||||
"negative_squared_cross_mark": "❎",
|
||||
"curly_loop": "➰",
|
||||
"loop": "➿",
|
||||
"part_alternation_mark": "〽️",
|
||||
"eight_spoked_asterisk": "✳️",
|
||||
"eight_pointed_black_star": "✴️",
|
||||
"sparkle": "❇️",
|
||||
"copyright": "©️",
|
||||
"registered": "®️",
|
||||
"tm": "™️",
|
||||
"information_source": "ℹ️",
|
||||
"m": "Ⓜ️",
|
||||
"black_circle": "⚫",
|
||||
"white_circle": "⚪",
|
||||
"black_large_square": "⬛",
|
||||
"white_large_square": "⬜",
|
||||
"black_medium_square": "◼️",
|
||||
"white_medium_square": "◻️",
|
||||
"black_medium_small_square": "◾",
|
||||
"white_medium_small_square": "◽",
|
||||
"black_small_square": "▪️",
|
||||
"white_small_square": "▫️"
|
||||
};
|
||||
|
||||
export default emojies;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Emoticons -> Emoji mapping.
|
||||
//
|
||||
// (!) Some patterns skipped, to avoid collisions
|
||||
// without increase matcher complicity. Than can change in future.
|
||||
//
|
||||
// Places to look for more emoticons info:
|
||||
//
|
||||
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
|
||||
// - https://github.com/wooorm/emoticon/blob/master/Support.md
|
||||
// - http://factoryjoe.com/projects/emoticons/
|
||||
//
|
||||
|
||||
import { EmojiShortcuts } from '../normalize_opts';
|
||||
|
||||
const shortcuts: EmojiShortcuts = {
|
||||
angry: ['>:(', '>:-('],
|
||||
blush: [':")', ':-")'],
|
||||
broken_heart: ['</3', '<\\3'],
|
||||
// :\ and :-\ not used because of conflict with markdown escaping
|
||||
confused: [':/', ':-/'], // twemoji shows question
|
||||
cry: [":'(", ":'-(", ':,(', ':,-('],
|
||||
frowning: [':(', ':-('],
|
||||
heart: ['<3'],
|
||||
imp: [']:(', ']:-('],
|
||||
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
|
||||
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
|
||||
kissing: [':*', ':-*'],
|
||||
laughing: ['x-)', 'X-)'],
|
||||
neutral_face: [':|', ':-|'],
|
||||
open_mouth: [':o', ':-o', ':O', ':-O'],
|
||||
rage: [':@', ':-@'],
|
||||
smile: [':D', ':-D'],
|
||||
smiley: [':)', ':-)'],
|
||||
smiling_imp: [']:)', ']:-)'],
|
||||
sob: [":,'(", ":,'-(", ';(', ';-('],
|
||||
stuck_out_tongue: [':P', ':-P'],
|
||||
sunglasses: ['8-)', 'B-)'],
|
||||
sweat: [',:(', ',:-('],
|
||||
sweat_smile: [',:)', ',:-)'],
|
||||
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
|
||||
wink: [';)', ';-)']
|
||||
};
|
||||
|
||||
export default shortcuts;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/full';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Full emoji 插件(包含完整的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/light';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Light emoji 插件(包含常用的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Emoji 定义类型
|
||||
*/
|
||||
export interface EmojiDefs {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji 快捷方式类型
|
||||
*/
|
||||
export interface EmojiShortcuts {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入选项接口
|
||||
*/
|
||||
export interface EmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: EmojiShortcuts;
|
||||
enabled: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化后的选项接口
|
||||
*/
|
||||
export interface NormalizedEmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: { [key: string]: string };
|
||||
scanRE: RegExp;
|
||||
replaceRE: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
*/
|
||||
function quoteRE(str: string): string {
|
||||
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入选项转换为更可用的格式并编译搜索正则表达式
|
||||
*/
|
||||
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
|
||||
let emojies = options.defs;
|
||||
|
||||
// Filter emojies by whitelist, if needed
|
||||
if (options.enabled.length) {
|
||||
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
|
||||
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Flatten shortcuts to simple object: { alias: emoji_name }
|
||||
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
|
||||
// Skip aliases for filtered emojies, to reduce regexp
|
||||
if (!emojies[key]) return acc;
|
||||
|
||||
if (Array.isArray(options.shortcuts[key])) {
|
||||
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[options.shortcuts[key] as string] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const keys = Object.keys(emojies);
|
||||
let names: string;
|
||||
|
||||
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
|
||||
if (keys.length === 0) {
|
||||
names = '^$';
|
||||
} else {
|
||||
// Compile regexp
|
||||
names = keys
|
||||
.map((name: string) => { return `:${name}:`; })
|
||||
.concat(Object.keys(shortcuts))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((name: string) => { return quoteRE(name); })
|
||||
.join('|');
|
||||
}
|
||||
const scanRE = RegExp(names);
|
||||
const replaceRE = RegExp(names, 'g');
|
||||
|
||||
return {
|
||||
defs: emojies,
|
||||
shortcuts,
|
||||
scanRE,
|
||||
replaceRE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Emoji 渲染函数
|
||||
*/
|
||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||
return tokens[idx].content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import MarkdownIt, { StateCore, Token } from 'markdown-it';
|
||||
import { EmojiDefs } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Emoji 和快捷方式替换逻辑
|
||||
*
|
||||
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
|
||||
* 但是,谁在乎呢...
|
||||
*/
|
||||
export default function create_rule(
|
||||
md: MarkdownIt,
|
||||
emojies: EmojiDefs,
|
||||
shortcuts: { [key: string]: string },
|
||||
scanRE: RegExp,
|
||||
replaceRE: RegExp
|
||||
) {
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
const ucm = md.utils.lib.ucmicro;
|
||||
const has = md.utils.has;
|
||||
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
|
||||
|
||||
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
|
||||
let last_pos = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
|
||||
let emoji_name: string;
|
||||
// Validate emoji name
|
||||
if (has(shortcuts, match)) {
|
||||
// replace shortcut with full name
|
||||
emoji_name = shortcuts[match];
|
||||
|
||||
// Don't allow letters before any shortcut (as in no ":/" in http://)
|
||||
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
|
||||
|
||||
// Don't allow letters after any shortcut
|
||||
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
emoji_name = match.slice(1, -1);
|
||||
}
|
||||
|
||||
// Add new tokens to pending list
|
||||
if (offset > last_pos) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos, offset);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token = new TokenConstructor('emoji', '', 0);
|
||||
token.markup = emoji_name;
|
||||
token.content = emojies[emoji_name];
|
||||
nodes.push(token);
|
||||
|
||||
last_pos = offset + match.length;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (last_pos < text.length) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return function emoji_replace(state: StateCore): void {
|
||||
let token: Token;
|
||||
const blockTokens = state.tokens;
|
||||
let autolinkLevel = 0;
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
// Use reversed logic in links start/end match
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
token = tokens[i];
|
||||
|
||||
if (token.type === 'link_open' || token.type === 'link_close') {
|
||||
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
|
||||
}
|
||||
|
||||
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(
|
||||
tokens, i, splitTextToken(token.content, token.level, state.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 脚注元数据接口
|
||||
*/
|
||||
interface FootnoteMeta {
|
||||
id: number;
|
||||
subId: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脚注列表项接口
|
||||
*/
|
||||
interface FootnoteItem {
|
||||
label?: string;
|
||||
content?: string;
|
||||
tokens?: Token[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境接口
|
||||
*/
|
||||
interface FootnoteEnv {
|
||||
footnotes?: {
|
||||
refs?: { [key: string]: number };
|
||||
list?: FootnoteItem[];
|
||||
};
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
/// /////////////////////////////////////////////////////////////////////////////
|
||||
// Renderer partials
|
||||
|
||||
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
|
||||
const n = Number(tokens[idx].meta.id + 1).toString();
|
||||
let prefix = '';
|
||||
|
||||
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
|
||||
|
||||
return prefix + n;
|
||||
}
|
||||
|
||||
function render_footnote_caption(tokens: Token[], idx: number): string {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||
|
||||
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `[${n}]`;
|
||||
}
|
||||
|
||||
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
|
||||
let refid = id;
|
||||
|
||||
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||
}
|
||||
|
||||
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
|
||||
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
|
||||
'<section class="footnotes">\n' +
|
||||
'<ol class="footnotes-list">\n';
|
||||
}
|
||||
|
||||
function render_footnote_block_close(): string {
|
||||
return '</ol>\n</section>\n';
|
||||
}
|
||||
|
||||
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<li id="fn${id}" class="footnote-item">`;
|
||||
}
|
||||
|
||||
function render_footnote_close(): string {
|
||||
return '</li>\n';
|
||||
}
|
||||
|
||||
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-footnote 插件
|
||||
* 用于支持脚注语法
|
||||
*/
|
||||
export default function footnote_plugin(md: MarkdownIt): void {
|
||||
const parseLinkLabel = md.helpers.parseLinkLabel;
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
md.renderer.rules.footnote_ref = render_footnote_ref;
|
||||
md.renderer.rules.footnote_block_open = render_footnote_block_open;
|
||||
md.renderer.rules.footnote_block_close = render_footnote_block_close;
|
||||
md.renderer.rules.footnote_open = render_footnote_open;
|
||||
md.renderer.rules.footnote_close = render_footnote_close;
|
||||
md.renderer.rules.footnote_anchor = render_footnote_anchor;
|
||||
|
||||
// helpers (only used in other rules, no tokens are attached to those)
|
||||
md.renderer.rules.footnote_caption = render_footnote_caption;
|
||||
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
|
||||
|
||||
// Process footnote block definition
|
||||
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// line should be at least 5 chars - "[^x]:"
|
||||
if (start + 4 > max) return false;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
|
||||
if (silent) return true;
|
||||
pos++;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.refs) env.footnotes.refs = {};
|
||||
const label = state.src.slice(start + 2, pos - 2);
|
||||
env.footnotes.refs[`:${label}`] = -1;
|
||||
|
||||
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
|
||||
token_fref_o.meta = { label };
|
||||
token_fref_o.level = state.level++;
|
||||
state.tokens.push(token_fref_o);
|
||||
|
||||
const oldBMark = state.bMarks[startLine];
|
||||
const oldTShift = state.tShift[startLine];
|
||||
const oldSCount = state.sCount[startLine];
|
||||
const oldParentType = state.parentType;
|
||||
|
||||
const posAfterColon = pos;
|
||||
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
|
||||
let offset = initial;
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
state.tShift[startLine] = pos - posAfterColon;
|
||||
state.sCount[startLine] = offset - initial;
|
||||
|
||||
state.bMarks[startLine] = posAfterColon;
|
||||
state.blkIndent += 4;
|
||||
state.parentType = 'footnote' as any;
|
||||
|
||||
if (state.sCount[startLine] < state.blkIndent) {
|
||||
state.sCount[startLine] += state.blkIndent;
|
||||
}
|
||||
|
||||
state.md.block.tokenize(state, startLine, endLine);
|
||||
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent -= 4;
|
||||
state.tShift[startLine] = oldTShift;
|
||||
state.sCount[startLine] = oldSCount;
|
||||
state.bMarks[startLine] = oldBMark;
|
||||
|
||||
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
|
||||
token_fref_c.level = --state.level;
|
||||
state.tokens.push(token_fref_c);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process inline footnotes (^[...])
|
||||
function footnote_inline(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (start + 2 >= max) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
|
||||
|
||||
const labelStart = start + 2;
|
||||
const labelEnd = parseLinkLabel(state, start + 1);
|
||||
|
||||
// parser failed to find ']', so it's not a valid note
|
||||
if (labelEnd < 0) return false;
|
||||
|
||||
// We found the end of the link, and know for a fact it's a valid link;
|
||||
// so all that's left to do is to call tokenizer.
|
||||
//
|
||||
if (!silent) {
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
const footnoteId = env.footnotes.list.length;
|
||||
const tokens: Token[] = [];
|
||||
|
||||
state.md.inline.parse(
|
||||
state.src.slice(labelStart, labelEnd),
|
||||
state.md,
|
||||
state.env,
|
||||
tokens
|
||||
);
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId };
|
||||
|
||||
env.footnotes.list[footnoteId] = {
|
||||
content: state.src.slice(labelStart, labelEnd),
|
||||
tokens,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
state.pos = labelEnd + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process footnote references ([^...])
|
||||
function footnote_ref(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
// should be at least 4 chars - "[^x]"
|
||||
if (start + 3 > max) return false;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes || !env.footnotes.refs) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x0A) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos >= max) return false;
|
||||
pos++;
|
||||
|
||||
const label = state.src.slice(start + 2, pos - 1);
|
||||
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
|
||||
|
||||
if (!silent) {
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
|
||||
let footnoteId: number;
|
||||
|
||||
if (env.footnotes.refs[`:${label}`] < 0) {
|
||||
footnoteId = env.footnotes.list.length;
|
||||
env.footnotes.list[footnoteId] = { label, count: 0 };
|
||||
env.footnotes.refs[`:${label}`] = footnoteId;
|
||||
} else {
|
||||
footnoteId = env.footnotes.refs[`:${label}`];
|
||||
}
|
||||
|
||||
const footnoteSubId = env.footnotes.list[footnoteId].count;
|
||||
env.footnotes.list[footnoteId].count++;
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId, subId: footnoteSubId, label };
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Glue footnote tokens to end of token stream
|
||||
function footnote_tail(state: StateCore): void {
|
||||
let tokens: Token[] | null = null;
|
||||
let current: Token[];
|
||||
let currentLabel: string;
|
||||
let insideRef = false;
|
||||
const refTokens: { [key: string]: Token[] } = {};
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) { return; }
|
||||
|
||||
state.tokens = state.tokens.filter(function (tok) {
|
||||
if (tok.type === 'footnote_reference_open') {
|
||||
insideRef = true;
|
||||
current = [];
|
||||
currentLabel = tok.meta.label;
|
||||
return false;
|
||||
}
|
||||
if (tok.type === 'footnote_reference_close') {
|
||||
insideRef = false;
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
refTokens[':' + currentLabel] = current;
|
||||
return false;
|
||||
}
|
||||
if (insideRef) { current.push(tok); }
|
||||
return !insideRef;
|
||||
});
|
||||
|
||||
if (!env.footnotes.list) { return; }
|
||||
const list = env.footnotes.list;
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_open', '', 1));
|
||||
|
||||
for (let i = 0, l = list.length; i < l; i++) {
|
||||
const token_fo = new state.Token('footnote_open', '', 1);
|
||||
token_fo.meta = { id: i, label: list[i].label };
|
||||
state.tokens.push(token_fo);
|
||||
|
||||
if (list[i].tokens) {
|
||||
tokens = [];
|
||||
|
||||
const token_po = new state.Token('paragraph_open', 'p', 1);
|
||||
token_po.block = true;
|
||||
tokens.push(token_po);
|
||||
|
||||
const token_i = new state.Token('inline', '', 0);
|
||||
token_i.children = list[i].tokens || null;
|
||||
token_i.content = list[i].content || '';
|
||||
tokens.push(token_i);
|
||||
|
||||
const token_pc = new state.Token('paragraph_close', 'p', -1);
|
||||
token_pc.block = true;
|
||||
tokens.push(token_pc);
|
||||
} else if (list[i].label) {
|
||||
tokens = refTokens[`:${list[i].label}`] || null;
|
||||
}
|
||||
|
||||
if (tokens) state.tokens = state.tokens.concat(tokens);
|
||||
|
||||
let lastParagraph: Token | null;
|
||||
|
||||
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
|
||||
lastParagraph = state.tokens.pop()!;
|
||||
} else {
|
||||
lastParagraph = null;
|
||||
}
|
||||
|
||||
const t = list[i].count > 0 ? list[i].count : 1;
|
||||
for (let j = 0; j < t; j++) {
|
||||
const token_a = new state.Token('footnote_anchor', '', 0);
|
||||
token_a.meta = { id: i, subId: j, label: list[i].label };
|
||||
state.tokens.push(token_a);
|
||||
}
|
||||
|
||||
if (lastParagraph) {
|
||||
state.tokens.push(lastParagraph);
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_close', '', -1));
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_close', '', -1));
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
|
||||
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
|
||||
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
|
||||
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
|
||||
}
|
||||
160
frontend/src/common/markdown-it/plugins/markdown-it-ins/index.ts
Normal file
160
frontend/src/common/markdown-it/plugins/markdown-it-ins/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-ins 插件
|
||||
* 用于支持插入文本语法 ++text++
|
||||
*/
|
||||
export default function ins_plugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x2B/* + */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
let token: Token;
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x2B/* + */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
token = state.tokens[startDelim.token];
|
||||
token.type = 'ins_open';
|
||||
token.tag = 'ins';
|
||||
token.nesting = 1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
token = state.tokens[endDelim.token];
|
||||
token.type = 'ins_close';
|
||||
token.tag = 'ins';
|
||||
token.nesting = -1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '+') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'ins', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (let curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import MarkdownIt, {StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-mark 插件
|
||||
* 用于支持 ==标记文本== 语法
|
||||
*/
|
||||
export default function markPlugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x3D/* = */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x3D/* = */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
const token_o = state.tokens[startDelim.token];
|
||||
token_o.type = 'mark_open';
|
||||
token_o.tag = 'mark';
|
||||
token_o.nesting = 1;
|
||||
token_o.markup = '==';
|
||||
token_o.content = '';
|
||||
|
||||
const token_c = state.tokens[endDelim.token];
|
||||
token_c.type = 'mark_close';
|
||||
token_c.tag = 'mark';
|
||||
token_c.nesting = -1;
|
||||
token_c.markup = '==';
|
||||
token_c.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '=') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
const token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'mark', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
|
||||
let curr: number;
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import mermaid from "mermaid";
|
||||
import {genUid, hashCode, sleep} from "./utils";
|
||||
|
||||
const mermaidCache = new Map<string, HTMLElement>();
|
||||
|
||||
// 缓存计数器,用于清除缓存
|
||||
const mermaidCacheCount = new Map<string, number>();
|
||||
let count = 0;
|
||||
|
||||
|
||||
let countTmo = setTimeout(() => undefined, 0);
|
||||
const addCount = () => {
|
||||
clearTimeout(countTmo);
|
||||
countTmo = setTimeout(() => {
|
||||
count++;
|
||||
clearCache();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
for (const key of mermaidCacheCount.keys()) {
|
||||
const value = mermaidCacheCount.get(key)!;
|
||||
if (value + 3 < count) {
|
||||
mermaidCache.delete(key);
|
||||
mermaidCacheCount.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 mermaid
|
||||
* @param code mermaid 代码
|
||||
* @param targetId 目标 id
|
||||
* @param count 计数器
|
||||
*/
|
||||
const renderMermaid = async (code: string, targetId: string, count: number) => {
|
||||
let limit = 100;
|
||||
while (limit-- > 0) {
|
||||
const container = document.getElementById(targetId);
|
||||
if (!container) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
|
||||
container.innerHTML = svg;
|
||||
mermaidCache.set(targetId, container);
|
||||
mermaidCacheCount.set(targetId, count);
|
||||
} catch (e) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MermaidItOptions {
|
||||
theme?: "default" | "dark" | "forest" | "neutral" | "base";
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 mermaid 主题
|
||||
*/
|
||||
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
// 清空缓存,强制重新渲染
|
||||
mermaidCache.clear();
|
||||
mermaidCacheCount.clear();
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* mermaid 插件
|
||||
* @param md markdown-it
|
||||
* @param options 配置选项
|
||||
* @constructor MermaidIt
|
||||
*/
|
||||
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
|
||||
const theme = options?.theme || "default";
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
|
||||
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
|
||||
addCount();
|
||||
const token = tokens[idx];
|
||||
const info = token.info.trim();
|
||||
if (info === "mermaid") {
|
||||
const containerId = "mermaid-container-" + hashCode(token.content);
|
||||
const container = document.createElement("div");
|
||||
container.id = containerId;
|
||||
if (mermaidCache.has(containerId)) {
|
||||
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
|
||||
mermaidCacheCount.set(containerId, count);
|
||||
} else {
|
||||
renderMermaid(token.content, containerId, count).then();
|
||||
}
|
||||
return container.outerHTML;
|
||||
}
|
||||
// 使用默认的渲染规则
|
||||
return defaultRenderer(tokens, idx, options, env, self);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* uuid 生成函数
|
||||
* @param split 分隔符
|
||||
*/
|
||||
export const genUid = (split = "") => {
|
||||
return uuidv4().split("-").join(split);
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的sleep函数
|
||||
*/
|
||||
export const sleep = async (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算字符串的hash值
|
||||
* 返回一个数字
|
||||
* @param str
|
||||
*/
|
||||
export const hashCode = (str: string) => {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的阻塞函数
|
||||
*/
|
||||
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (cb()) return true;
|
||||
if (timeout && Date.now() - start > timeout) {
|
||||
console.error("阻塞超时: " + errText);
|
||||
return false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
// Process ~subscript~
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function subscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sub_open', 'sub', 1);
|
||||
token_so.markup = '~';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sub_close', 'sub', -1);
|
||||
token_sc.markup = '~';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sub 插件
|
||||
* 用于支持下标语法 ~text~
|
||||
*/
|
||||
export default function sub_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sub', subscript);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Process ^superscript^
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function superscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sup_open', 'sup', 1);
|
||||
token_so.markup = '^';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sup_close', 'sup', -1);
|
||||
token_sc.markup = '^';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sup 插件
|
||||
* 用于支持上标语法 ^text^
|
||||
*/
|
||||
export default function sup_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sup', superscript);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ function animationLoop() {
|
||||
// 等待一段时间后重置动画
|
||||
resetTimeoutId = window.setTimeout(() => {
|
||||
reset();
|
||||
}, 750);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,12 +136,13 @@ onBeforeUnmount(() => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--voidraft-bg-gradient, radial-gradient(#222922, #000500));
|
||||
//background: var(--voidraft-bg-gradient, rgba(0, 5, 0, 0.15));
|
||||
//backdrop-filter: blur(2px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
||||
font-family: var(--voidraft-font-mono),serif;
|
||||
}
|
||||
|
||||
.loading-word {
|
||||
@@ -174,4 +175,4 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.menu-item:hover & {
|
||||
|
||||
@@ -13,16 +13,20 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {usePanelStore} from '@/stores/panelStore';
|
||||
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const configStore = readonly(useConfigStore());
|
||||
const updateStore = readonly(useUpdateStore());
|
||||
const windowStore = readonly(useWindowStore());
|
||||
const systemStore = readonly(useSystemStore());
|
||||
const panelStore = readonly(usePanelStore());
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const canFormatCurrentBlock = ref(false);
|
||||
const canPreviewMarkdown = ref(false);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
const { documentStats } = toRefs(editorStore);
|
||||
@@ -33,6 +37,11 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||
});
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
const toggleAlwaysOnTop = async () => {
|
||||
const currentlyOnTop = isCurrentWindowOnTop.value;
|
||||
@@ -60,11 +69,22 @@ const formatCurrentBlock = () => {
|
||||
formatBlockContent(editorStore.editorView);
|
||||
};
|
||||
|
||||
// 格式化按钮状态更新 - 使用更高效的检查逻辑
|
||||
const updateFormatButtonState = () => {
|
||||
const view = editorStore.editorView;
|
||||
// 切换 Markdown 预览
|
||||
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
|
||||
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
|
||||
toggleMarkdownPreview(editorStore.editorView as any);
|
||||
}, { delay: 200 });
|
||||
|
||||
const togglePreview = () => {
|
||||
debouncedTogglePreview();
|
||||
};
|
||||
|
||||
// 统一更新按钮状态
|
||||
const updateButtonStates = () => {
|
||||
const view: any = editorStore.editorView;
|
||||
if (!view) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,20 +95,25 @@ const updateFormatButtonState = () => {
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
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);
|
||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
||||
} catch (error) {
|
||||
console.warn('Error checking format capability:', error);
|
||||
console.warn('Error checking block capabilities:', error);
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建带1s防抖的更新函数
|
||||
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce(
|
||||
updateFormatButtonState,
|
||||
const { debouncedFn: debouncedUpdateButtonStates, cancel: cancelDebounce } = createDebounce(
|
||||
updateButtonStates,
|
||||
{ delay: 1000 }
|
||||
);
|
||||
|
||||
@@ -102,9 +127,9 @@ const setupEditorListeners = (view: any) => {
|
||||
|
||||
// 使用对象缓存事件处理器,避免重复创建
|
||||
const eventHandlers = {
|
||||
click: updateFormatButtonState,
|
||||
keyup: debouncedUpdateFormat,
|
||||
focus: updateFormatButtonState
|
||||
click: updateButtonStates,
|
||||
keyup: debouncedUpdateButtonStates,
|
||||
focus: updateButtonStates
|
||||
} as const;
|
||||
|
||||
const events = Object.entries(eventHandlers).map(([type, handler]) => ({
|
||||
@@ -131,11 +156,12 @@ watch(
|
||||
|
||||
if (newView) {
|
||||
// 初始更新状态
|
||||
updateFormatButtonState();
|
||||
updateButtonStates();
|
||||
// 设置新监听器
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -145,8 +171,8 @@ watch(
|
||||
// 组件生命周期
|
||||
onMounted(async () => {
|
||||
isLoaded.value = true;
|
||||
// 首次更新格式化状态
|
||||
updateFormatButtonState();
|
||||
// 首次更新按钮状态
|
||||
updateButtonStates();
|
||||
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
|
||||
});
|
||||
|
||||
@@ -229,6 +255,21 @@ const statsData = computed(() => ({
|
||||
<!-- 块语言选择器 -->
|
||||
<BlockLanguageSelector/>
|
||||
|
||||
<!-- Markdown预览按钮 -->
|
||||
<div
|
||||
v-if="canPreviewMarkdown"
|
||||
class="preview-button"
|
||||
:class="{ 'active': isCurrentBlockPreviewing }"
|
||||
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 格式化按钮 - 支持点击操作 -->
|
||||
<div
|
||||
v-if="canFormatCurrentBlock"
|
||||
@@ -512,6 +553,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 {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -19,6 +19,8 @@ export default {
|
||||
searchLanguage: 'Search language...',
|
||||
noLanguageFound: 'No language found',
|
||||
formatHint: 'Click Format Block (Ctrl+Shift+F)',
|
||||
previewMarkdown: 'Preview Markdown',
|
||||
closePreview: 'Close Preview',
|
||||
// Document selector
|
||||
selectDocument: 'Select Document',
|
||||
searchOrCreateDocument: 'Search or enter new document name...',
|
||||
@@ -159,53 +161,6 @@ export default {
|
||||
customThemeColors: 'Custom Theme Colors',
|
||||
resetToDefault: 'Reset to Default',
|
||||
colorValue: 'Color Value',
|
||||
themeColors: {
|
||||
basic: 'Basic Colors',
|
||||
text: 'Text Colors',
|
||||
syntax: 'Syntax Highlighting',
|
||||
interface: 'Interface Elements',
|
||||
border: 'Borders & Dividers',
|
||||
search: 'Search & Matching',
|
||||
// Base Colors
|
||||
background: 'Main Background',
|
||||
backgroundSecondary: 'Secondary Background',
|
||||
surface: 'Panel Background',
|
||||
dropdownBackground: 'Dropdown Background',
|
||||
dropdownBorder: 'Dropdown Border',
|
||||
// Text Colors
|
||||
foreground: 'Primary Text',
|
||||
foregroundSecondary: 'Secondary Text',
|
||||
comment: 'Comments',
|
||||
// Syntax Highlighting - Core
|
||||
keyword: 'Keywords',
|
||||
string: 'Strings',
|
||||
function: 'Functions',
|
||||
number: 'Numbers',
|
||||
operator: 'Operators',
|
||||
variable: 'Variables',
|
||||
type: 'Types',
|
||||
// Syntax Highlighting - Extended
|
||||
constant: 'Constants',
|
||||
storage: 'Storage Type',
|
||||
parameter: 'Parameters',
|
||||
class: 'Class Names',
|
||||
heading: 'Headings',
|
||||
invalid: 'Invalid/Error',
|
||||
regexp: 'Regular Expressions',
|
||||
// Interface Elements
|
||||
cursor: 'Cursor',
|
||||
selection: 'Selection Background',
|
||||
selectionBlur: 'Unfocused Selection',
|
||||
activeLine: 'Active Line Highlight',
|
||||
lineNumber: 'Line Numbers',
|
||||
activeLineNumber: 'Active Line Number',
|
||||
// Borders & Dividers
|
||||
borderColor: 'Border Color',
|
||||
borderLight: 'Light Border',
|
||||
// Search & Matching
|
||||
searchMatch: 'Search Match',
|
||||
matchingBracket: 'Matching Bracket'
|
||||
},
|
||||
lineHeight: 'Line Height',
|
||||
tabSettings: 'Tab Settings',
|
||||
tabSize: 'Tab Size',
|
||||
@@ -339,4 +294,4 @@ export default {
|
||||
memory: 'Memory',
|
||||
clickToClean: 'Click to clean memory'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ export default {
|
||||
searchLanguage: '搜索语言...',
|
||||
noLanguageFound: '未找到匹配的语言',
|
||||
formatHint: '点击格式化区块(Ctrl+Shift+F)',
|
||||
previewMarkdown: '预览 Markdown',
|
||||
closePreview: '关闭预览',
|
||||
// 文档选择器
|
||||
selectDocument: '选择文档',
|
||||
searchOrCreateDocument: '搜索或输入新文档名...',
|
||||
@@ -200,54 +202,6 @@ export default {
|
||||
customThemeColors: '自定义主题颜色',
|
||||
resetToDefault: '重置为默认',
|
||||
colorValue: '颜色值',
|
||||
themeColors: {
|
||||
basic: '基础色调',
|
||||
text: '文本颜色',
|
||||
syntax: '语法高亮',
|
||||
interface: '界面元素',
|
||||
border: '边框分割线',
|
||||
search: '搜索匹配',
|
||||
// 基础色调
|
||||
background: '主背景色',
|
||||
backgroundSecondary: '次要背景色',
|
||||
surface: '面板背景',
|
||||
dropdownBackground: '下拉菜单背景',
|
||||
dropdownBorder: '下拉菜单边框',
|
||||
// 文本颜色
|
||||
foreground: '主文本色',
|
||||
foregroundSecondary: '次要文本色',
|
||||
comment: '注释色',
|
||||
// 语法高亮 - 核心
|
||||
keyword: '关键字',
|
||||
string: '字符串',
|
||||
function: '函数名',
|
||||
number: '数字',
|
||||
operator: '操作符',
|
||||
variable: '变量',
|
||||
type: '类型',
|
||||
// 语法高亮 - 扩展
|
||||
constant: '常量',
|
||||
storage: '存储类型',
|
||||
parameter: '参数',
|
||||
class: '类名',
|
||||
heading: '标题',
|
||||
invalid: '无效内容',
|
||||
regexp: '正则表达式',
|
||||
// 界面元素
|
||||
cursor: '光标',
|
||||
selection: '选中背景',
|
||||
selectionBlur: '失焦选中背景',
|
||||
activeLine: '当前行高亮',
|
||||
lineNumber: '行号',
|
||||
activeLineNumber: '活动行号',
|
||||
// 边框和分割线
|
||||
borderColor: '边框色',
|
||||
borderLight: '浅色边框',
|
||||
// 搜索和匹配
|
||||
searchMatch: '搜索匹配',
|
||||
matchingBracket: '匹配括号'
|
||||
},
|
||||
|
||||
hotkeyPreview: '预览:',
|
||||
none: '无',
|
||||
backup: {
|
||||
@@ -342,4 +296,4 @@ export default {
|
||||
memory: '内存',
|
||||
clickToClean: '点击清理内存'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,175 +1,49 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, readonly, ref, shallowRef, watchEffect, onScopeDispose } from 'vue';
|
||||
import type { GitBackupConfig } from '@/../bindings/voidraft/internal/models';
|
||||
import { ref, onScopeDispose } from 'vue';
|
||||
import { BackupService } from '@/../bindings/voidraft/internal/services';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
import { createTimerManager } from '@/common/utils/timerUtils';
|
||||
|
||||
// 备份状态枚举
|
||||
export enum BackupStatus {
|
||||
IDLE = 'idle',
|
||||
PUSHING = 'pushing',
|
||||
SUCCESS = 'success',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
// 备份操作结果类型
|
||||
export interface BackupResult {
|
||||
status: BackupStatus;
|
||||
message?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
// 类型守卫函数
|
||||
const isBackupError = (error: unknown): error is Error => {
|
||||
return error instanceof Error;
|
||||
};
|
||||
|
||||
// 工具类型:提取错误消息
|
||||
type ErrorMessage<T> = T extends Error ? string : string;
|
||||
|
||||
|
||||
export const useBackupStore = defineStore('backup', () => {
|
||||
// === 核心状态 ===
|
||||
const config = shallowRef<GitBackupConfig | null>(null);
|
||||
|
||||
// 统一的备份结果状态
|
||||
const backupResult = ref<BackupResult>({
|
||||
status: BackupStatus.IDLE
|
||||
});
|
||||
|
||||
// === 定时器管理 ===
|
||||
const statusTimer = createTimerManager();
|
||||
const isPushing = ref(false);
|
||||
const message = ref<string | null>(null);
|
||||
const isError = ref(false);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onScopeDispose(() => {
|
||||
statusTimer.clear();
|
||||
});
|
||||
|
||||
// === 外部依赖 ===
|
||||
const timer = createTimerManager();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// === 计算属性 ===
|
||||
const isEnabled = computed(() => configStore.config.backup.enabled);
|
||||
const isConfigured = computed(() => Boolean(configStore.config.backup.repo_url?.trim()));
|
||||
|
||||
// 派生状态计算属性
|
||||
const isPushing = computed(() => backupResult.value.status === BackupStatus.PUSHING);
|
||||
const isSuccess = computed(() => backupResult.value.status === BackupStatus.SUCCESS);
|
||||
const isError = computed(() => backupResult.value.status === BackupStatus.ERROR);
|
||||
const errorMessage = computed(() =>
|
||||
backupResult.value.status === BackupStatus.ERROR ? backupResult.value.message : null
|
||||
);
|
||||
onScopeDispose(() => timer.clear());
|
||||
|
||||
// === 状态管理方法 ===
|
||||
|
||||
/**
|
||||
* 设置备份状态
|
||||
* @param status 备份状态
|
||||
* @param message 可选消息
|
||||
* @param autoHide 是否自动隐藏(毫秒)
|
||||
*/
|
||||
const setBackupStatus = <T extends BackupStatus>(
|
||||
status: T,
|
||||
message?: T extends BackupStatus.ERROR ? string : string,
|
||||
autoHide?: number
|
||||
): void => {
|
||||
statusTimer.clear();
|
||||
const pushToRemote = async () => {
|
||||
const isConfigured = Boolean(configStore.config.backup.repo_url?.trim());
|
||||
|
||||
backupResult.value = {
|
||||
status,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 自动隐藏逻辑
|
||||
if (autoHide && (status === BackupStatus.SUCCESS || status === BackupStatus.ERROR)) {
|
||||
statusTimer.set(() => {
|
||||
if (backupResult.value.status === status) {
|
||||
backupResult.value = { status: BackupStatus.IDLE };
|
||||
}
|
||||
}, autoHide);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除当前状态
|
||||
*/
|
||||
const clearStatus = (): void => {
|
||||
statusTimer.clear();
|
||||
backupResult.value = { status: BackupStatus.IDLE };
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理错误的通用方法
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message: ErrorMessage<typeof error> = isBackupError(error)
|
||||
? error.message
|
||||
: 'Backup operation failed';
|
||||
|
||||
setBackupStatus(BackupStatus.ERROR, message, 5000);
|
||||
};
|
||||
|
||||
// === 业务逻辑方法 ===
|
||||
|
||||
/**
|
||||
* 推送到远程仓库
|
||||
* 使用现代 async/await 和错误处理
|
||||
*/
|
||||
const pushToRemote = async (): Promise<void> => {
|
||||
// 前置条件检查
|
||||
if (isPushing.value || !isConfigured.value) {
|
||||
if (isPushing.value || !isConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBackupStatus(BackupStatus.PUSHING);
|
||||
isPushing.value = true;
|
||||
message.value = null;
|
||||
timer.clear();
|
||||
|
||||
await BackupService.PushToRemote();
|
||||
|
||||
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
|
||||
isError.value = false;
|
||||
message.value = 'push successful';
|
||||
timer.set(() => { message.value = null; }, 3000);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
isError.value = true;
|
||||
message.value = error instanceof Error ? error.message : 'backup operation failed';
|
||||
timer.set(() => { message.value = null; }, 5000);
|
||||
} finally {
|
||||
isPushing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重试备份操作
|
||||
*/
|
||||
const retryBackup = async (): Promise<void> => {
|
||||
if (isError.value) {
|
||||
await pushToRemote();
|
||||
}
|
||||
};
|
||||
|
||||
// === 响应式副作用 ===
|
||||
|
||||
// 监听配置变化,自动清除错误状态
|
||||
watchEffect(() => {
|
||||
if (isEnabled.value && isConfigured.value && isError.value) {
|
||||
// 配置修复后清除错误状态
|
||||
clearStatus();
|
||||
}
|
||||
});
|
||||
|
||||
// === 返回的 API ===
|
||||
return {
|
||||
// 只读状态
|
||||
config: readonly(config),
|
||||
backupResult: readonly(backupResult),
|
||||
|
||||
// 计算属性
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
isPushing,
|
||||
isSuccess,
|
||||
message,
|
||||
isError,
|
||||
errorMessage,
|
||||
|
||||
// 方法
|
||||
pushToRemote,
|
||||
retryBackup,
|
||||
clearStatus
|
||||
} as const;
|
||||
pushToRemote
|
||||
};
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
|
||||
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
||||
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import type {EditorViewState} from '@/stores/editorStore';
|
||||
|
||||
export const useDocumentStore = defineStore('document', () => {
|
||||
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
|
||||
@@ -13,6 +14,10 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
const currentDocumentId = ref<number | null>(null);
|
||||
const currentDocument = ref<Document | null>(null);
|
||||
|
||||
// === 编辑器状态持久化 ===
|
||||
// 修复:使用统一的 EditorViewState 类型定义
|
||||
const documentStates = ref<Record<number, EditorViewState>>({});
|
||||
|
||||
// === UI状态 ===
|
||||
const showDocumentSelector = ref(false);
|
||||
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
||||
@@ -218,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
documentList,
|
||||
currentDocumentId,
|
||||
currentDocument,
|
||||
documentStates,
|
||||
showDocumentSelector,
|
||||
selectorError,
|
||||
isLoading,
|
||||
@@ -240,6 +246,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
persist: {
|
||||
key: 'voidraft-document',
|
||||
storage: localStorage,
|
||||
pick: ['currentDocumentId', 'documents']
|
||||
pick: ['currentDocumentId', 'documents', 'documentStates']
|
||||
}
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import {EditorView} from '@codemirror/view';
|
||||
import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {useThemeStore} from './themeStore';
|
||||
import {ExtensionID, SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {usePanelStore} from './panelStore';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language";
|
||||
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
|
||||
@@ -14,16 +14,24 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
|
||||
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||
import {createDynamicExtensions, getExtensionManager, setExtensionManagerView, removeExtensionManagerView} from '@/views/editor/manager';
|
||||
import {
|
||||
createDynamicExtensions,
|
||||
getExtensionManager,
|
||||
removeExtensionManagerView,
|
||||
setExtensionManagerView
|
||||
} from '@/views/editor/manager';
|
||||
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 {AsyncManager} from '@/common/utils/asyncManager';
|
||||
import {generateContentHash} from "@/common/utils/hashUtils";
|
||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
import {markdownPreviewExtension, updateMarkdownPreviewTheme} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
lines: number;
|
||||
@@ -31,6 +39,11 @@ export interface DocumentStats {
|
||||
selectedCharacters: number;
|
||||
}
|
||||
|
||||
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
|
||||
export interface EditorViewState {
|
||||
cursorPos: number;
|
||||
}
|
||||
|
||||
interface EditorInstance {
|
||||
view: EditorView;
|
||||
documentId: number;
|
||||
@@ -43,13 +56,14 @@ interface EditorInstance {
|
||||
lastContentHash: string;
|
||||
lastParsed: Date;
|
||||
} | null;
|
||||
// 修复:使用统一的类型,可选但不是 undefined | {...}
|
||||
editorState?: EditorViewState;
|
||||
}
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
// === 依赖store ===
|
||||
const configStore = useConfigStore();
|
||||
const documentStore = useDocumentStore();
|
||||
const themeStore = useThemeStore();
|
||||
const extensionStore = useExtensionStore();
|
||||
|
||||
// === 核心状态 ===
|
||||
@@ -65,6 +79,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
// 编辑器加载状态
|
||||
const isLoading = ref(false);
|
||||
// 修复:使用操作计数器精确管理加载状态
|
||||
const loadingOperations = ref(0);
|
||||
|
||||
// 异步操作管理器
|
||||
const operationManager = new AsyncManager<number>();
|
||||
@@ -72,8 +88,92 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 自动保存设置 - 从配置动态获取
|
||||
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 instance = editorCache.get(documentId);
|
||||
@@ -143,6 +243,11 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension(
|
||||
() => configStore.increaseFontSize(),
|
||||
() => configStore.decreaseFontSize()
|
||||
);
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
|
||||
@@ -157,6 +262,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// Markdown预览扩展
|
||||
const previewExtension = markdownPreviewExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
throw new Error('Operation cancelled');
|
||||
@@ -185,11 +293,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
themeExtension,
|
||||
...tabExtensions,
|
||||
fontExtension,
|
||||
wheelZoomExtension,
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions,
|
||||
...httpExtension
|
||||
...httpExtension,
|
||||
previewExtension
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
@@ -198,19 +308,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
extensions
|
||||
});
|
||||
|
||||
// 创建编辑器视图
|
||||
const view = new EditorView({
|
||||
return new EditorView({
|
||||
state
|
||||
});
|
||||
|
||||
// 将光标定位到文档末尾并滚动到该位置
|
||||
const docLength = view.state.doc.length;
|
||||
view.dispatch({
|
||||
selection: {anchor: docLength, head: docLength},
|
||||
scrollIntoView: true
|
||||
});
|
||||
|
||||
return view;
|
||||
};
|
||||
|
||||
// 添加编辑器到缓存
|
||||
@@ -222,7 +322,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
isDirty: false,
|
||||
lastModified: new Date(),
|
||||
autoSaveTimer: createTimerManager(),
|
||||
syntaxTreeCache: null
|
||||
syntaxTreeCache: null,
|
||||
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
|
||||
editorState: documentStore.documentStates[documentId]
|
||||
};
|
||||
|
||||
// 使用LRU缓存的onEvict回调处理被驱逐的实例
|
||||
@@ -260,10 +362,19 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 创建新的编辑器实例
|
||||
const view = await createEditorInstance(content, 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');
|
||||
}
|
||||
|
||||
@@ -283,9 +394,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
currentEditor.value.dom.remove();
|
||||
}
|
||||
|
||||
// 确保容器为空
|
||||
containerElement.value.innerHTML = '';
|
||||
|
||||
// 将目标编辑器DOM添加到容器
|
||||
containerElement.value.appendChild(instance.view.dom);
|
||||
currentEditor.value = instance.view;
|
||||
@@ -293,20 +401,18 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 设置扩展管理器视图
|
||||
setExtensionManagerView(instance.view, documentId);
|
||||
|
||||
// 重新测量和聚焦编辑器
|
||||
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
|
||||
nextTick(() => {
|
||||
// 将光标定位到文档末尾并滚动到该位置
|
||||
const docLength = instance.view.state.doc.length;
|
||||
instance.view.dispatch({
|
||||
selection: {anchor: docLength, head: docLength},
|
||||
scrollIntoView: true
|
||||
requestAnimationFrame(() => {
|
||||
// 恢复编辑器状态(光标位置和滚动位置)
|
||||
restoreEditorState(instance, documentId);
|
||||
|
||||
// 聚焦编辑器
|
||||
instance.view.focus();
|
||||
|
||||
// 使用缓存的语法树确保方法
|
||||
ensureSyntaxTreeCached(instance.view, documentId);
|
||||
});
|
||||
|
||||
// 滚动到文档底部(将光标位置滚动到可见区域)
|
||||
instance.view.focus();
|
||||
|
||||
// 使用缓存的语法树确保方法
|
||||
ensureSyntaxTreeCached(instance.view, documentId);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error showing editor:', error);
|
||||
@@ -340,17 +446,20 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
};
|
||||
|
||||
// 内容变化处理
|
||||
const onContentChange = (documentId: number) => {
|
||||
const onContentChange = () => {
|
||||
const documentId = documentStore.currentDocumentId;
|
||||
if (!documentId) return;
|
||||
const instance = editorCache.get(documentId);
|
||||
if (!instance) return;
|
||||
|
||||
// 立即设置脏标记和修改时间(切换文档时需要判断)
|
||||
instance.isDirty = true;
|
||||
instance.lastModified = new Date();
|
||||
|
||||
// 清理语法树缓存,下次访问时重新构建
|
||||
instance.syntaxTreeCache = null;
|
||||
// 优使用防抖清理语法树缓存
|
||||
debouncedClearSyntaxCache.debouncedFn(instance);
|
||||
|
||||
// 设置自动保存定时器
|
||||
// 设置自动保存定时器(已经是防抖效果:每次重置定时器)
|
||||
instance.autoSaveTimer.set(() => {
|
||||
saveEditorContent(documentId);
|
||||
}, getAutoSaveDelay());
|
||||
@@ -370,7 +479,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
// 加载编辑器
|
||||
const loadEditor = async (documentId: number, content: string) => {
|
||||
// 设置加载状态
|
||||
// 修复:使用计数器精确管理加载状态
|
||||
loadingOperations.value++;
|
||||
isLoading.value = true;
|
||||
|
||||
// 开始新的操作
|
||||
@@ -419,6 +529,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.isDirty = false;
|
||||
// 清理语法树缓存,因为内容已更新
|
||||
instance.syntaxTreeCache = null;
|
||||
// 修复:内容变了,清空光标位置,避免越界
|
||||
instance.editorState = undefined;
|
||||
delete documentStore.documentStates[documentId];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,15 +553,20 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 完成操作
|
||||
operationManager.completeOperation(operationId);
|
||||
|
||||
// 延迟一段时间后再取消加载状态
|
||||
// 修复:使用计数器精确管理加载状态,避免快速切换时状态不准确
|
||||
loadingOperations.value--;
|
||||
// 延迟一段时间后再取消加载状态,但要确保所有操作都完成了
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
if (loadingOperations.value <= 0) {
|
||||
loadingOperations.value = 0;
|
||||
isLoading.value = false;
|
||||
}
|
||||
}, EDITOR_CONFIG.LOADING_DELAY);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除编辑器
|
||||
const removeEditor = (documentId: number) => {
|
||||
const removeEditor = async (documentId: number) => {
|
||||
const instance = editorCache.get(documentId);
|
||||
if (instance) {
|
||||
try {
|
||||
@@ -457,6 +575,20 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
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();
|
||||
|
||||
@@ -510,6 +642,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 应用 Markdown 预览主题
|
||||
const applyPreviewThemeSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
updateMarkdownPreviewTheme(instance.view);
|
||||
});
|
||||
};
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
@@ -538,6 +677,16 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
operationManager.cancelAllOperations();
|
||||
|
||||
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();
|
||||
|
||||
@@ -551,6 +700,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 销毁编辑器
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
||||
const panelStore = usePanelStore();
|
||||
panelStore.reset();
|
||||
|
||||
currentEditor.value = null;
|
||||
};
|
||||
@@ -568,23 +721,38 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 更新前端编辑器扩展 - 应用于所有实例
|
||||
const manager = getExtensionManager();
|
||||
if (manager) {
|
||||
// 使用立即更新模式,跳过防抖
|
||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
||||
// 直接更新前端扩展至所有视图
|
||||
manager.updateExtension(id, enabled, config);
|
||||
}
|
||||
|
||||
// 重新加载扩展配置
|
||||
await extensionStore.loadExtensions();
|
||||
if (manager) {
|
||||
manager.initExtensions(extensionStore.extensions);
|
||||
}
|
||||
|
||||
await applyKeymapSettings();
|
||||
};
|
||||
|
||||
// 监听文档切换
|
||||
watch(() => documentStore.currentDocument, async (newDoc) => {
|
||||
watch(() => documentStore.currentDocument, async (newDoc, oldDoc) => {
|
||||
if (newDoc && containerElement.value) {
|
||||
// 使用 nextTick 确保DOM更新完成后再加载编辑器
|
||||
await nextTick(() => {
|
||||
loadEditor(newDoc.id, newDoc.content);
|
||||
});
|
||||
// 修复:在切换到新文档前,只保存旧文档的光标位置
|
||||
if (oldDoc && oldDoc.id !== newDoc.id && currentEditor.value) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -622,6 +790,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 配置更新方法
|
||||
applyFontSettings,
|
||||
applyThemeSettings,
|
||||
applyPreviewThemeSettings,
|
||||
applyTabSettings,
|
||||
applyKeymapSettings,
|
||||
|
||||
@@ -630,4 +799,4 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
editorView: currentEditor,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
170
frontend/src/stores/panelStore.ts
Normal file
170
frontend/src/stores/panelStore.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { useDocumentStore } from './documentStore';
|
||||
|
||||
/**
|
||||
* 单个文档的预览状态
|
||||
*/
|
||||
interface DocumentPreviewState {
|
||||
isOpen: boolean;
|
||||
isClosing: boolean;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板状态管理 Store
|
||||
* 管理编辑器中各种面板的显示状态(按文档ID区分)
|
||||
*/
|
||||
export const usePanelStore = defineStore('panel', () => {
|
||||
// 当前编辑器视图引用
|
||||
const editorView = ref<EditorView | null>(null);
|
||||
|
||||
// 每个文档的预览状态 Map<documentId, PreviewState>
|
||||
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
|
||||
|
||||
/**
|
||||
* 获取当前文档的预览状态
|
||||
*/
|
||||
const markdownPreview = computed(() => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) {
|
||||
return {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
}
|
||||
|
||||
return documentPreviews.value.get(currentDocId) || {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置编辑器视图
|
||||
*/
|
||||
const setEditorView = (view: EditorView | null) => {
|
||||
editorView.value = view;
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开 Markdown 预览面板
|
||||
*/
|
||||
const openMarkdownPreview = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: true,
|
||||
isClosing: false,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始关闭 Markdown 预览面板
|
||||
*/
|
||||
const startClosingMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
isClosing: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 Markdown 预览面板
|
||||
*/
|
||||
const closeMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新预览块的范围(用于实时预览)
|
||||
*/
|
||||
const updatePreviewRange = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指定块是否正在预览
|
||||
*/
|
||||
const isBlockPreviewing = (from: number, to: number): boolean => {
|
||||
const preview = markdownPreview.value;
|
||||
return preview.isOpen &&
|
||||
preview.blockFrom === from &&
|
||||
preview.blockTo === to;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置所有面板状态
|
||||
*/
|
||||
const reset = () => {
|
||||
documentPreviews.value.clear();
|
||||
editorView.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理指定文档的预览状态(文档关闭时调用)
|
||||
*/
|
||||
const clearDocumentPreview = (documentId: number) => {
|
||||
documentPreviews.value.delete(documentId);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
editorView,
|
||||
markdownPreview,
|
||||
|
||||
// 方法
|
||||
setEditorView,
|
||||
openMarkdownPreview,
|
||||
startClosingMarkdownPreview,
|
||||
closeMarkdownPreview,
|
||||
updatePreviewRange,
|
||||
isBlockPreviewing,
|
||||
reset,
|
||||
clearDocumentPreview
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,195 +1,161 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
|
||||
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { ThemeService } from '@/../bindings/voidraft/internal/services';
|
||||
import { useConfigStore } from './configStore';
|
||||
import { useEditorStore } from './editorStore';
|
||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
||||
|
||||
type ThemeOption = { name: string; type: ThemeType };
|
||||
|
||||
const resolveThemeName = (name?: string) =>
|
||||
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
|
||||
|
||||
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
|
||||
themePresetList
|
||||
.filter(preset => preset.type === type)
|
||||
.map(preset => ({ name: preset.name, type: preset.type }));
|
||||
|
||||
const darkThemeOptions = createThemeOptions(ThemeType.ThemeTypeDark);
|
||||
const lightThemeOptions = createThemeOptions(ThemeType.ThemeTypeLight);
|
||||
|
||||
const cloneColors = (colors: ThemeColorConfig): ThemeColors =>
|
||||
JSON.parse(JSON.stringify(colors)) as ThemeColors;
|
||||
|
||||
const getPresetColors = (name: string): ThemeColors => {
|
||||
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
|
||||
const colors = cloneThemeColors(preset.colors);
|
||||
colors.themeName = name;
|
||||
return colors;
|
||||
};
|
||||
|
||||
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
|
||||
const safeName = resolveThemeName(themeName);
|
||||
try {
|
||||
const theme = await ThemeService.GetThemeByName(safeName);
|
||||
if (theme?.colors) {
|
||||
const colors = cloneColors(theme.colors);
|
||||
colors.themeName = safeName;
|
||||
return colors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme override:', error);
|
||||
}
|
||||
return getPresetColors(safeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题管理 Store
|
||||
* 职责:管理主题状态、颜色配置和预设主题列表
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 所有主题列表
|
||||
const allThemes = ref<Theme[]>([]);
|
||||
|
||||
// 当前主题的颜色配置
|
||||
const currentColors = ref<ThemeColors | null>(null);
|
||||
|
||||
// 计算属性:当前系统主题模式
|
||||
const currentTheme = computed(() =>
|
||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
|
||||
const currentTheme = computed(
|
||||
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
);
|
||||
|
||||
// 计算属性:当前是否为深色模式
|
||||
const isDarkMode = computed(() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const isDarkMode = computed(
|
||||
() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
);
|
||||
|
||||
// 计算属性:根据类型获取主题列表
|
||||
const darkThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
|
||||
);
|
||||
|
||||
const lightThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
||||
);
|
||||
|
||||
// 计算属性:当前可用的主题列表
|
||||
const availableThemes = computed(() =>
|
||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
||||
const availableThemes = computed<ThemeOption[]>(() =>
|
||||
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||
);
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||
const themeMap = {
|
||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||
[SystemThemeType.SystemThemeLight]: 'light'
|
||||
[SystemThemeType.SystemThemeLight]: 'light',
|
||||
};
|
||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||
};
|
||||
|
||||
// 从数据库加载所有主题
|
||||
const loadAllThemes = async () => {
|
||||
try {
|
||||
const themes = await ThemeService.GetAllThemes();
|
||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
||||
return allThemes.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to load themes from database:', error);
|
||||
allThemes.value = [];
|
||||
return [];
|
||||
}
|
||||
const loadThemeColors = async (themeName?: string) => {
|
||||
const targetName = resolveThemeName(
|
||||
themeName || configStore.config?.appearance?.currentTheme
|
||||
);
|
||||
currentColors.value = await fetchThemeColors(targetName);
|
||||
};
|
||||
|
||||
// 初始化主题颜色
|
||||
const initializeThemeColors = async () => {
|
||||
// 加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
// 从配置获取当前主题名称并加载
|
||||
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentThemeName);
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Theme not found: ${currentThemeName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
const initializeTheme = async () => {
|
||||
const theme = currentTheme.value;
|
||||
applyThemeToDOM(theme);
|
||||
await initializeThemeColors();
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
await loadThemeColors();
|
||||
};
|
||||
|
||||
// 设置系统主题模式(深色/浅色/自动)
|
||||
const setTheme = async (theme: SystemThemeType) => {
|
||||
await configStore.setSystemTheme(theme);
|
||||
applyThemeToDOM(theme);
|
||||
refreshEditorTheme();
|
||||
};
|
||||
|
||||
// 切换到指定的预设主题
|
||||
|
||||
const switchToTheme = async (themeName: string) => {
|
||||
const theme = allThemes.value.find(t => t.name === themeName);
|
||||
if (!theme) {
|
||||
if (!themePresetMap[themeName]) {
|
||||
console.error('Theme not found:', themeName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
|
||||
// 持久化到配置
|
||||
await loadThemeColors(themeName);
|
||||
await configStore.setCurrentTheme(themeName);
|
||||
|
||||
// 刷新编辑器
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 更新当前主题的颜色配置
|
||||
|
||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||
if (!currentColors.value) return;
|
||||
Object.assign(currentColors.value, colors);
|
||||
};
|
||||
|
||||
// 保存当前主题颜色到数据库
|
||||
|
||||
const saveCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
if (!theme) {
|
||||
throw new Error('Theme not found');
|
||||
}
|
||||
|
||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
currentColors.value.themeName = themeName;
|
||||
|
||||
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 重置当前主题为预设配置
|
||||
|
||||
const resetCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
// 调用后端重置
|
||||
await ThemeService.ResetTheme(0, currentColors.value.name);
|
||||
|
||||
// 重新加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
|
||||
if (updatedTheme) {
|
||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
||||
}
|
||||
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
await ThemeService.ResetTheme(themeName);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 刷新编辑器主题
|
||||
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
editorStore?.applyPreviewThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
availableThemes,
|
||||
currentTheme,
|
||||
currentColors,
|
||||
isDarkMode,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
switchToTheme,
|
||||
initializeTheme,
|
||||
loadAllThemes,
|
||||
updateCurrentColors,
|
||||
saveCurrentTheme,
|
||||
resetCurrentTheme,
|
||||
refreshEditorTheme,
|
||||
applyThemeToDOM,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user