28 Commits

Author SHA1 Message Date
5d6f157ae1 🎨 Update initial code block definition
Some checks failed
Build and Release Voidraft / prepare (push) Failing after 2s
Build and Release Voidraft / build (push) Has been cancelled
Build and Release Voidraft / release (push) Has been cancelled
2025-11-16 17:49:19 +08:00
afda3d5301 Merge remote-tracking branch 'github/dependabot/npm_and_yarn/frontend/js-yaml-4.1.1' 2025-11-16 15:44:58 +08:00
5d4ba757aa Optimize HTTP client 2025-11-16 15:41:16 +08:00
d12d58b15a 🐛 Fixed markdown preview issue 2025-11-16 15:22:49 +08:00
627c3dc71f 🐛 Fixed markdown preview issue 2025-11-16 15:16:49 +08:00
26c7a3241c 🐛 Fixed module path issues 2025-11-16 02:42:59 +08:00
46c5e3dd1a Added markdown and mermaid preview 2025-11-16 02:42:01 +08:00
dependabot[bot]
92a6c6bfdb ⬆️ Bump js-yaml from 4.1.0 to 4.1.1 in /frontend
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-15 18:38:56 +00:00
031aa49f9f Added markdown and mermaid preview 2025-11-16 02:37:30 +08:00
1d7aee4cea 🐛 Fixed data migration issues 2025-11-14 20:16:16 +08:00
dec3ef5ef4 Added cursor protection extension 2025-11-13 21:00:35 +08:00
d42f913250 Added editor cursor state persistence 2025-11-13 20:44:47 +08:00
bae4e663fb ⬆️ Upgrade dependencies 2025-11-13 20:02:22 +08:00
a17e060d16 ⬆️ Upgrade dependencies 2025-11-13 19:36:11 +08:00
71946965eb 🐛 Fixed database constraint issues 2025-11-08 17:35:29 +08:00
d4cd22d234 🚀 Update build and release workflows 2025-11-08 17:17:07 +08:00
05f2f7d46d 🚀 Update build and release workflows 2025-11-08 17:05:31 +08:00
9deb2744a9 🚀 Update build and release workflows 2025-11-08 16:24:22 +08:00
6fac7c42d6 🚀 Update build and release workflows 2025-11-08 16:03:26 +08:00
3393bc84e3 🚀 Add build and release workflows 2025-11-08 15:50:30 +08:00
286b0159d7 🎨 Optimize update services
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2025-11-08 00:00:08 +08:00
cc98e556c6 ♻️ Optimize code
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2025-11-07 22:34:12 +08:00
5902f482d9 ♻️ Refactor configuration change notification service 2025-11-07 00:35:11 +08:00
551e7e2cfd Optimize hotkey service 2025-11-06 22:42:44 +08:00
e0179b5838 🎨 Optimize hotkey service 2025-11-06 00:08:26 +08:00
df79267e16 Optimize multi-window services 2025-11-05 22:07:43 +08:00
1f0254822f 🎨 Optimize multi-window services 2025-11-05 00:10:26 +08:00
e9b6fef3cd Added mermaid language support 2025-11-04 22:58:36 +08:00
186 changed files with 20879 additions and 4120 deletions

325
.github/workflows/build-release.yml vendored Normal file
View 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 }}

View File

@@ -12,25 +12,13 @@ vars:
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks: tasks:
version:
summary: Generate version information
cmds:
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
sources:
- scripts/version.bat
- scripts/version.sh
generates:
- version.txt
build: build:
summary: Builds the application summary: Builds the application
deps: [version]
cmds: cmds:
- task: "{{OS}}:build" - task: "{{OS}}:build"
package: package:
summary: Packages a production build of the application summary: Packages a production build of the application
deps: [version]
cmds: cmds:
- task: "{{OS}}:package" - task: "{{OS}}:package"

293
build/COMMANDS.md Normal file
View 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

View File

@@ -39,7 +39,7 @@ tasks:
summary: Generates Windows `.syso` file summary: Generates Windows `.syso` file
dir: build dir: build
cmds: 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: vars:
ARCH: '{{.ARCH | default ARCH}}' ARCH: '{{.ARCH | default ARCH}}'

View File

@@ -62,19 +62,4 @@ export class ServiceOptions {
} }
} }
export class WebviewWindow { export type Window = any;
/** 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>);
}
}

View File

@@ -10,10 +10,17 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime"; 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 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as models$0 from "../models/models.js"; 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 获取配置项 * Get 获取配置项
*/ */
@@ -34,22 +41,6 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
return $typingPromise; 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 执行配置迁移 * MigrateConfig 执行配置迁移
*/ */
@@ -74,6 +65,14 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
return $resultPromise; 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 设置配置项 * 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 } { export function Watch(path: string, callback: $models.ObserverCallback): Promise<$models.CancelFunc> & { cancel(): void } {
let $resultPromise = $Call.ByID(3264871659, callback) as any; let $resultPromise = $Call.ByID(1143583035, path, callback) as any;
return $resultPromise; return $resultPromise;
} }
/** /**
* SetDataPathChangeCallback 设置数据路径配置变更回调 * WatchWithContext 使用 Context 注册监听器
*/ */
export function SetDataPathChangeCallback(callback: any): Promise<void> & { cancel(): void } { export function WatchWithContext(path: string, callback: $models.ObserverCallback): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(393017412, callback) as any; let $resultPromise = $Call.ByID(1454973098, path, 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;
return $resultPromise; return $resultPromise;
} }

View File

@@ -14,14 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* OnDataPathChanged handles data path changes
*/
export function OnDataPathChanged(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3652863491) as any;
return $resultPromise;
}
/** /**
* RegisterModel 注册模型与表的映射关系 * RegisterModel 注册模型与表的映射关系
*/ */

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
/** /**
* HotkeyService Windows全局热键服务 * HotkeyService 全局热键服务
* @module * @module
*/ */
@@ -48,8 +48,8 @@ export function IsRegistered(): Promise<boolean> & { cancel(): void } {
/** /**
* RegisterHotkey 注册全局热键 * RegisterHotkey 注册全局热键
*/ */
export function RegisterHotkey(hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } { export function RegisterHotkey(combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1103945691, hotkey) as any; let $resultPromise = $Call.ByID(1103945691, combo) as any;
return $resultPromise; 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 } { export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3079990808, options) as any; let $resultPromise = $Call.ByID(3079990808, options) as any;
@@ -80,8 +80,8 @@ export function UnregisterHotkey(): Promise<void> & { cancel(): void } {
/** /**
* UpdateHotkey 更新热键配置 * UpdateHotkey 更新热键配置
*/ */
export function UpdateHotkey(enable: boolean, hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } { export function UpdateHotkey(enable: boolean, combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(823285555, enable, hotkey) as any; let $resultPromise = $Call.ByID(823285555, enable, combo) as any;
return $resultPromise; return $resultPromise;
} }

View File

@@ -5,9 +5,6 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime"; 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 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as http$0 from "../../../net/http/models.js"; 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 // @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js"; import * as time$0 from "../../../time/models.js";
/**
* CancelFunc 取消订阅函数
* 调用此函数可以取消对配置的监听
*/
export type CancelFunc = any;
/** /**
* HttpRequest HTTP请求结构 * HttpRequest HTTP请求结构
*/ */
@@ -263,6 +266,11 @@ export class OSInfo {
} }
} }
/**
* ObserverCallback 观察者回调函数
*/
export type ObserverCallback = any;
/** /**
* SelfUpdateResult 自我更新结果 * 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 // Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any); const $$createType0 = $Create.Map($Create.Any, $Create.Any);
var $$createType1 = (function $$initCreateType1(...args): any { var $$createType1 = (function $$initCreateType1(...args): any {
@@ -463,5 +415,3 @@ const $$createType3 = $Create.Map($Create.Any, $$createType2);
const $$createType4 = OSInfo.createFrom; const $$createType4 = OSInfo.createFrom;
const $$createType5 = $Create.Nullable($$createType4); const $$createType5 = $Create.Nullable($$createType4);
const $$createType6 = $Create.Map($Create.Any, $Create.Any); const $$createType6 = $Create.Map($Create.Any, $Create.Any);
const $$createType7 = application$0.WebviewWindow.createFrom;
const $$createType8 = $Create.Nullable($$createType7);

View File

@@ -10,6 +10,14 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime"; 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 处理窗口关闭事件 * HandleWindowClose 处理窗口关闭事件
*/ */

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
/** /**
* WindowService 窗口管理服务(专注于窗口生命周期管理) * WindowService 窗口管理服务
* @module * @module
*/ */
@@ -12,15 +12,15 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports // @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 $resultPromise = $Call.ByID(1464997251) as any;
let $typingPromise = $resultPromise.then(($result: any) => { let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result); return $$createType0($result);
}) as any; }) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise; 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 } { export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1105193745, snapService) as any; let $resultPromise = $Call.ByID(2432987694, options) as any;
return $resultPromise; return $resultPromise;
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $models.WindowInfo.createFrom; const $$createType0 = $Create.Array($Create.Any);
const $$createType1 = $Create.Array($$createType0);

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,15 @@
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint --fix", "lint:fix": "eslint --fix",
"build:lang-parser": "node src/views/editor/extensions/codeblock/lang-parser/build-parser.js", "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", "test": "vitest",
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build 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": { "dependencies": {
"@codemirror/autocomplete": "^6.19.1", "@codemirror/autocomplete": "^6.19.1",
@@ -42,56 +47,64 @@
"@codemirror/language": "^6.11.3", "@codemirror/language": "^6.11.3",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.1", "@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.6", "@codemirror/view": "^6.38.6",
"@cospaia/prettier-plugin-clojure": "^0.0.2", "@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.3",
"@mdit/plugin-katex": "^0.23.2",
"@mdit/plugin-tasklist": "^0.22.2",
"@prettier/plugin-xml": "^3.4.2", "@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0", "@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0", "@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0", "@toml-tools/parser": "^1.0.0",
"@types/markdown-it": "^14.1.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0", "codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2", "colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2", "colors-named-hex": "^1.0.2",
"groovy-beautify": "^0.0.17", "groovy-beautify": "^0.0.17",
"highlight.js": "^11.11.1",
"hsl-matcher": "^1.2.4", "hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1", "java-parser": "^3.0.1",
"jsox": "^1.2.123",
"linguist-languages": "^9.1.0", "linguist-languages": "^9.1.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.12.1",
"npm": "^11.6.2",
"php-parser": "^3.2.5", "php-parser": "^3.2.5",
"pinia": "^3.0.3", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"remarkable": "^2.0.1", "sass": "^1.94.0",
"sass": "^1.93.3", "vue": "^3.5.24",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12", "vue-i18n": "^11.1.12",
"vue-pick-colors": "^1.8.0", "vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.1",
"@lezer/generator": "^1.8.0", "@lezer/generator": "^1.8.0",
"@types/node": "^24.9.2", "@types/node": "^24.9.2",
"@types/remarkable": "^2.0.8",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@wailsio/runtime": "latest", "@wailsio/runtime": "latest",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"eslint": "^9.39.0", "eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1", "eslint-plugin-vue": "^10.5.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^20.0.10",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.4",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12", "vite": "npm:rolldown-vite@latest",
"vite-plugin-node-polyfills": "^0.24.0", "vite-plugin-node-polyfills": "^0.24.0",
"vitepress": "^2.0.0-alpha.12", "vitepress": "^2.0.0-alpha.12",
"vitest": "^4.0.6", "vitest": "^4.0.8",
"vue-eslint-parser": "^10.2.0", "vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.2" "vue-tsc": "^3.1.3"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
} }
} }

View File

@@ -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);
}

View File

@@ -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'] });
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
};
}

View File

@@ -0,0 +1,9 @@
import { Token } from 'markdown-it';
/**
* Emoji 渲染函数
*/
export default function emoji_html(tokens: Token[], idx: number): string {
return tokens[idx].content;
}

View File

@@ -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)
);
}
}
}
};
}

View File

@@ -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);
}

View 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;
});
}

View 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-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;
});
}

View File

@@ -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);
};
};

View File

@@ -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);
}
};

View File

@@ -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);
}

View File

@@ -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);
}

View 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');
});
});
});

View 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);
}

View File

@@ -80,7 +80,7 @@ function animationLoop() {
// 等待一段时间后重置动画 // 等待一段时间后重置动画
resetTimeoutId = window.setTimeout(() => { resetTimeoutId = window.setTimeout(() => {
reset(); reset();
}, 750); }, 500);
} }
} }
@@ -136,7 +136,8 @@ onBeforeUnmount(() => {
left: 0; left: 0;
right: 0; right: 0;
bottom: 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; z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -13,16 +13,20 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages'; import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode'; import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
import {createDebounce} from '@/common/utils/debounce'; import {createDebounce} from '@/common/utils/debounce';
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
import {usePanelStore} from '@/stores/panelStore';
const editorStore = readonly(useEditorStore()); const editorStore = readonly(useEditorStore());
const configStore = readonly(useConfigStore()); const configStore = readonly(useConfigStore());
const updateStore = readonly(useUpdateStore()); const updateStore = readonly(useUpdateStore());
const windowStore = readonly(useWindowStore()); const windowStore = readonly(useWindowStore());
const systemStore = readonly(useSystemStore()); const systemStore = readonly(useSystemStore());
const panelStore = readonly(usePanelStore());
const {t} = useI18n(); const {t} = useI18n();
const router = useRouter(); const router = useRouter();
const canFormatCurrentBlock = ref(false); const canFormatCurrentBlock = ref(false);
const canPreviewMarkdown = ref(false);
const isLoaded = shallowRef(false); const isLoaded = shallowRef(false);
const { documentStats } = toRefs(editorStore); const { documentStats } = toRefs(editorStore);
@@ -33,6 +37,11 @@ const isCurrentWindowOnTop = computed(() => {
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop; return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
}); });
// 当前文档的预览是否打开
const isCurrentBlockPreviewing = computed(() => {
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
});
// 切换窗口置顶状态 // 切换窗口置顶状态
const toggleAlwaysOnTop = async () => { const toggleAlwaysOnTop = async () => {
const currentlyOnTop = isCurrentWindowOnTop.value; const currentlyOnTop = isCurrentWindowOnTop.value;
@@ -60,11 +69,22 @@ const formatCurrentBlock = () => {
formatBlockContent(editorStore.editorView); formatBlockContent(editorStore.editorView);
}; };
// 格式化按钮状态更新 - 使用更高效的检查逻辑 // 切换 Markdown 预览
const updateFormatButtonState = () => { const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
const view = editorStore.editorView; 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) { if (!view) {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return; return;
} }
@@ -75,20 +95,25 @@ const updateFormatButtonState = () => {
// 提前返回,减少不必要的计算 // 提前返回,减少不必要的计算
if (!activeBlock) { if (!activeBlock) {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return; return;
} }
const language = getLanguage(activeBlock.language.name as any); const languageName = activeBlock.language.name;
const language = getLanguage(languageName as any);
canFormatCurrentBlock.value = Boolean(language?.prettier); canFormatCurrentBlock.value = Boolean(language?.prettier);
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
} catch (error) { } catch (error) {
console.warn('Error checking format capability:', error); console.warn('Error checking block capabilities:', error);
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
} }
}; };
// 创建带1s防抖的更新函数 // 创建带1s防抖的更新函数
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce( const { debouncedFn: debouncedUpdateButtonStates, cancel: cancelDebounce } = createDebounce(
updateFormatButtonState, updateButtonStates,
{ delay: 1000 } { delay: 1000 }
); );
@@ -102,9 +127,9 @@ const setupEditorListeners = (view: any) => {
// 使用对象缓存事件处理器,避免重复创建 // 使用对象缓存事件处理器,避免重复创建
const eventHandlers = { const eventHandlers = {
click: updateFormatButtonState, click: updateButtonStates,
keyup: debouncedUpdateFormat, keyup: debouncedUpdateButtonStates,
focus: updateFormatButtonState focus: updateButtonStates
} as const; } as const;
const events = Object.entries(eventHandlers).map(([type, handler]) => ({ const events = Object.entries(eventHandlers).map(([type, handler]) => ({
@@ -131,11 +156,12 @@ watch(
if (newView) { if (newView) {
// 初始更新状态 // 初始更新状态
updateFormatButtonState(); updateButtonStates();
// 设置新监听器 // 设置新监听器
cleanupListeners = setupEditorListeners(newView); cleanupListeners = setupEditorListeners(newView);
} else { } else {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
} }
}); });
}, },
@@ -145,8 +171,8 @@ watch(
// 组件生命周期 // 组件生命周期
onMounted(async () => { onMounted(async () => {
isLoaded.value = true; isLoaded.value = true;
// 首次更新格式化状态 // 首次更新按钮状态
updateFormatButtonState(); updateButtonStates();
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value); await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
}); });
@@ -229,6 +255,21 @@ const statsData = computed(() => ({
<!-- 块语言选择器 --> <!-- 块语言选择器 -->
<BlockLanguageSelector/> <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 <div
v-if="canFormatCurrentBlock" 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 { .settings-btn {
background: none; background: none;
border: none; border: none;

View File

@@ -19,6 +19,8 @@ export default {
searchLanguage: 'Search language...', searchLanguage: 'Search language...',
noLanguageFound: 'No language found', noLanguageFound: 'No language found',
formatHint: 'Click Format Block (Ctrl+Shift+F)', formatHint: 'Click Format Block (Ctrl+Shift+F)',
previewMarkdown: 'Preview Markdown',
closePreview: 'Close Preview',
// Document selector // Document selector
selectDocument: 'Select Document', selectDocument: 'Select Document',
searchOrCreateDocument: 'Search or enter new document name...', searchOrCreateDocument: 'Search or enter new document name...',

View File

@@ -19,6 +19,8 @@ export default {
searchLanguage: '搜索语言...', searchLanguage: '搜索语言...',
noLanguageFound: '未找到匹配的语言', noLanguageFound: '未找到匹配的语言',
formatHint: '点击格式化区块Ctrl+Shift+F', formatHint: '点击格式化区块Ctrl+Shift+F',
previewMarkdown: '预览 Markdown',
closePreview: '关闭预览',
// 文档选择器 // 文档选择器
selectDocument: '选择文档', selectDocument: '选择文档',
searchOrCreateDocument: '搜索或输入新文档名...', searchOrCreateDocument: '搜索或输入新文档名...',

View File

@@ -1,175 +1,49 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, readonly, ref, shallowRef, watchEffect, onScopeDispose } from 'vue'; import { ref, onScopeDispose } from 'vue';
import type { GitBackupConfig } from '@/../bindings/voidraft/internal/models';
import { BackupService } from '@/../bindings/voidraft/internal/services'; import { BackupService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from '@/stores/configStore'; import { useConfigStore } from '@/stores/configStore';
import { createTimerManager } from '@/common/utils/timerUtils'; 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', () => { export const useBackupStore = defineStore('backup', () => {
// === 核心状态 === const isPushing = ref(false);
const config = shallowRef<GitBackupConfig | null>(null); const message = ref<string | null>(null);
const isError = ref(false);
// 统一的备份结果状态 const timer = createTimerManager();
const backupResult = ref<BackupResult>({
status: BackupStatus.IDLE
});
// === 定时器管理 ===
const statusTimer = createTimerManager();
// 组件卸载时清理定时器
onScopeDispose(() => {
statusTimer.clear();
});
// === 外部依赖 ===
const configStore = useConfigStore(); const configStore = useConfigStore();
// === 计算属性 === onScopeDispose(() => timer.clear());
const isEnabled = computed(() => configStore.config.backup.enabled);
const isConfigured = computed(() => Boolean(configStore.config.backup.repo_url?.trim()));
// 派生状态计算属性 const pushToRemote = async () => {
const isPushing = computed(() => backupResult.value.status === BackupStatus.PUSHING); const isConfigured = Boolean(configStore.config.backup.repo_url?.trim());
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
);
// === 状态管理方法 === if (isPushing.value || !isConfigured) {
/**
* 设置备份状态
* @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();
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) {
return; return;
} }
try { try {
setBackupStatus(BackupStatus.PUSHING); isPushing.value = true;
message.value = null;
timer.clear();
await BackupService.PushToRemote(); 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) { } 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 { return {
// 只读状态
config: readonly(config),
backupResult: readonly(backupResult),
// 计算属性
isEnabled,
isConfigured,
isPushing, isPushing,
isSuccess, message,
isError, isError,
errorMessage, pushToRemote
};
// 方法
pushToRemote,
retryBackup,
clearStatus
} as const;
}); });

View File

@@ -4,6 +4,7 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice'; import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models'; import {Document} from '@/../bindings/voidraft/internal/models/models';
import {useTabStore} from "@/stores/tabStore"; import {useTabStore} from "@/stores/tabStore";
import type {EditorViewState} from '@/stores/editorStore';
export const useDocumentStore = defineStore('document', () => { export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
@@ -13,6 +14,10 @@ export const useDocumentStore = defineStore('document', () => {
const currentDocumentId = ref<number | null>(null); const currentDocumentId = ref<number | null>(null);
const currentDocument = ref<Document | null>(null); const currentDocument = ref<Document | null>(null);
// === 编辑器状态持久化 ===
// 修复:使用统一的 EditorViewState 类型定义
const documentStates = ref<Record<number, EditorViewState>>({});
// === UI状态 === // === UI状态 ===
const showDocumentSelector = ref(false); const showDocumentSelector = ref(false);
const selectorError = ref<{ docId: number; message: string } | null>(null); const selectorError = ref<{ docId: number; message: string } | null>(null);
@@ -218,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
documentList, documentList,
currentDocumentId, currentDocumentId,
currentDocument, currentDocument,
documentStates,
showDocumentSelector, showDocumentSelector,
selectorError, selectorError,
isLoading, isLoading,
@@ -240,6 +246,6 @@ export const useDocumentStore = defineStore('document', () => {
persist: { persist: {
key: 'voidraft-document', key: 'voidraft-document',
storage: localStorage, storage: localStorage,
pick: ['currentDocumentId', 'documents'] pick: ['currentDocumentId', 'documents', 'documentStates']
} }
}); });

View File

@@ -4,8 +4,8 @@ import {EditorView} from '@codemirror/view';
import {EditorState, Extension} from '@codemirror/state'; import {EditorState, Extension} from '@codemirror/state';
import {useConfigStore} from './configStore'; import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore'; import {useDocumentStore} from './documentStore';
import {useThemeStore} from './themeStore'; import {usePanelStore} from './panelStore';
import {ExtensionID, SystemThemeType} from '@/../bindings/voidraft/internal/models/models'; import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services'; import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
import {ensureSyntaxTree} from "@codemirror/language"; import {ensureSyntaxTree} from "@codemirror/language";
import {createBasicSetup} from '@/views/editor/basic/basicSetup'; import {createBasicSetup} from '@/views/editor/basic/basicSetup';
@@ -15,15 +15,22 @@ import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/b
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension'; import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension'; import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap'; import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
import {createDynamicExtensions, getExtensionManager, setExtensionManagerView, removeExtensionManagerView} from '@/views/editor/manager'; import {
createDynamicExtensions,
getExtensionManager,
removeExtensionManagerView,
setExtensionManagerView
} from '@/views/editor/manager';
import {useExtensionStore} from './extensionStore'; import {useExtensionStore} from './extensionStore';
import createCodeBlockExtension from "@/views/editor/extensions/codeblock"; import createCodeBlockExtension, {blockState} from "@/views/editor/extensions/codeblock";
import {LruCache} from '@/common/utils/lruCache'; import {LruCache} from '@/common/utils/lruCache';
import {AsyncManager} from '@/common/utils/asyncManager'; import {AsyncManager} from '@/common/utils/asyncManager';
import {generateContentHash} from "@/common/utils/hashUtils"; import {generateContentHash} from "@/common/utils/hashUtils";
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils'; import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor'; import {EDITOR_CONFIG} from '@/common/constant/editor';
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient"; import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
import {createDebounce} from '@/common/utils/debounce';
export interface DocumentStats { export interface DocumentStats {
lines: number; lines: number;
@@ -31,6 +38,11 @@ export interface DocumentStats {
selectedCharacters: number; selectedCharacters: number;
} }
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
export interface EditorViewState {
cursorPos: number;
}
interface EditorInstance { interface EditorInstance {
view: EditorView; view: EditorView;
documentId: number; documentId: number;
@@ -43,13 +55,14 @@ interface EditorInstance {
lastContentHash: string; lastContentHash: string;
lastParsed: Date; lastParsed: Date;
} | null; } | null;
// 修复:使用统一的类型,可选但不是 undefined | {...}
editorState?: EditorViewState;
} }
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
// === 依赖store === // === 依赖store ===
const configStore = useConfigStore(); const configStore = useConfigStore();
const documentStore = useDocumentStore(); const documentStore = useDocumentStore();
const themeStore = useThemeStore();
const extensionStore = useExtensionStore(); const extensionStore = useExtensionStore();
// === 核心状态 === // === 核心状态 ===
@@ -65,6 +78,8 @@ export const useEditorStore = defineStore('editor', () => {
// 编辑器加载状态 // 编辑器加载状态
const isLoading = ref(false); const isLoading = ref(false);
// 修复:使用操作计数器精确管理加载状态
const loadingOperations = ref(0);
// 异步操作管理器 // 异步操作管理器
const operationManager = new AsyncManager<number>(); const operationManager = new AsyncManager<number>();
@@ -72,8 +87,92 @@ export const useEditorStore = defineStore('editor', () => {
// 自动保存设置 - 从配置动态获取 // 自动保存设置 - 从配置动态获取
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay; const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
// 创建防抖的语法树缓存清理函数
const debouncedClearSyntaxCache = createDebounce((instance) => {
if (instance) {
instance.syntaxTreeCache = null;
}
}, { delay: 500 }); // 500ms 内的多次输入只清理一次
// === 私有方法 === // === 私有方法 ===
/**
* 检查位置是否在代码块分隔符区域内
*/
const isPositionInDelimiter = (view: EditorView, pos: number): boolean => {
try {
const blocks = view.state.field(blockState, false);
if (!blocks) return false;
for (const block of blocks) {
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
return true;
}
}
return false;
} catch {
return false;
}
};
/**
* 调整光标位置到有效的内容区域
* 如果位置在分隔符内,移动到该块的内容开始位置
*/
const adjustCursorPosition = (view: EditorView, pos: number): number => {
try {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) return pos;
// 如果位置在分隔符内,移动到该块的内容开始位置
for (const block of blocks) {
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
return block.content.from;
}
}
return pos;
} catch {
return pos;
}
};
/**
* 恢复编辑器的光标位置(自动滚动到光标处)
*/
const restoreEditorState = (instance: EditorInstance, documentId: number): void => {
const savedState = instance.editorState;
if (savedState) {
// 有保存的状态,恢复光标位置
let pos = Math.min(savedState.cursorPos, instance.view.state.doc.length);
// 确保位置不在分隔符上
if (isPositionInDelimiter(instance.view, pos)) {
pos = adjustCursorPosition(instance.view, pos);
}
// 修复:设置光标位置并居中滚动(更好的用户体验)
instance.view.dispatch({
selection: {anchor: pos, head: pos},
effects: EditorView.scrollIntoView(pos, {
y: "center", // 垂直居中显示
yMargin: 100 // 上下留一些边距
})
});
} else {
// 首次打开或没有记录,光标在文档末尾
const docLength = instance.view.state.doc.length;
instance.view.dispatch({
selection: {anchor: docLength, head: docLength},
effects: EditorView.scrollIntoView(docLength, {
y: "center",
yMargin: 100
})
});
}
};
// 缓存化的语法树确保方法 // 缓存化的语法树确保方法
const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => { const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => {
const instance = editorCache.get(documentId); const instance = editorCache.get(documentId);
@@ -157,6 +256,9 @@ export const useEditorStore = defineStore('editor', () => {
const httpExtension = createHttpClientExtension(); const httpExtension = createHttpClientExtension();
// Markdown预览扩展
const previewExtension = markdownPreviewExtension();
// 再次检查操作有效性 // 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) { if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled'); throw new Error('Operation cancelled');
@@ -189,7 +291,8 @@ export const useEditorStore = defineStore('editor', () => {
contentChangeExtension, contentChangeExtension,
codeBlockExtension, codeBlockExtension,
...dynamicExtensions, ...dynamicExtensions,
...httpExtension ...httpExtension,
previewExtension
]; ];
// 创建编辑器状态 // 创建编辑器状态
@@ -198,19 +301,9 @@ export const useEditorStore = defineStore('editor', () => {
extensions extensions
}); });
// 创建编辑器视图 return new EditorView({
const view = new EditorView({
state state
}); });
// 将光标定位到文档末尾并滚动到该位置
const docLength = view.state.doc.length;
view.dispatch({
selection: {anchor: docLength, head: docLength},
scrollIntoView: true
});
return view;
}; };
// 添加编辑器到缓存 // 添加编辑器到缓存
@@ -222,7 +315,9 @@ export const useEditorStore = defineStore('editor', () => {
isDirty: false, isDirty: false,
lastModified: new Date(), lastModified: new Date(),
autoSaveTimer: createTimerManager(), autoSaveTimer: createTimerManager(),
syntaxTreeCache: null syntaxTreeCache: null,
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
editorState: documentStore.documentStates[documentId]
}; };
// 使用LRU缓存的onEvict回调处理被驱逐的实例 // 使用LRU缓存的onEvict回调处理被驱逐的实例
@@ -260,10 +355,19 @@ export const useEditorStore = defineStore('editor', () => {
// 创建新的编辑器实例 // 创建新的编辑器实例
const view = await createEditorInstance(content, operationId, documentId); const view = await createEditorInstance(content, operationId, documentId);
// 最终检查操作有效性 // 完善取消操作时的清理逻辑
if (!operationManager.isOperationValid(operationId, documentId)) { if (!operationManager.isOperationValid(operationId, documentId)) {
// 如果操作已取消,清理创建的实例 // 如果操作已取消,彻底清理创建的实例
view.destroy(); try {
// 移除 DOM 元素(如果已添加到文档)
if (view.dom && view.dom.parentElement) {
view.dom.remove();
}
// 销毁编辑器视图
view.destroy();
} catch (error) {
console.error('Error cleaning up cancelled editor:', error);
}
throw new Error('Operation cancelled'); throw new Error('Operation cancelled');
} }
@@ -283,9 +387,6 @@ export const useEditorStore = defineStore('editor', () => {
currentEditor.value.dom.remove(); currentEditor.value.dom.remove();
} }
// 确保容器为空
containerElement.value.innerHTML = '';
// 将目标编辑器DOM添加到容器 // 将目标编辑器DOM添加到容器
containerElement.value.appendChild(instance.view.dom); containerElement.value.appendChild(instance.view.dom);
currentEditor.value = instance.view; currentEditor.value = instance.view;
@@ -293,20 +394,18 @@ export const useEditorStore = defineStore('editor', () => {
// 设置扩展管理器视图 // 设置扩展管理器视图
setExtensionManagerView(instance.view, documentId); setExtensionManagerView(instance.view, documentId);
// 重新测量和聚焦编辑器 //使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
nextTick(() => { nextTick(() => {
// 将光标定位到文档末尾并滚动到该位置 requestAnimationFrame(() => {
const docLength = instance.view.state.doc.length; // 恢复编辑器状态(光标位置和滚动位置)
instance.view.dispatch({ restoreEditorState(instance, documentId);
selection: {anchor: docLength, head: docLength},
scrollIntoView: true // 聚焦编辑器
instance.view.focus();
// 使用缓存的语法树确保方法
ensureSyntaxTreeCached(instance.view, documentId);
}); });
// 滚动到文档底部(将光标位置滚动到可见区域)
instance.view.focus();
// 使用缓存的语法树确保方法
ensureSyntaxTreeCached(instance.view, documentId);
}); });
} catch (error) { } catch (error) {
console.error('Error showing editor:', error); console.error('Error showing editor:', error);
@@ -340,17 +439,20 @@ export const useEditorStore = defineStore('editor', () => {
}; };
// 内容变化处理 // 内容变化处理
const onContentChange = (documentId: number) => { const onContentChange = () => {
const documentId = documentStore.currentDocumentId;
if (!documentId) return;
const instance = editorCache.get(documentId); const instance = editorCache.get(documentId);
if (!instance) return; if (!instance) return;
// 立即设置脏标记和修改时间(切换文档时需要判断)
instance.isDirty = true; instance.isDirty = true;
instance.lastModified = new Date(); instance.lastModified = new Date();
// 清理语法树缓存,下次访问时重新构建 // 优使用防抖清理语法树缓存
instance.syntaxTreeCache = null; debouncedClearSyntaxCache.debouncedFn(instance);
// 设置自动保存定时器 // 设置自动保存定时器(已经是防抖效果:每次重置定时器)
instance.autoSaveTimer.set(() => { instance.autoSaveTimer.set(() => {
saveEditorContent(documentId); saveEditorContent(documentId);
}, getAutoSaveDelay()); }, getAutoSaveDelay());
@@ -370,7 +472,8 @@ export const useEditorStore = defineStore('editor', () => {
// 加载编辑器 // 加载编辑器
const loadEditor = async (documentId: number, content: string) => { const loadEditor = async (documentId: number, content: string) => {
// 设置加载状态 // 修复:使用计数器精确管理加载状态
loadingOperations.value++;
isLoading.value = true; isLoading.value = true;
// 开始新的操作 // 开始新的操作
@@ -419,6 +522,9 @@ export const useEditorStore = defineStore('editor', () => {
instance.isDirty = false; instance.isDirty = false;
// 清理语法树缓存,因为内容已更新 // 清理语法树缓存,因为内容已更新
instance.syntaxTreeCache = null; instance.syntaxTreeCache = null;
// 修复:内容变了,清空光标位置,避免越界
instance.editorState = undefined;
delete documentStore.documentStates[documentId];
} }
} }
@@ -440,15 +546,20 @@ export const useEditorStore = defineStore('editor', () => {
// 完成操作 // 完成操作
operationManager.completeOperation(operationId); operationManager.completeOperation(operationId);
// 延迟一段时间后再取消加载状态 // 修复:使用计数器精确管理加载状态,避免快速切换时状态不准确
loadingOperations.value--;
// 延迟一段时间后再取消加载状态,但要确保所有操作都完成了
setTimeout(() => { setTimeout(() => {
isLoading.value = false; if (loadingOperations.value <= 0) {
loadingOperations.value = 0;
isLoading.value = false;
}
}, EDITOR_CONFIG.LOADING_DELAY); }, EDITOR_CONFIG.LOADING_DELAY);
} }
}; };
// 移除编辑器 // 移除编辑器
const removeEditor = (documentId: number) => { const removeEditor = async (documentId: number) => {
const instance = editorCache.get(documentId); const instance = editorCache.get(documentId);
if (instance) { if (instance) {
try { try {
@@ -457,6 +568,20 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations(); operationManager.cancelAllOperations();
} }
// 修复:移除前先保存内容(如果有未保存的修改)
if (instance.isDirty) {
await saveEditorContent(documentId);
}
// 保存光标位置
if (instance.view && instance.view.state) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 保存到 documentStore 用于持久化
documentStore.documentStates[documentId] = currentState;
}
// 清除自动保存定时器 // 清除自动保存定时器
instance.autoSaveTimer.clear(); instance.autoSaveTimer.clear();
@@ -538,6 +663,16 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations(); operationManager.cancelAllOperations();
editorCache.clear((_documentId, instance) => { editorCache.clear((_documentId, instance) => {
// 修复:清空前只保存光标位置
if (instance.view) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 同时保存到实例和 documentStore
instance.editorState = currentState;
documentStore.documentStates[instance.documentId] = currentState;
}
// 清除自动保存定时器 // 清除自动保存定时器
instance.autoSaveTimer.clear(); instance.autoSaveTimer.clear();
@@ -552,6 +687,10 @@ export const useEditorStore = defineStore('editor', () => {
instance.view.destroy(); instance.view.destroy();
}); });
// 清理 panelStore 状态(导航离开编辑器页面时)
const panelStore = usePanelStore();
panelStore.reset();
currentEditor.value = null; currentEditor.value = null;
}; };
@@ -579,12 +718,24 @@ export const useEditorStore = defineStore('editor', () => {
}; };
// 监听文档切换 // 监听文档切换
watch(() => documentStore.currentDocument, async (newDoc) => { watch(() => documentStore.currentDocument, async (newDoc, oldDoc) => {
if (newDoc && containerElement.value) { if (newDoc && containerElement.value) {
// 使用 nextTick 确保DOM更新完成后再加载编辑器 // 修复:在切换到新文档前,只保存旧文档的光标位置
await nextTick(() => { if (oldDoc && oldDoc.id !== newDoc.id && currentEditor.value) {
loadEditor(newDoc.id, newDoc.content); const oldInstance = editorCache.get(oldDoc.id);
}); if (oldInstance) {
const currentState: EditorViewState = {
cursorPos: currentEditor.value.state.selection.main.head
};
// 同时保存到实例和 documentStore
oldInstance.editorState = currentState;
documentStore.documentStates[oldDoc.id] = currentState;
}
}
// 等待 DOM 更新完成,再加载新文档的编辑器
await nextTick();
loadEditor(newDoc.id, newDoc.content);
} }
}); });

View 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
};
});

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, readonly, ref, shallowRef, onScopeDispose } from 'vue'; import { computed, readonly, ref, onScopeDispose } from 'vue';
import { CheckForUpdates, ApplyUpdate, RestartApplication } from '@/../bindings/voidraft/internal/services/selfupdateservice'; import { CheckForUpdates, ApplyUpdate, RestartApplication } from '@/../bindings/voidraft/internal/services/selfupdateservice';
import { SelfUpdateResult } from '@/../bindings/voidraft/internal/services/models'; import { SelfUpdateResult } from '@/../bindings/voidraft/internal/services/models';
import { useConfigStore } from './configStore'; import { useConfigStore } from './configStore';

View File

@@ -55,9 +55,11 @@ onBeforeUnmount(() => {
<template> <template>
<div class="editor-container"> <div class="editor-container">
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
<div ref="editorElement" class="editor"></div> <div ref="editorElement" class="editor"></div>
<Toolbar/> <Toolbar/>
<transition name="loading-fade">
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
</transition>
</div> </div>
</template> </template>
@@ -85,4 +87,15 @@ onBeforeUnmount(() => {
:deep(.cm-scroller) { :deep(.cm-scroller) {
overflow: auto; overflow: auto;
} }
// 加载动画过渡效果
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.3s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -1,5 +1,4 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'; import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { useDocumentStore } from '@/stores/documentStore';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
/** /**
@@ -8,7 +7,6 @@ import { useEditorStore } from '@/stores/editorStore';
export function createContentChangePlugin() { export function createContentChangePlugin() {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class ContentChangePlugin { class ContentChangePlugin {
private documentStore = useDocumentStore();
private editorStore = useEditorStore(); private editorStore = useEditorStore();
private lastContent = ''; private lastContent = '';
@@ -24,11 +22,8 @@ export function createContentChangePlugin() {
this.lastContent = newContent; this.lastContent = newContent;
// 通知编辑器管理器内容已变化 this.editorStore.onContentChange();
const currentDocId = this.documentStore.currentDocumentId;
if (currentDocId) {
this.editorStore.onContentChange(currentDocId);
}
} }
destroy() { destroy() {

View File

@@ -0,0 +1,151 @@
/**
* 光标保护扩展
* 防止光标通过方向键移动到分隔符区域
*/
import { EditorView } from '@codemirror/view';
import { EditorSelection } from '@codemirror/state';
import { blockState } from './state';
import { Block } from './types';
/**
* 二分查找:找到包含指定位置的块
* blocks 数组按位置排序,使用二分查找 O(log n)
*/
function findBlockAtPos(blocks: Block[], pos: number): Block | null {
let left = 0;
let right = blocks.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const block = blocks[mid];
if (pos < block.range.from) {
// 位置在当前块之前
right = mid - 1;
} else if (pos > block.range.to) {
// 位置在当前块之后
left = mid + 1;
} else {
// 位置在当前块范围内
return block;
}
}
return null;
}
/**
* 检查位置是否在分隔符区域内
*/
function isInDelimiter(view: EditorView, pos: number): boolean {
try {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) return false;
const block = findBlockAtPos(blocks, pos);
if (!block) return false;
// 检查是否在该块的分隔符区域内
return pos >= block.delimiter.from && pos < block.delimiter.to;
} catch {
return false;
}
}
/**
* 调整光标位置,跳过分隔符区域
*/
function adjustPosition(view: EditorView, pos: number, forward: boolean): number {
try {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) return pos;
const block = findBlockAtPos(blocks, pos);
if (!block) return pos;
// 如果位置在分隔符内
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
// 向前移动:跳到该块内容的开始
// 向后移动:跳到前一个块的内容末尾
if (forward) {
return block.content.from;
} else {
// 找到前一个块的索引
const blockIndex = blocks.indexOf(block);
if (blockIndex > 0) {
const prevBlock = blocks[blockIndex - 1];
return prevBlock.content.to;
}
return block.delimiter.from;
}
}
return pos;
} catch {
return pos;
}
}
/**
* 光标保护扩展
* 拦截方向键移动,防止光标进入分隔符区域
*/
export function createCursorProtection() {
return EditorView.domEventHandlers({
keydown(event, view) {
// 只处理方向键
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
return false;
}
// 获取当前光标位置
const selection = view.state.selection.main;
const currentPos = selection.head;
// 计算目标位置
let targetPos = currentPos;
if (event.key === 'ArrowLeft') {
targetPos = Math.max(0, currentPos - 1);
} else if (event.key === 'ArrowRight') {
targetPos = Math.min(view.state.doc.length, currentPos + 1);
} else if (event.key === 'ArrowUp') {
const line = view.state.doc.lineAt(currentPos);
if (line.number > 1) {
const prevLine = view.state.doc.line(line.number - 1);
const col = currentPos - line.from;
targetPos = Math.min(prevLine.from + col, prevLine.to);
}
} else if (event.key === 'ArrowDown') {
const line = view.state.doc.lineAt(currentPos);
if (line.number < view.state.doc.lines) {
const nextLine = view.state.doc.line(line.number + 1);
const col = currentPos - line.from;
targetPos = Math.min(nextLine.from + col, nextLine.to);
}
}
// 检查目标位置是否在分隔符内
if (isInDelimiter(view, targetPos)) {
// 调整位置
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
const adjustedPos = adjustPosition(view, targetPos, forward);
// 移动光标到调整后的位置
view.dispatch({
selection: EditorSelection.cursor(adjustedPos),
scrollIntoView: true,
userEvent: 'select'
});
// 阻止默认行为
event.preventDefault();
return true;
}
return false;
}
});
}

View File

@@ -25,6 +25,7 @@ import {getCodeBlockLanguageExtension} from './lang-parser';
import {createLanguageDetection} from './lang-detect'; import {createLanguageDetection} from './lang-detect';
import {SupportedLanguage} from './types'; import {SupportedLanguage} from './types';
import {getMathBlockExtensions} from './mathBlock'; import {getMathBlockExtensions} from './mathBlock';
import {createCursorProtection} from './cursorProtection';
/** /**
* 代码块扩展配置选项 * 代码块扩展配置选项
@@ -108,6 +109,9 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
showBackground showBackground
}), }),
// 光标保护(防止方向键移动到分隔符上)
createCursorProtection(),
// 块选择功能 // 块选择功能
...getBlockSelectExtensions(), ...getBlockSelectExtensions(),
@@ -129,6 +133,12 @@ export {
type CreateBlockOptions, type CreateBlockOptions,
} from './types'; } from './types';
// 导出解析器函数
export {
getActiveBlock,
getBlockFromPos
} from './parser';
// 状态管理 // 状态管理
export { export {
blockState, blockState,
@@ -207,6 +217,11 @@ export {
getMathBlockExtensions getMathBlockExtensions
} from './mathBlock'; } from './mathBlock';
// 光标保护功能
export {
createCursorProtection
} from './cursorProtection';
/** /**
* 默认导出 * 默认导出
*/ */

View File

@@ -18,7 +18,7 @@ BlockLanguage {
"go" | "clj" | "ex" | "erl" | "js" | "ts" | "swift" | "kt" | "groovy" | "go" | "clj" | "ex" | "erl" | "js" | "ts" | "swift" | "kt" | "groovy" |
"ps1" | "dart" | "scala" | "math" | "dockerfile" | "lua" | "vue" | "lezer" | "ps1" | "dart" | "scala" | "math" | "dockerfile" | "lua" | "vue" | "lezer" |
"liquid" | "wast" | "sass" | "less" | "angular" | "svelte" | "liquid" | "wast" | "sass" | "less" | "angular" | "svelte" |
"http" "http" | "mermaid"
} }
@tokens { @tokens {

View File

@@ -24,7 +24,7 @@ import {lessLanguage} from "@codemirror/lang-less";
import {angularLanguage} from "@codemirror/lang-angular"; import {angularLanguage} from "@codemirror/lang-angular";
import { svelteLanguage } from "@replit/codemirror-lang-svelte"; import { svelteLanguage } from "@replit/codemirror-lang-svelte";
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language"; import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
import { mermaidLanguage } from '@/views/editor/language/mermaid';
import {StreamLanguage} from "@codemirror/language"; import {StreamLanguage} from "@codemirror/language";
import {ruby} from "@codemirror/legacy-modes/mode/ruby"; import {ruby} from "@codemirror/legacy-modes/mode/ruby";
import {shell} from "@codemirror/legacy-modes/mode/shell"; import {shell} from "@codemirror/legacy-modes/mode/shell";
@@ -226,6 +226,7 @@ export const LANGUAGES: LanguageInfo[] = [
} }
}), }),
new LanguageInfo("http", "Http", httpLanguage.parser, ["http"]), new LanguageInfo("http", "Http", httpLanguage.parser, ["http"]),
new LanguageInfo("mermaid", "Mermaid", mermaidLanguage.parser, ["mermaid"]),
]; ];

View File

@@ -3,14 +3,14 @@ import {LRParser} from "@lezer/lr"
import {blockContent} from "./external-tokens.js" import {blockContent} from "./external-tokens.js"
export const parser = LRParser.deserialize({ export const parser = LRParser.deserialize({
version: 14, version: 14,
states: "!jQQOQOOOVOQO'#C`O#xOPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#}OSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO$VOSO1G.fOOOP7+$Q7+$Q", states: "!jQQOQOOOVOQO'#C`O#{OPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO$QOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO$YOSO1G.fOOOP7+$Q7+$Q",
stateData: "$[~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO!STO~OPVO~OUYO!TXO~O!TZO~O", stateData: "$_~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO!STO!TTO~OPVO~OUYO!UXO~O!UZO~O",
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS", goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto", nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
maxTerm: 51, maxTerm: 52,
skippedNodes: [0], skippedNodes: [0],
repeatNodeCount: 1, repeatNodeCount: 1,
tokenData: "3u~RdYZ!a}!O!z#T#U#V#V#W$Q#W#X%R#X#Y&t#Z#['_#[#]([#^#_)R#_#`*Q#`#a*]#a#b,Y#d#e,y#f#g-r#g#h.V#h#i0|#j#k2R#k#l2d#l#m2{#m#n3^R!fP!TQ%&x%&y!iP!lP%&x%&y!oP!rP%&x%&y!uP!zOXP~!}P#T#U#Q~#VOU~~#YP#b#c#]~#`P#Z#[#c~#fP#i#j#i~#lP#`#a#o~#rP#T#U#u~#xP#f#g#{~$QO!Q~~$TR#`#a$^#d#e$i#g#h$t~$aP#^#_$d~$iOl~~$lP#d#e$o~$tOd~~$yPf~#g#h$|~%ROb~~%UQ#T#U%[#c#d%m~%_P#f#g%b~%eP#h#i%h~%mOu~~%pP#V#W%s~%vP#_#`%y~%|P#X#Y&P~&SP#f#g&V~&YP#Y#Z&]~&`P#]#^&c~&fP#`#a&i~&lP#X#Y&o~&tOx~~&wQ#f#g&}#l#m'Y~'QP#`#a'T~'YOn~~'_Om~~'bQ#c#d'h#f#g'm~'mOk~~'pP#c#d's~'vP#c#d'y~'|P#j#k(P~(SP#m#n(V~([Os~~(_P#h#i(b~(eQ#a#b(k#h#i(v~(nP#`#a(q~(vO]~~(yP#d#e(|~)RO!S~~)UQ#T#U)[#g#h)m~)_P#j#k)b~)eP#T#U)h~)mO`~~)rPo~#c#d)u~)xP#b#c){~*QOZ~~*TP#h#i*W~*]Or~~*`R#X#Y*i#]#^+`#i#j+}~*lQ#g#h*r#n#o*}~*uP#g#h*x~*}O!P~~+QP#X#Y+T~+WP#f#g+Z~+`O{~~+cP#e#f+f~+iP#i#j+l~+oP#]#^+r~+uP#W#X+x~+}O|~~,QP#T#U,T~,YOy~~,]Q#T#U,c#W#X,t~,fP#h#i,i~,lP#[#],o~,tOw~~,yO_~~,|R#[#]-V#g#h-b#m#n-m~-YP#d#e-]~-bOa~~-eP!R!S-h~-mOt~~-rO[~~-uQ#U#V-{#g#h.Q~.QOg~~.VOe~~.YU#T#U.l#V#W.}#[#]/f#e#f/k#j#k/v#k#l0e~.oP#g#h.r~.uP#g#h.x~.}O!O~~/QP#T#U/T~/WP#`#a/Z~/^P#T#U/a~/fOv~~/kOh~~/nP#`#a/q~/vO^~~/yP#X#Y/|~0PP#`#a0S~0VP#h#i0Y~0]P#X#Y0`~0eO!R~~0hP#]#^0k~0nP#Y#Z0q~0tP#h#i0w~0|Oq~~1PR#X#Y1Y#c#d1k#g#h1|~1]P#l#m1`~1cP#h#i1f~1kOY~~1nP#a#b1q~1tP#`#a1w~1|Oj~~2ROp~~2UP#i#j2X~2[P#X#Y2_~2dOz~~2gP#T#U2j~2mP#g#h2p~2sP#h#i2v~2{O}~~3OP#a#b3R~3UP#`#a3X~3^Oc~~3aP#T#U3d~3gP#a#b3j~3mP#`#a3p~3uOi~", tokenData: "4m~RdYZ!a}!O!z#T#U#V#V#W$Q#W#X%R#X#Y&t#Z#['_#[#]([#^#_)R#_#`*Q#`#a*]#a#b,Y#d#e-q#f#g.j#g#h.}#h#i1t#j#k2y#k#l3[#l#m3s#m#n4UR!fP!UQ%&x%&y!iP!lP%&x%&y!oP!rP%&x%&y!uP!zOXP~!}P#T#U#Q~#VOU~~#YP#b#c#]~#`P#Z#[#c~#fP#i#j#i~#lP#`#a#o~#rP#T#U#u~#xP#f#g#{~$QO!Q~~$TR#`#a$^#d#e$i#g#h$t~$aP#^#_$d~$iOl~~$lP#d#e$o~$tOd~~$yPf~#g#h$|~%ROb~~%UQ#T#U%[#c#d%m~%_P#f#g%b~%eP#h#i%h~%mOu~~%pP#V#W%s~%vP#_#`%y~%|P#X#Y&P~&SP#f#g&V~&YP#Y#Z&]~&`P#]#^&c~&fP#`#a&i~&lP#X#Y&o~&tOx~~&wQ#f#g&}#l#m'Y~'QP#`#a'T~'YOn~~'_Om~~'bQ#c#d'h#f#g'm~'mOk~~'pP#c#d's~'vP#c#d'y~'|P#j#k(P~(SP#m#n(V~([Os~~(_P#h#i(b~(eQ#a#b(k#h#i(v~(nP#`#a(q~(vO]~~(yP#d#e(|~)RO!S~~)UQ#T#U)[#g#h)m~)_P#j#k)b~)eP#T#U)h~)mO`~~)rPo~#c#d)u~)xP#b#c){~*QOZ~~*TP#h#i*W~*]Or~~*`R#X#Y*i#]#^+`#i#j+}~*lQ#g#h*r#n#o*}~*uP#g#h*x~*}O!P~~+QP#X#Y+T~+WP#f#g+Z~+`O{~~+cP#e#f+f~+iP#i#j+l~+oP#]#^+r~+uP#W#X+x~+}O|~~,QP#T#U,T~,YOy~~,]R#T#U,f#W#X,w#X#Y,|~,iP#h#i,l~,oP#[#],r~,wOw~~,|O_~~-PP#f#g-S~-VP#a#b-Y~-]P#T#U-`~-cP#]#^-f~-iP#W#X-l~-qO!T~~-tR#[#]-}#g#h.Y#m#n.e~.QP#d#e.T~.YOa~~.]P!R!S.`~.eOt~~.jO[~~.mQ#U#V.s#g#h.x~.xOg~~.}Oe~~/QU#T#U/d#V#W/u#[#]0^#e#f0c#j#k0n#k#l1]~/gP#g#h/j~/mP#g#h/p~/uO!O~~/xP#T#U/{~0OP#`#a0R~0UP#T#U0X~0^Ov~~0cOh~~0fP#`#a0i~0nO^~~0qP#X#Y0t~0wP#`#a0z~0}P#h#i1Q~1TP#X#Y1W~1]O!R~~1`P#]#^1c~1fP#Y#Z1i~1lP#h#i1o~1tOq~~1wR#X#Y2Q#c#d2c#g#h2t~2TP#l#m2W~2ZP#h#i2^~2cOY~~2fP#a#b2i~2lP#`#a2o~2tOj~~2yOp~~2|P#i#j3P~3SP#X#Y3V~3[Oz~~3_P#T#U3b~3eP#g#h3h~3kP#h#i3n~3sO}~~3vP#a#b3y~3|P#`#a4P~4UOc~~4XP#T#U4[~4_P#a#b4b~4eP#`#a4h~4mOi~",
tokenizers: [blockContent, 0, 1], tokenizers: [blockContent, 0, 1],
topRules: {"Document":[0,2]}, topRules: {"Document":[0,2]},
tokenPrec: 0 tokenPrec: 0

View File

@@ -4,7 +4,7 @@
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language'; import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage, Document } from './lang-parser/parser.terms.js'; import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
import { import {
SupportedLanguage, SupportedLanguage,
DELIMITER_REGEX, DELIMITER_REGEX,

View File

@@ -66,6 +66,7 @@ export type SupportedLanguage =
| 'angular' | 'angular'
| 'svelte' | 'svelte'
| 'http' // HTTP Client | 'http' // HTTP Client
| 'mermaid'
/** /**
* 创建块的选项 * 创建块的选项
@@ -85,7 +86,6 @@ export interface EditorOptions {
} }
// 分隔符格式常量 // 分隔符格式常量
export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm; export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm;
export const DELIMITER_PREFIX = '\n∞∞∞'; export const DELIMITER_PREFIX = '\n∞∞∞';

View File

@@ -26,11 +26,24 @@ interface CachedHttpRequest {
request: HttpRequest; // 完整的解析结果 request: HttpRequest; // 完整的解析结果
} }
/**
* 快速检查节点是否包含错误
* 使用游标直接遍历子节点,避免创建迭代器的开销
*/
function hasErrorNode(node: any): boolean {
let child = node.firstChild;
while (child) {
if (child.name === '⚠') return true;
// 递归检查子节点
if (child.firstChild && hasErrorNode(child)) return true;
child = child.nextSibling;
}
return false;
}
/** /**
* 预解析所有 HTTP 块中的请求 * 预解析所有 HTTP 块中的请求
* 只在文档改变时调用,结果缓存在 StateField 中 * 只在文档改变时调用,结果缓存在 StateField 中
*
* 优化:一次遍历完成验证和解析,避免重复工作
*/ */
function parseHttpRequests(state: any): Map<number, CachedHttpRequest> { function parseHttpRequests(state: any): Map<number, CachedHttpRequest> {
const requestsMap = new Map<number, CachedHttpRequest>(); const requestsMap = new Map<number, CachedHttpRequest>();
@@ -38,28 +51,22 @@ function parseHttpRequests(state: any): Map<number, CachedHttpRequest> {
if (!blocks) return requestsMap; if (!blocks) return requestsMap;
// 提前过滤出所有 HTTP 块
const httpBlocks = blocks.filter((block: any) => block.language.name === 'http');
if (httpBlocks.length === 0) return requestsMap;
const tree = syntaxTree(state); const tree = syntaxTree(state);
// 只遍历 HTTP 块 // 只遍历 HTTP 块
for (const block of blocks) { for (const block of httpBlocks) {
if (block.language.name !== 'http') continue;
// 在块范围内查找所有 RequestStatement // 在块范围内查找所有 RequestStatement
tree.iterate({ tree.iterate({
from: block.content.from, from: block.content.from,
to: block.content.to, to: block.content.to,
enter: (node) => { enter: (node) => {
if (node.name === NODE_TYPES.REQUEST_STATEMENT) { if (node.name === NODE_TYPES.REQUEST_STATEMENT) {
// 检查是否包含错误节点 // 使用快速错误检查
let hasError = false; if (hasErrorNode(node.node)) return;
node.node.cursor().iterate((nodeRef) => {
if (nodeRef.name === '⚠') {
hasError = true;
return false;
}
});
if (hasError) return;
// 直接解析请求 // 直接解析请求
const request = parseHttpRequest(state, node.from,{from: block.content.from, to: block.content.to}); const request = parseHttpRequest(state, node.from,{from: block.content.from, to: block.content.to});

View File

@@ -0,0 +1,62 @@
/**
* Markdown 预览扩展主入口
*/
import { EditorView } from "@codemirror/view";
import { useThemeStore } from "@/stores/themeStore";
import { usePanelStore } from "@/stores/panelStore";
import { useDocumentStore } from "@/stores/documentStore";
import { getActiveNoteBlock } from "../codeblock/state";
import { createMarkdownPreviewTheme } from "./styles";
import { previewPanelState, previewPanelPlugin, togglePreview, closePreviewWithAnimation } from "./state";
/**
* 切换预览面板的命令
*/
export function toggleMarkdownPreview(view: EditorView): boolean {
const panelStore = usePanelStore();
const documentStore = useDocumentStore();
const currentState = view.state.field(previewPanelState, false);
const activeBlock = getActiveNoteBlock(view.state as any);
// 如果当前没有激活的 Markdown 块,不执行操作
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
return false;
}
// 获取当前文档ID
const currentDocumentId = documentStore.currentDocumentId;
if (currentDocumentId === null) {
return false;
}
// 如果预览面板已打开(无论预览的是不是当前块),关闭预览
if (panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing) {
// 使用带动画的关闭函数
closePreviewWithAnimation(view);
} else {
// 否则,打开当前块的预览
view.dispatch({
effects: togglePreview.of({
documentId: currentDocumentId,
blockFrom: activeBlock.content.from,
blockTo: activeBlock.content.to
})
});
// 注意store 状态由 ViewPlugin 在面板创建成功后更新
}
return true;
}
/**
* 导出 Markdown 预览扩展
*/
export function markdownPreviewExtension() {
const themeStore = useThemeStore();
const colors = themeStore.currentColors;
const theme = colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
return [previewPanelState, previewPanelPlugin, theme];
}

View File

@@ -0,0 +1,117 @@
/**
* Markdown 渲染器配置和自定义插件
*/
import MarkdownIt from 'markdown-it';
import {tasklist} from "@mdit/plugin-tasklist";
import {katex} from "@mdit/plugin-katex";
import markPlugin from "@/common/markdown-it/plugins/markdown-it-mark";
import hljs from 'highlight.js';
import 'highlight.js/styles/default.css';
import {full as emoji} from '@/common/markdown-it/plugins/markdown-it-emojis/'
import footnote_plugin from "@/common/markdown-it/plugins/markdown-it-footnote"
import sup_plugin from "@/common/markdown-it/plugins/markdown-it-sup"
import ins_plugin from "@/common/markdown-it/plugins/markdown-it-ins"
import deflist_plugin from "@/common/markdown-it/plugins/markdown-it-deflist"
import abbr_plugin from "@/common/markdown-it/plugins/markdown-it-abbr"
import sub_plugin from "@/common/markdown-it/plugins/markdown-it-sub"
import {MermaidIt} from "@/common/markdown-it/plugins/markdown-it-mermaid"
import {useThemeStore} from '@/stores/themeStore'
/**
* 自定义链接插件:使用 data-href 替代 href配合事件委托实现自定义跳转
*/
export function customLinkPlugin(md: MarkdownIt) {
// 保存默认的 link_open 渲染器
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// 重写 link_open 渲染器
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
// 获取 href 属性
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
// 添加 data-href 属性保存原始链接
token.attrPush(['data-href', href]);
// 添加 class 用于样式
const classIndex = token.attrIndex('class');
if (classIndex < 0) {
token.attrPush(['class', 'markdown-link']);
} else {
token.attrs![classIndex][1] += ' markdown-link';
}
// 移除 href 属性,防止默认跳转
token.attrs!.splice(hrefIndex, 1);
}
return defaultRender(tokens, idx, options, env, self);
};
}
/**
* 创建 Markdown-It 实例
*/
export function createMarkdownRenderer(): MarkdownIt {
const themeStore = useThemeStore();
const mermaidTheme = themeStore.isDarkMode ? "dark" : "default";
return new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
langPrefix: "language-",
highlight: (code, lang) => {
// 对于大代码块(>1000行跳过高亮以提升性能
if (code.length > 50000) {
return `<pre><code>${code}</code></pre>`;
}
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, {language: lang, ignoreIllegals: true}).value;
} catch (error) {
console.warn(`Failed to highlight code block with language: ${lang}`, error);
return code;
}
}
// 对于中等大小的代码块(>5000字符跳过自动检测
if (code.length > 5000) {
return code;
}
// 小代码块才使用自动检测
try {
return hljs.highlightAuto(code).value;
} catch (error) {
console.warn('Failed to auto-highlight code block', error);
return code;
}
}
})
.use(tasklist, {
disabled: false,
})
.use(customLinkPlugin)
.use(markPlugin)
.use(emoji)
.use(footnote_plugin)
.use(sup_plugin)
.use(ins_plugin)
.use(deflist_plugin)
.use(abbr_plugin)
.use(sub_plugin)
.use(katex)
.use(MermaidIt, {
theme: mermaidTheme
});
}

View File

@@ -0,0 +1,367 @@
/**
* Markdown 预览面板 UI 组件
*/
import {EditorView, Panel, ViewUpdate} from "@codemirror/view";
import MarkdownIt from 'markdown-it';
import * as runtime from "@wailsio/runtime";
import {previewPanelState} from "./state";
import {createMarkdownRenderer} from "./markdownRenderer";
import {updateMermaidTheme} from "@/common/markdown-it/plugins/markdown-it-mermaid";
import {useThemeStore} from "@/stores/themeStore";
import {usePanelStore} from "@/stores/panelStore";
import {watch} from "vue";
import {createDebounce} from "@/common/utils/debounce";
import {morphHTML} from "@/common/utils/domDiff";
/**
* Markdown 预览面板类
*/
export class MarkdownPreviewPanel {
private md: MarkdownIt;
private readonly dom: HTMLDivElement;
private readonly resizeHandle: HTMLDivElement;
private readonly content: HTMLDivElement;
private view: EditorView;
private themeUnwatch?: () => void;
private lastRenderedContent: string = "";
private readonly debouncedUpdate: ReturnType<typeof createDebounce>;
private isDestroyed: boolean = false; // 标记面板是否已销毁
constructor(view: EditorView) {
this.view = view;
this.md = createMarkdownRenderer();
// 创建防抖更新函数
this.debouncedUpdate = createDebounce(() => {
this.updateContentInternal();
}, { delay: 500 });
// 监听主题变化
const themeStore = useThemeStore();
this.themeUnwatch = watch(() => themeStore.isDarkMode, (isDark) => {
const newTheme = isDark ? "dark" : "default";
updateMermaidTheme(newTheme);
this.lastRenderedContent = ""; // 清空缓存,强制重新渲染
});
// 创建 DOM 结构
this.dom = document.createElement("div");
this.dom.className = "cm-markdown-preview-panel";
this.resizeHandle = document.createElement("div");
this.resizeHandle.className = "cm-preview-resize-handle";
this.content = document.createElement("div");
this.content.className = "cm-preview-content";
this.dom.appendChild(this.resizeHandle);
this.dom.appendChild(this.content);
// 设置默认高度为编辑器高度的一半
const defaultHeight = Math.floor(this.view.dom.clientHeight / 2);
this.dom.style.height = `${defaultHeight}px`;
// 初始化拖动功能
this.initResize();
// 初始化链接点击处理
this.initLinkHandler();
// 初始渲染
this.updateContentInternal();
}
/**
* 初始化链接点击处理(事件委托)
*/
private initLinkHandler(): void {
this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 查找最近的 <a> 标签
let linkElement = target;
while (linkElement && linkElement !== this.content) {
if (linkElement.tagName === 'A') {
const anchor = linkElement as HTMLAnchorElement;
const href = anchor.getAttribute('href');
// 处理脚注内部锚点链接
if (href && href.startsWith('#')) {
e.preventDefault();
// 在预览面板内查找目标元素
const targetId = href.substring(1);
// 使用 getElementById 而不是 querySelector因为 ID 可能包含特殊字符(如冒号)
const targetElement = document.getElementById(targetId);
if (targetElement && this.content.contains(targetElement)) {
// 平滑滚动到目标元素
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return;
}
// 处理带 data-href 的外部链接
if (anchor.hasAttribute('data-href')) {
e.preventDefault();
const url = anchor.getAttribute('data-href');
if (url && this.isValidUrl(url)) {
runtime.Browser.OpenURL(url);
}
return;
}
// 处理其他链接
if (href && !href.startsWith('#')) {
e.preventDefault();
// 只有有效的 URLhttp/https/mailto/file 等)才用浏览器打开
if (this.isValidUrl(href)) {
runtime.Browser.OpenURL(href);
} else {
// 相对路径或无效链接,显示提示
console.warn('Invalid or relative link in preview:', href);
}
return;
}
}
linkElement = linkElement.parentElement as HTMLElement;
}
});
}
/**
* 检查是否是有效的 URL包含协议
*/
private isValidUrl(url: string): boolean {
try {
// 检查是否包含协议
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
const parsedUrl = new URL(url);
// 允许的协议列表
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
return allowedProtocols.includes(parsedUrl.protocol);
}
return false;
} catch {
return false;
}
}
/**
* 初始化拖动调整高度功能
*/
private initResize(): void {
let startY = 0;
let startHeight = 0;
const onMouseMove = (e: MouseEvent) => {
const delta = startY - e.clientY;
const maxHeight = this.getMaxHeight();
const newHeight = Math.max(100, Math.min(maxHeight, startHeight + delta));
this.dom.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
this.resizeHandle.classList.remove("dragging");
// 恢复 body 样式
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
this.resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
startY = e.clientY;
startHeight = this.dom.offsetHeight;
this.resizeHandle.classList.add("dragging");
// 设置 body 样式,防止拖动时光标闪烁
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
/**
* 动态计算最大高度(编辑器高度)
*/
private getMaxHeight(): number {
return this.view.dom.clientHeight;
}
/**
* 内部更新预览内容(带缓存 + DOM Diff 优化)
*/
private updateContentInternal(): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const state = this.view.state;
const currentPreviewState = state.field(previewPanelState, false);
if (!currentPreviewState) {
return;
}
const blockContent = state.doc.sliceString(
currentPreviewState.blockFrom,
currentPreviewState.blockTo
);
if (!blockContent || blockContent.trim().length === 0) {
return;
}
// 缓存检查:如果内容没变,不重新渲染
if (blockContent === this.lastRenderedContent) {
return;
}
// 对于大内容,使用异步渲染避免阻塞主线程
if (blockContent.length > 1000) {
this.renderLargeContentAsync(blockContent);
} else {
// 小内容使用 DOM Diff 优化渲染
this.renderWithDiff(blockContent);
}
} catch (error) {
console.warn("Error updating preview content:", error);
}
}
/**
* 使用 DOM Diff 渲染内容(保留未变化的节点)
*/
private renderWithDiff(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const newHtml = this.md.render(content);
// 如果是首次渲染或内容为空,直接设置 innerHTML
if (!this.lastRenderedContent || this.content.children.length === 0) {
this.content.innerHTML = newHtml;
} else {
// 使用 DOM Diff 增量更新
morphHTML(this.content, newHtml);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering with diff:", error);
// 降级到直接设置 innerHTML
if (!this.isDestroyed) {
this.content.innerHTML = this.md.render(content);
this.lastRenderedContent = content;
}
}
}
/**
* 异步渲染大内容(使用 DOM Diff 优化)
*/
private renderLargeContentAsync(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
// 如果是首次渲染,显示加载状态
if (!this.lastRenderedContent) {
this.content.innerHTML = '<div class="markdown-loading">Rendering...</div>';
}
// 使用 requestIdleCallback 在浏览器空闲时渲染
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
callback(() => {
// 再次检查是否已销毁(异步回调时可能已经关闭)
if (this.isDestroyed) {
return;
}
try {
const html = this.md.render(content);
// 如果是首次渲染或之前内容为空,直接设置
if (!this.lastRenderedContent || this.content.children.length === 0) {
// 使用 DocumentFragment 减少 DOM 操作
const fragment = document.createRange().createContextualFragment(html);
this.content.innerHTML = '';
this.content.appendChild(fragment);
} else {
// 使用 DOM Diff 增量更新(保留滚动位置和未变化的节点)
morphHTML(this.content, html);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering large content:", error);
if (!this.isDestroyed) {
this.content.innerHTML = '<div class="markdown-error">Render failed</div>';
}
}
});
}
/**
* 响应编辑器更新
*/
public update(update: ViewUpdate): void {
if (update.docChanged) {
// 文档改变时使用防抖更新
this.debouncedUpdate.debouncedFn();
} else if (update.selectionSet) {
// 光标移动时不触发更新
// 如果需要根据光标位置更新,可以在这里处理
}
}
/**
* 清理资源
*/
public destroy(): void {
// 标记为已销毁,防止异步回调继续执行
this.isDestroyed = true;
// 清理防抖
if (this.debouncedUpdate) {
this.debouncedUpdate.cancel();
}
// 清空缓存
this.lastRenderedContent = "";
}
/**
* 获取 CodeMirror Panel 对象
*/
public getPanel(): Panel {
return {
top: false,
dom: this.dom,
update: (update: ViewUpdate) => this.update(update),
destroy: () => this.destroy()
};
}
}
/**
* 创建预览面板
*/
export function createPreviewPanel(view: EditorView): Panel {
const panel = new MarkdownPreviewPanel(view);
return panel.getPanel();
}

View File

@@ -0,0 +1,142 @@
/**
* Markdown 预览面板的 CodeMirror 状态管理
*/
import { EditorView, showPanel, ViewUpdate, ViewPlugin } from "@codemirror/view";
import { StateEffect, StateField } from "@codemirror/state";
import { getActiveNoteBlock } from "../codeblock/state";
import { usePanelStore } from "@/stores/panelStore";
import { createPreviewPanel } from "./panel";
import type { PreviewState } from "./types";
/**
* 定义切换预览面板的 Effect
*/
export const togglePreview = StateEffect.define<PreviewState | null>();
/**
* 关闭面板(带动画)
*/
export function closePreviewWithAnimation(view: EditorView): void {
const panelStore = usePanelStore();
// 标记开始关闭
panelStore.startClosingMarkdownPreview();
const panelElement = view.dom.querySelector('.cm-panels.cm-panels-bottom') as HTMLElement;
if (panelElement) {
panelElement.style.animation = 'panelSlideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
// 等待动画完成后再关闭面板
setTimeout(() => {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}, 280);
} else {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}
}
/**
* 定义预览面板的状态字段
*/
export const previewPanelState = StateField.define<PreviewState | null>({
create: () => null,
update(value, tr) {
const panelStore = usePanelStore();
for (let e of tr.effects) {
if (e.is(togglePreview)) {
value = e.value;
}
}
// 如果有预览状态,智能管理预览生命周期
if (value && !value.closing) {
const activeBlock = getActiveNoteBlock(tr.state as any);
// 关键修复:检查预览状态是否属于当前文档
// 如果 panelStore 中没有当前文档的预览状态(说明切换了文档),
// 则不执行关闭逻辑,保持其他文档的预览状态
if (!panelStore.markdownPreview.isOpen) {
// 当前文档没有预览,不处理
return value;
}
// 场景1离开 Markdown 块或无激活块 → 关闭预览
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景2切换到其他块起始位置变化→ 关闭预览
else if (activeBlock.content.from !== value.blockFrom) {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景3还在同一个块内编辑只有结束位置变化→ 更新范围,实时预览
else if (activeBlock.content.to !== value.blockTo) {
// 更新 panelStore 中的预览范围
panelStore.updatePreviewRange(value.blockFrom, activeBlock.content.to);
return {
documentId: value.documentId,
blockFrom: value.blockFrom,
blockTo: activeBlock.content.to,
closing: false
};
}
}
return value;
},
provide: f => showPanel.from(f, state => state ? createPreviewPanel : null)
});
/**
* 创建监听插件
*/
export const previewPanelPlugin = ViewPlugin.fromClass(class {
private lastState: PreviewState | null | undefined = null;
private panelStore = usePanelStore();
constructor(private view: EditorView) {
this.lastState = view.state.field(previewPanelState, false);
this.panelStore.setEditorView(view);
}
update(update: ViewUpdate) {
const currentState = update.state.field(previewPanelState, false);
// 检测到面板打开(从 null 变为有值,且不是 closing
if (currentState && !currentState.closing && !this.lastState) {
// 验证面板 DOM 是否真正创建成功
requestAnimationFrame(() => {
const panelElement = this.view.dom.querySelector('.cm-markdown-preview-panel');
if (panelElement) {
// 面板创建成功,更新 store 状态
this.panelStore.openMarkdownPreview(currentState.blockFrom, currentState.blockTo);
}
});
}
// 检测到状态变为 closing
if (currentState?.closing && !this.lastState?.closing) {
// 触发关闭动画
closePreviewWithAnimation(this.view);
}
this.lastState = currentState;
}
destroy() {
// 不调用 reset(),因为那会清空所有文档的预览状态
// 只清理编辑器视图引用
this.panelStore.setEditorView(null);
}
});

View File

@@ -0,0 +1,356 @@
import { EditorView } from "@codemirror/view";
import type { ThemeColors } from "@/views/editor/theme/types";
/**
* 创建 Markdown 预览面板的主题样式
*/
export function createMarkdownPreviewTheme(colors: ThemeColors) {
// GitHub 官方颜色变量
const isDark = colors.dark;
// GitHub Light 主题颜色
const lightColors = {
fg: {
default: "#1F2328",
muted: "#656d76",
subtle: "#6e7781"
},
border: {
default: "#d0d7de",
muted: "#d8dee4"
},
canvas: {
default: "#ffffff",
subtle: "#f6f8fa"
},
accent: {
fg: "#0969da",
emphasis: "#0969da"
}
};
// GitHub Dark 主题颜色
const darkColors = {
fg: {
default: "#e6edf3",
muted: "#7d8590",
subtle: "#6e7681"
},
border: {
default: "#30363d",
muted: "#21262d"
},
canvas: {
default: "#0d1117",
subtle: "#161b22"
},
accent: {
fg: "#2f81f7",
emphasis: "#2f81f7"
}
};
const ghColors = isDark ? darkColors : lightColors;
return EditorView.theme({
// 面板容器
".cm-markdown-preview-panel": {
position: "relative",
display: "flex",
flexDirection: "column",
overflow: "hidden"
},
// 拖动调整大小的手柄
".cm-preview-resize-handle": {
width: "100%",
height: "3px",
backgroundColor: colors.borderColor,
cursor: "ns-resize",
position: "relative",
flexShrink: 0,
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: colors.selection
},
"&.dragging": {
backgroundColor: colors.selection
}
},
// 内容区域
".cm-preview-content": {
flex: 1,
padding: "45px",
overflow: "auto",
fontSize: "16px",
lineHeight: "1.5",
color: ghColors.fg.default,
wordWrap: "break-word",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'",
boxSizing: "border-box",
// Loading state
"& .markdown-loading, & .markdown-error": {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "200px",
fontSize: "14px",
color: ghColors.fg.muted
},
"& .markdown-error": {
color: "#f85149"
},
// ========== 标题样式 ==========
"& h1, & h2, & h3, & h4, & h5, & h6": {
marginTop: "24px",
marginBottom: "16px",
fontWeight: "600",
lineHeight: "1.25",
color: ghColors.fg.default
},
"& h1": {
fontSize: "2em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h2": {
fontSize: "1.5em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h3": {
fontSize: "1.25em"
},
"& h4": {
fontSize: "1em"
},
"& h5": {
fontSize: "0.875em"
},
"& h6": {
fontSize: "0.85em",
color: ghColors.fg.muted
},
// ========== 段落和文本 ==========
"& p": {
marginTop: "0",
marginBottom: "16px"
},
"& strong": {
fontWeight: "600"
},
"& em": {
fontStyle: "italic"
},
"& del": {
textDecoration: "line-through",
opacity: "0.7"
},
// ========== 列表 ==========
"& ul, & ol": {
paddingLeft: "2em",
marginTop: "0",
marginBottom: "16px"
},
"& ul ul, & ul ol, & ol ol, & ol ul": {
marginTop: "0",
marginBottom: "0"
},
"& li": {
wordWrap: "break-all"
},
"& li > p": {
marginTop: "16px"
},
"& li + li": {
marginTop: "0.25em"
},
// 任务列表
"& .task-list-item": {
listStyleType: "none",
position: "relative",
paddingLeft: "1.5em"
},
"& .task-list-item + .task-list-item": {
marginTop: "3px"
},
"& .task-list-item input[type='checkbox']": {
font: "inherit",
overflow: "visible",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
boxSizing: "border-box",
padding: "0",
margin: "0 0.2em 0.25em -1.6em",
verticalAlign: "middle",
cursor: "pointer"
},
// ========== 代码块 ==========
"& code, & tt": {
fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace",
fontSize: "85%",
padding: "0.2em 0.4em",
margin: "0",
backgroundColor: isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(27, 31, 35, 0.05)",
borderRadius: "3px"
},
"& pre": {
position: "relative",
backgroundColor: isDark ? "#161b22" : "#f6f8fa",
padding: "40px 16px 16px 16px",
borderRadius: "6px",
overflow: "auto",
margin: "16px 0",
fontSize: "85%",
lineHeight: "1.45",
wordWrap: "normal",
// macOS 窗口样式 - 使用伪元素创建顶部栏
"&::before": {
content: '""',
position: "absolute",
top: "0",
left: "0",
right: "0",
height: "28px",
backgroundColor: isDark ? "#1c1c1e" : "#e8e8e8",
borderBottom: `1px solid ${ghColors.border.default}`,
borderRadius: "6px 6px 0 0"
},
// macOS 三个控制按钮
"&::after": {
content: '""',
position: "absolute",
top: "10px",
left: "12px",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: isDark ? "#ec6a5f" : "#ff5f57",
boxShadow: `
18px 0 0 0 ${isDark ? "#f4bf4f" : "#febc2e"},
36px 0 0 0 ${isDark ? "#61c554" : "#28c840"}
`
}
},
"& pre code, & pre tt": {
display: "inline",
maxWidth: "auto",
padding: "0",
margin: "0",
overflow: "visible",
lineHeight: "inherit",
wordWrap: "normal",
backgroundColor: "transparent",
border: "0",
fontSize: "100%",
color: ghColors.fg.default,
wordBreak: "normal",
whiteSpace: "pre"
},
// ========== 引用块 ==========
"& blockquote": {
margin: "16px 0",
padding: "0 1em",
color: isDark ? "#7d8590" : "#6a737d",
borderLeft: isDark ? "0.25em solid #3b434b" : "0.25em solid #dfe2e5"
},
"& blockquote > :first-child": {
marginTop: "0"
},
"& blockquote > :last-child": {
marginBottom: "0"
},
// ========== 分割线 ==========
"& hr": {
height: "0.25em",
padding: "0",
margin: "24px 0",
backgroundColor: isDark ? "#21262d" : "#e1e4e8",
border: "0",
overflow: "hidden",
boxSizing: "content-box"
},
// ========== 表格 ==========
"& table": {
borderSpacing: "0",
borderCollapse: "collapse",
display: "block",
width: "100%",
overflow: "auto",
marginTop: "0",
marginBottom: "16px"
},
"& table tr": {
backgroundColor: isDark ? "#0d1117" : "#ffffff",
borderTop: isDark ? "1px solid #21262d" : "1px solid #c6cbd1"
},
"& table th, & table td": {
padding: "6px 13px",
border: isDark ? "1px solid #30363d" : "1px solid #dfe2e5"
},
"& table th": {
fontWeight: "600"
},
// ========== 链接 ==========
"& a, & .markdown-link": {
color: isDark ? "#58a6ff" : "#0366d6",
textDecoration: "none",
cursor: "pointer",
"&:hover": {
textDecoration: "underline"
}
},
// ========== 图片 ==========
"& img": {
maxWidth: "100%",
height: "auto",
borderRadius: "4px",
margin: "16px 0"
},
// ========== 其他元素 ==========
"& kbd": {
display: "inline-block",
padding: "3px 5px",
fontSize: "11px",
lineHeight: "10px",
color: ghColors.fg.default,
verticalAlign: "middle",
backgroundColor: ghColors.canvas.subtle,
border: `solid 1px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderBottom: `solid 2px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderRadius: "6px",
boxShadow: "inset 0 -1px 0 rgba(175, 184, 193, 0.2)"
},
// 首个子元素去除上边距
"& > *:first-child": {
marginTop: "0 !important"
},
// 最后一个子元素去除下边距
"& > *:last-child": {
marginBottom: "0 !important"
}
}
}, { dark: colors.dark });
}

View File

@@ -0,0 +1,12 @@
/**
* Markdown 预览面板相关类型定义
*/
// 预览面板状态
export interface PreviewState {
documentId: number; // 预览所属的文档ID
blockFrom: number;
blockTo: number;
closing?: boolean; // 标记面板正在关闭
}

View File

@@ -0,0 +1,57 @@
import { buildParserFile } from '@lezer/generator';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const parsersDir = join(__dirname, 'parsers');
const parserTypes = [
'mermaid',
'mindmap',
'pie',
'flowchart',
'sequence',
'journey',
'requirement',
'gantt'
];
console.log('开始构建 Mermaid 语法解析器...\n');
for (const type of parserTypes) {
try {
const grammarPath = join(parsersDir, type, `${type}.grammar`);
const outputPath = join(parsersDir, type, `${type}.parser.grammar.ts`);
console.log(`正在处理: ${type}`);
console.log(` 读取: ${grammarPath}`);
const grammar = readFileSync(grammarPath, 'utf-8');
const result = buildParserFile(grammar, {
fileName: `${type}.grammar`,
typeScript: true,
warn: (message) => console.warn(` 警告: ${message}`)
});
writeFileSync(outputPath, result.parser);
console.log(` ✓ 生成: ${outputPath}`);
// 生成 terms 文件
if (result.terms) {
const termsPath = join(parsersDir, type, `${type}.grammar.terms.ts`);
writeFileSync(termsPath, result.terms);
console.log(` ✓ 生成: ${termsPath}`);
}
console.log('');
} catch (error) {
console.error(` ✗ 错误: ${type} - ${error.message}\n`);
process.exit(1);
}
}
console.log('✓ 所有解析器构建完成!');

View File

@@ -0,0 +1,62 @@
import { foldService } from '@codemirror/language';
const countLeadingSpaces = (str: string) => {
let count = 0;
for (let i = 0; i < str.length; i++) {
if (str[i] === ' ') {
count++;
} else if (str[i] === '\t') {
count += 4;
} else {
break;
}
}
return count;
};
const isEmptyLine = (text: string) => {
return /^[ \t]*$/.test(text);
};
export const foldByIndent = () => {
return foldService.of((state, lineStart, lineEnd) => {
const line = state.doc.lineAt(lineStart);
const lineCount = state.doc.lines;
let indents = countLeadingSpaces(line.text);
let foldStart = lineStart;
let foldEnd = lineEnd;
let nextLine = line;
while (nextLine.number < lineCount) {
nextLine = state.doc.line(nextLine.number + 1);
if (nextLine.text === '' || isEmptyLine(nextLine.text)) continue;
let nextIndents = countLeadingSpaces(nextLine.text);
if (nextIndents > indents && !isEmptyLine(nextLine.text)) {
foldEnd = nextLine.to;
} else {
break;
}
}
if (
state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number
) {
return null;
}
foldStart = line.to;
const lineAtFoldStart = state.doc.lineAt(foldStart);
if (lineAtFoldStart.text === '' || isEmptyLine(lineAtFoldStart.text)) {
return null;
}
return { from: foldStart, to: foldEnd };
});
};

View File

@@ -0,0 +1,45 @@
export {
mermaidLanguage,
mindmapLanguage,
pieLanguage,
flowchartLanguage,
sequenceLanguage,
journeyLanguage,
requirementLanguage,
ganttLanguage,
} from './language-definitions';
export {
mermaidLanguageDescription,
mindmapLanguageDescription,
pieLanguageDescription,
flowchartLanguageDescription,
sequenceLanguageDescription,
journeyLanguageDescription,
requirementLanguageDescription,
ganttLanguageDescription,
} from './language-descriptions';
export {
mermaid,
mindmap,
pie,
flowchart,
sequence,
journey,
requirement,
gantt,
} from './language-support';
export {
mermaidTags,
mindmapTags,
pieTags,
flowchartTags,
sequenceTags,
journeyTags,
requirementTags,
ganttTags,
} from './tags';
export { foldByIndent } from './extensions';

View File

@@ -0,0 +1,74 @@
import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import {
mermaidParser,
mindmapParser,
pieParser,
flowchartParser,
sequenceParser,
journeyParser,
requirementParser,
ganttParser,
} from '../parsers';
import { DiagramType, MermaidLanguageType } from '../types';
export const mermaidLanguage = LRLanguage.define({
name: MermaidLanguageType.Mermaid,
parser: mermaidParser.configure({
wrap: parseMixed((node) => {
switch (node.name) {
case DiagramType.Mindmap:
return { parser: mindmapParser };
case DiagramType.Pie:
return { parser: pieParser };
case DiagramType.Flowchart:
return { parser: flowchartParser };
case DiagramType.Sequence:
return { parser: sequenceParser };
case DiagramType.Journey:
return { parser: journeyParser };
case DiagramType.Requirement:
return { parser: requirementParser };
case DiagramType.Gantt:
return { parser: ganttParser };
default:
return null;
}
}),
}),
});
export const mindmapLanguage = LRLanguage.define({
name: MermaidLanguageType.Mindmap,
parser: mindmapParser,
});
export const pieLanguage = LRLanguage.define({
name: MermaidLanguageType.Pie,
parser: pieParser,
});
export const flowchartLanguage = LRLanguage.define({
name: MermaidLanguageType.Flowchart,
parser: flowchartParser,
});
export const sequenceLanguage = LRLanguage.define({
name: MermaidLanguageType.Sequence,
parser: sequenceParser,
});
export const journeyLanguage = LRLanguage.define({
name: MermaidLanguageType.Journey,
parser: journeyParser,
});
export const requirementLanguage = LRLanguage.define({
name: MermaidLanguageType.Requirement,
parser: requirementParser,
});
export const ganttLanguage = LRLanguage.define({
name: MermaidLanguageType.Gantt,
parser: ganttParser,
});

View File

@@ -0,0 +1,71 @@
import { LanguageDescription } from '@codemirror/language';
import {
mermaid,
mindmap,
pie,
flowchart,
sequence,
journey,
requirement,
gantt,
} from '../language-support';
import { MermaidDescriptionName, MermaidAlias } from '../types';
export const mermaidLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Mermaid,
load: async () => {
return mermaid();
},
});
export const mindmapLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Mindmap,
load: async () => {
return mindmap();
},
});
export const pieLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Pie,
load: async () => {
return pie();
},
});
export const flowchartLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Flowchart,
alias: [MermaidAlias.Graph],
load: async () => {
return flowchart();
},
});
export const sequenceLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Sequence,
alias: [MermaidAlias.Sequence],
load: async () => {
return sequence();
},
});
export const journeyLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Journey,
load: async () => {
return journey();
},
});
export const requirementLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Requirement,
alias: [MermaidAlias.Requirement],
load: async () => {
return requirement();
},
});
export const ganttLanguageDescription = LanguageDescription.of({
name: MermaidDescriptionName.Gantt,
load: async () => {
return gantt();
},
});

View File

@@ -0,0 +1,43 @@
import { LanguageSupport } from '@codemirror/language';
import {
mermaidLanguage,
mindmapLanguage,
pieLanguage,
flowchartLanguage,
sequenceLanguage,
journeyLanguage,
requirementLanguage,
ganttLanguage,
} from '../language-definitions';
export function mermaid() {
return new LanguageSupport(mermaidLanguage);
}
export function mindmap() {
return new LanguageSupport(mindmapLanguage);
}
export function pie() {
return new LanguageSupport(pieLanguage);
}
export function flowchart() {
return new LanguageSupport(flowchartLanguage);
}
export function sequence() {
return new LanguageSupport(sequenceLanguage);
}
export function journey() {
return new LanguageSupport(journeyLanguage);
}
export function requirement() {
return new LanguageSupport(requirementLanguage);
}
export function gantt() {
return new LanguageSupport(ganttLanguage);
}

View File

@@ -0,0 +1,170 @@
@top FlowchartDiagram { document+ }
@skip { spaces | LineComment }
@skip {} {
String {
singleQuote (stringContentSingle)* (singleQuote) |
doubleQuote (stringContentDouble)* (doubleQuote) |
backTick (stringContentBackTick)* (backTick)
}
}
document {
(
DiagramName |
DiagramName Orientation |
DiagramName Orientation newlines* subDocument (newlines* subDocument semicolon?)*
) newlines*
}
subDocument {
NodeId |
Node |
Link |
NodeEdge |
ampersand |
Keyword |
emptyParentheses |
colon |
tripleColon |
String |
StyleKeyword NodeId StyleText
}
NodeId {
identifier | Orientation
}
text {
NodeText | String
}
edgeText {
NodeEdgeText | String
}
emptyParentheses { "()" }
Node {
"(" text ")" |
"[" text "]" |
"|" text "|" |
"([" text "])" |
"[(" text "])" |
"[[" text "]]" |
"[(" text ")]" |
"((" text "))" |
">" text "]" |
"{" text "}" |
"{{" text "}}" |
"(((" text ")))"
}
NodeEdge {
(DoubleHyphen | DoubleEqual) edgeText Link
}
Link {
linkType1 |
linkType2 |
linkType3 |
linkType4 |
linkType5 |
linkType6
}
DiagramName {
kw<"flowchart"> |
kw<"graph">
}
Orientation {
kw<"TB"> |
kw<"TD"> |
kw<"BT"> |
kw<"RL"> |
kw<"LR">
}
StyleKeyword {
kw<"style"> |
kw<"linkStyle"> |
kw<"class"> |
kw<"classDef">
}
Keyword {
kw<"subgraph"> |
kw<"end"> |
kw<"direction"> |
kw<"click"> |
kw<"call"> |
kw<"href"> |
kw<"_self"> |
kw<"_blank"> |
kw<"_parent"> |
kw<"_to">
}
kw<term> { @specialize<identifier, term> }
@external tokens nodeEdgeText from "./tokens" { NodeEdgeText }
@external tokens nodeText from "./tokens" { NodeText }
@external tokens styleText from "./tokens" { StyleText }
/*
Single character tokens will need to go inside the @tokens rule to specify precedence over "char".
Longer tokens always beat shorter tokens, which is why "flowchart" takes priority over multiple "char" tokens.
The @specialize rule also helps makes "DiagramName" have higher priority for "flowchart" even though it overlaps with "char" and "identifier" tokens
*/
@tokens {
char { @asciiLetter | @digit | $[!"\#$%&'*+\.`?\\_\/\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC] }
identifier { char+ }
newlines { $[\n]+ }
spaces { @whitespace+ }
stringContentSingle { ![']+ }
stringContentDouble { !["]+ }
stringContentBackTick { ![`]+ }
LineComment { "%%" ![\n]* }
DoubleHyphen { "--" }
DoubleEqual { "==" }
linkType1 { ("<-" | "x-" | "o-") ("->" | "-x" | "-o")}
linkType2 { ("<=" | "x=" | "o=") ("=>" | "=x" | "=o")}
linkType3 { ("<-" | "x-" | "o-")? "-"+ ("->" | "-x" | "-o")?}
linkType4 { ("<=" | "x=" | "o=")? "="+ ("=>" | "=x" | "=o")?}
linkType5 { ("<-" | "x-" | "o-" | "-")? "."+ ("->" | "-x" | "-o" | "-")?}
linkType6 { "~~~" | "---" | "===" }
ampersand { "&" }
semicolon { ";" }
singleQuote { "'" }
doubleQuote { '"' }
backTick { "`" }
tripleColon[@name=":::"] { ":::" }
colon[@name=":"] { ":" }
@precedence {
newlines,
spaces,
LineComment,
linkType1,
linkType2,
linkType3,
linkType4,
linkType5,
linkType6,
DoubleHyphen,
DoubleEqual,
ampersand,
semicolon,
singleQuote,
doubleQuote,
backTick,
tripleColon,
colon,
identifier
}
}
@external propSource flowchartHighlighting from "./highlight"

View File

@@ -0,0 +1,3 @@
export declare const NodeText: number;
export declare const NodeEdgeText: number;
export declare const StyleText: number;

View File

@@ -0,0 +1,20 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
NodeEdgeText = 1,
NodeText = 2,
StyleText = 3,
LineComment = 4,
FlowchartDiagram = 5,
DiagramName = 6,
Orientation = 7,
NodeId = 8,
Node = 9,
String = 10,
Link = 11,
NodeEdge = 12,
DoubleHyphen = 13,
DoubleEqual = 14,
Keyword = 15,
colon = 16,
tripleColon = 17,
StyleKeyword = 18

View File

@@ -0,0 +1,217 @@
import { describe, it, expect } from 'vitest';
import { parser } from './flowchart.parser.grammar';
/**
* Flowchart Grammar 测试
*
* 测试目标:验证标准的 Mermaid Flowchart 语法是否能正确解析,不应该出现错误节点(⚠)
*/
describe('Flowchart Grammar 解析测试', () => {
/**
* 辅助函数:解析代码并返回语法树
*/
function parseCode(code: string) {
const tree = parser.parse(code);
return tree;
}
/**
* 辅助函数:检查语法树中是否有错误节点
*/
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
tree.iterate({
enter: (node: any) => {
if (node.name === '⚠') {
errors.push({
name: node.name,
from: node.from,
to: node.to,
text: tree.toString().substring(node.from, node.to)
});
}
}
});
return {
hasError: errors.length > 0,
errors
};
}
/**
* 辅助函数:打印语法树结构(用于调试)
*/
function printTree(tree: any, code: string, maxDepth = 5) {
const lines: string[] = [];
tree.iterate({
enter: (node: any) => {
const depth = getNodeDepth(tree, node);
if (depth > maxDepth) return false; // 限制深度
const indent = ' '.repeat(depth);
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
const displayText = text.length === 30 ? text + '...' : text;
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
}
});
return lines.join('\n');
}
/**
* 获取节点深度
*/
function getNodeDepth(tree: any, targetNode: any): number {
let depth = 0;
let current = targetNode;
while (current.parent) {
depth++;
current = current.parent;
}
return depth;
}
it('应该正确解析基础的 flowchart 声明TB 方向)', () => {
const code = `flowchart TB
A
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析基础的 graph 声明LR 方向)', () => {
const code = `graph LR
A
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带方框节点的流程图', () => {
const code = `flowchart TD
A[Christmas]
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带箭头连接的节点', () => {
const code = `flowchart TD
A --> B
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带标签的连接线', () => {
const code = `flowchart TD
A -- text --> B
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析不同形状的节点(圆形)', () => {
const code = `flowchart TD
A((Circle))
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析不同形状的节点(菱形)', () => {
const code = `flowchart TD
A{Diamond}
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析完整的流程图示例', () => {
const code = `flowchart TD
A[Start] --> B{Is it?}
B -->|Yes| C[OK]
B -->|No| D[End]
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,3 @@
import { LRParser } from '@lezer/lr';
export declare const parser: LRParser;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { flowchartTags } from '../../tags';
export const flowchartHighlighting = styleTags({
'( )': t.paren,
'[ ]': t.squareBracket,
'{ }': t.brace,
'<': t.angleBracket,
DiagramName: flowchartTags.diagramName,
DoubleEqual: flowchartTags.link,
DoubleHyphen: flowchartTags.link,
Keyword: flowchartTags.keyword,
LineComment: flowchartTags.lineComment,
Link: flowchartTags.link,
NodeEdge: flowchartTags.nodeEdge,
NodeEdgeText: flowchartTags.nodeEdgeText,
NodeId: flowchartTags.nodeId,
NodeText: flowchartTags.nodeText,
Number: flowchartTags.number,
Orientation: flowchartTags.orientation,
String: flowchartTags.string,
});

View File

@@ -0,0 +1,55 @@
import { ExternalTokenizer } from '@lezer/lr';
import { NodeText, NodeEdgeText, StyleText } from './flowchart.grammar.terms';
const skipCodePoints = [-1, 9, 13, 32, 34, 39, 96];
const startBracketCodePoints = [40, 62, 91, 123, 124];
const endBracketCodePoints = [41, 93, 124, 125];
const hyphen = 45;
const equal = 61;
const dot = 46;
export const nodeText = new ExternalTokenizer((input) => {
if (
skipCodePoints.includes(input.next) ||
startBracketCodePoints.includes(input.next)
)
return;
while (!endBracketCodePoints.includes(input.next) && input.next !== -1) {
input.advance();
}
input.acceptToken(NodeText);
});
export const nodeEdgeText = new ExternalTokenizer((input) => {
if (
skipCodePoints.includes(input.next) ||
startBracketCodePoints.includes(input.next) ||
input.next === hyphen ||
input.next === equal ||
input.next === dot
)
return;
while (
input.next !== hyphen &&
input.next !== equal &&
input.next !== dot &&
input.next !== -1
) {
input.advance();
}
input.acceptToken(NodeEdgeText);
});
export const styleText = new ExternalTokenizer((input) => {
if (input.next === 10 || input.next === -1) return;
while (input.next !== 10 && input.next !== -1) {
input.advance();
}
input.acceptToken(StyleText);
});

View File

@@ -0,0 +1,60 @@
@top GanttDiagram {
document
}
@skip { spaces }
document {
DiagramName newlines? |
DiagramName newlines (subDocument newlines?)+
}
subDocument {
Title ImportantText |
Section ImportantText |
DateFormat Text |
AxisFormat Text |
Excludes Text |
TickInterval Text |
TodayMarker Text |
Weekday Text |
Text |
InclusiveEndDates |
LineComment
}
ImportantText {
text
}
Text {
text
}
DiagramName { kw<"gantt"> }
kw<term> { @specialize<identifier, term> }
@external tokens textToken from "./tokens" {
AxisFormat[group=Keyword],
DateFormat[group=Keyword],
Excludes[group=Keyword],
InclusiveEndDates[group=Keyword],
TickInterval[group=Keyword],
Title[group=Keyword],
TodayMarker[group=Keyword],
Weekday[group=Keyword],
Section,
text
}
@tokens {
identifier { @asciiLetter+ }
spaces { @whitespace+ }
newlines { $[\n]+ }
LineComment { "%%" ![\n]* }
@precedence { newlines, spaces }
}
@external propSource ganttHighlighting from "./highlight"

View File

@@ -0,0 +1,10 @@
export declare const AxisFormat: number;
export declare const DateFormat: number;
export declare const Excludes: number;
export declare const InclusiveEndDates: number;
export declare const Section: number;
export declare const TickInterval: number;
export declare const Title: number;
export declare const TodayMarker: number;
export declare const Weekday: number;
export declare const text: number;

View File

@@ -0,0 +1,17 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
AxisFormat = 1,
DateFormat = 2,
Excludes = 3,
InclusiveEndDates = 4,
TickInterval = 5,
Title = 6,
TodayMarker = 7,
Weekday = 8,
Section = 9,
text = 17,
GanttDiagram = 10,
DiagramName = 11,
ImportantText = 12,
Text = 13,
LineComment = 14

View File

@@ -0,0 +1,273 @@
import { describe, it, expect } from 'vitest';
import { parser } from './gantt.parser.grammar';
/**
* Gantt Diagram Grammar 测试
*
* 测试目标:验证标准的 Mermaid Gantt Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
*/
describe('Gantt Diagram Grammar 解析测试', () => {
/**
* 辅助函数:解析代码并返回语法树
*/
function parseCode(code: string) {
const tree = parser.parse(code);
return tree;
}
/**
* 辅助函数:检查语法树中是否有错误节点
*/
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
tree.iterate({
enter: (node: any) => {
if (node.name === '⚠') {
errors.push({
name: node.name,
from: node.from,
to: node.to,
text: tree.toString().substring(node.from, node.to)
});
}
}
});
return {
hasError: errors.length > 0,
errors
};
}
/**
* 辅助函数:打印语法树结构(用于调试)
*/
function printTree(tree: any, code: string, maxDepth = 5) {
const lines: string[] = [];
tree.iterate({
enter: (node: any) => {
const depth = getNodeDepth(tree, node);
if (depth > maxDepth) return false; // 限制深度
const indent = ' '.repeat(depth);
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
const displayText = text.length === 30 ? text + '...' : text;
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
}
});
return lines.join('\n');
}
/**
* 获取节点深度
*/
function getNodeDepth(tree: any, targetNode: any): number {
let depth = 0;
let current = targetNode;
while (current.parent) {
depth++;
current = current.parent;
}
return depth;
}
it('应该正确解析基础的 gantt 声明', () => {
const code = `gantt
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带标题的 gantt 图', () => {
const code = `gantt
title A Gantt Diagram
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带日期格式的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带章节的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
section Section
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带任务的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
title Adding GANTT diagram functionality to mermaid
section A section
Completed task :done, des1, 2014-01-06,2014-01-08
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带活动任务的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
section A section
Active task :active, des2, 2014-01-09, 3d
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带 axisFormat 的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
axisFormat %m-%d
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带 excludes 的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
excludes weekends
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带 todayMarker 的 gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
todayMarker off
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析完整的 gantt 图示例', () => {
const code = `gantt
dateFormat YYYY-MM-DD
title Adding GANTT diagram functionality to mermaid
excludes weekends
section A section
Completed task :done, des1, 2014-01-06,2014-01-08
Active task :active, des2, 2014-01-09, 3d
Future task : des3, after des2, 5d
Future task2 : des4, after des3, 5d
section Critical tasks
Completed task in the critical line :crit, done, 2014-01-06,24h
Implement parser and jison :crit, done, after des1, 2d
Create tests for parser :crit, active, 3d
Future task in critical line :crit, 5d
Create tests for renderer :2d
Add to mermaid :1d
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,3 @@
import { LRParser } from '@lezer/lr';
export declare const parser: LRParser;

View File

@@ -0,0 +1,24 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {textToken} from "./tokens"
import {ganttHighlighting} from "./highlight"
const spec_identifier = {__proto__:null,gantt:44}
export const parser = LRParser.deserialize({
version: 14,
states: "!|OVQQOOO[QQO'#CpQOQQOOOOQO'#Cg'#CgO!XQRO,59[OOQP'#Ci'#CiO!`QRO'#CtO!SQRO'#CtOOQP'#Ct'#CtO!eQRO'#CkO#`QRO1G.vOOQP'#Ch'#ChOOQP,59`,59`OOQP,59V,59VOOQP-E6i-E6i",
stateData: "#j~OcOS~OfRO~OgSO`dX~OPVOQVORVOSWOTVOUUOVVOWVOXUO^WOaTO~O`da~PdOaZO~Og]OP_XQ_XR_XS_XT_XU_XV_XW_XX_X^_X`_Xa_X~O`di~PdOgc~",
goto: "!UiPPPPPPPPPPPjmpPwPPPP}PPP!QRPOR[USWSYR[VQYSR^YRQOTXSY",
nodeNames: "⚠ AxisFormat DateFormat Excludes InclusiveEndDates TickInterval Title TodayMarker Weekday Section GanttDiagram DiagramName ImportantText Text LineComment",
maxTerm: 24,
nodeProps: [
["group", -8,1,2,3,4,5,6,7,8,"Keyword"]
],
propSources: [ganttHighlighting],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "$l~R_XY!QYZ!uZ^!Qpq!Quv#r!c!}$a#T#o$a#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~!VYc~X^!Qpq!Q#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~!|[g~c~XY!QYZ!uZ^!Qpq!Q#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~#uPuv#x~#}S^~OY#xZ;'S#x;'S;=`$Z<%lO#x~$^P;=`<%l#x~$fQe~!c!}$a#T#o$a",
tokenizers: [textToken, 0],
topRules: {"GanttDiagram":[0,10]},
specialized: [{term: 21, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
tokenPrec: 115
})

View File

@@ -0,0 +1,9 @@
import { styleTags } from '@lezer/highlight';
import { ganttTags } from '../../tags';
export const ganttHighlighting = styleTags({
'DiagramName Section': ganttTags.diagramName,
Keyword: ganttTags.keyword,
ImportantText: ganttTags.string,
LineComment: ganttTags.lineComment,
});

View File

@@ -0,0 +1,59 @@
import { ExternalTokenizer } from '@lezer/lr';
import {
AxisFormat,
DateFormat,
Excludes,
InclusiveEndDates,
Section,
TickInterval,
Title,
TodayMarker,
Weekday,
text,
} from './gantt.grammar.terms';
const keywordMap: { [key: string]: number } = {
axisFormat: AxisFormat,
dateFormat: DateFormat,
excludes: Excludes,
inclusiveEndDates: InclusiveEndDates,
section: Section,
tickInterval: TickInterval,
title: Title,
todayMarker: TodayMarker,
weekday: Weekday,
};
const keywords = Object.keys(keywordMap);
export const textToken = new ExternalTokenizer((input) => {
if (input.next === 32 || input.next === 10 || input.next === -1) return;
if (input.next === 37 && input.peek(1) === 37) {
return;
}
let tokens = '';
while (input.next !== 10 && input.next !== -1) {
tokens += String.fromCodePoint(input.next);
input.advance();
}
const activeKeyword = keywords.filter((keyword) => {
if (keyword === tokens) {
return tokens.startsWith(keyword);
}
return tokens.startsWith(keyword + ' ');
});
if (activeKeyword.length > 0) {
input.acceptToken(
keywordMap[activeKeyword[0]],
activeKeyword[0].length - tokens.length
);
return;
}
input.acceptToken(text);
});

View File

@@ -0,0 +1,8 @@
export { parser as mermaidParser } from './mermaid/mermaid.parser.grammar';
export { parser as mindmapParser } from './mindmap/mindmap.parser.grammar';
export { parser as pieParser } from './pie/pie.parser.grammar';
export { parser as flowchartParser } from './flowchart/flowchart.parser.grammar';
export { parser as sequenceParser } from './sequence/sequence.parser.grammar';
export { parser as journeyParser } from './journey/journey.parser.grammar';
export { parser as requirementParser } from './requirement/requirement.parser.grammar';
export { parser as ganttParser } from './gantt/gantt.parser.grammar';

View File

@@ -0,0 +1,11 @@
import { styleTags } from '@lezer/highlight';
import { journeyTags } from '../../tags';
export const journeyHighlighting = styleTags({
DiagramName: journeyTags.diagramName,
'Text TaskName': journeyTags.text,
Actor: journeyTags.actor,
Keyword: journeyTags.keyword,
LineComment: journeyTags.lineComment,
Score: journeyTags.score,
});

View File

@@ -0,0 +1,59 @@
@top JourneyDiagram {
document
}
@skip { spaces }
document {
DiagramName newlines* (
() |
subDocument newlines* |
subDocument (newlines+ subDocument)+ newlines*
)
}
subDocument {
LineComment |
Keyword Text |
Task
}
Task {
TaskName ":" Score (":" Actor ("," Actor)*)?
}
Text {
text1
}
TaskName {
text2
}
Score {
text2
}
Actor {
text3
}
DiagramName { kw<"journey"> }
kw<term> { @specialize<identifier, term> }
@external tokens keywordTokens from "./tokens" { Keyword }
@external tokens textTokens1 from "./tokens" { text1 }
@external tokens textTokens2 from "./tokens" { text2 }
@external tokens textTokens3 from "./tokens" { text3 }
@tokens {
spaces { @whitespace+ }
newlines { $[\n]+ }
LineComment { "%%" ![\n]* }
identifier { @asciiLetter+ }
@precedence { newlines, spaces }
}
@external propSource journeyHighlighting from "./highlight"

View File

@@ -0,0 +1,5 @@
export declare const Keyword: number;
export declare const text1: number;
export declare const text2: number;
export declare const text3: number;

View File

@@ -0,0 +1,14 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Keyword = 1,
text1 = 14,
text2 = 15,
text3 = 16,
JourneyDiagram = 2,
DiagramName = 3,
LineComment = 4,
Text = 5,
Task = 6,
TaskName = 7,
Score = 8,
Actor = 9

View File

@@ -0,0 +1,234 @@
import { describe, it, expect } from 'vitest';
import { parser } from './journey.parser.grammar';
/**
* Journey Diagram Grammar 测试
*
* 测试目标:验证标准的 Mermaid Journey Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
*/
describe('Journey Diagram Grammar 解析测试', () => {
/**
* 辅助函数:解析代码并返回语法树
*/
function parseCode(code: string) {
const tree = parser.parse(code);
return tree;
}
/**
* 辅助函数:检查语法树中是否有错误节点
*/
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
tree.iterate({
enter: (node: any) => {
if (node.name === '⚠') {
errors.push({
name: node.name,
from: node.from,
to: node.to,
text: tree.toString().substring(node.from, node.to)
});
}
}
});
return {
hasError: errors.length > 0,
errors
};
}
/**
* 辅助函数:打印语法树结构(用于调试)
*/
function printTree(tree: any, code: string, maxDepth = 5) {
const lines: string[] = [];
tree.iterate({
enter: (node: any) => {
const depth = getNodeDepth(tree, node);
if (depth > maxDepth) return false; // 限制深度
const indent = ' '.repeat(depth);
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
const displayText = text.length === 30 ? text + '...' : text;
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
}
});
return lines.join('\n');
}
/**
* 获取节点深度
*/
function getNodeDepth(tree: any, targetNode: any): number {
let depth = 0;
let current = targetNode;
while (current.parent) {
depth++;
current = current.parent;
}
return depth;
}
it('应该正确解析基础的 journey 声明', () => {
const code = `journey
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带标题的 journey 图', () => {
const code = `journey
title My working day
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带章节的 journey 图', () => {
const code = `journey
title My working day
section Go to work
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带任务和分数的 journey 图', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带任务、分数和参与者的 journey 图', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带多个参与者的任务', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5: Me, Cat
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析多个章节的 journey 图', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
section Work
Do work: 1: Me, Cat
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析完整的 journey 图示例', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 5: Me
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,3 @@
import { LRParser } from '@lezer/lr';
export declare const parser: LRParser;

View File

@@ -0,0 +1,21 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {keywordTokens, textTokens1, textTokens2, textTokens3} from "./tokens"
import {journeyHighlighting} from "./highlight"
const spec_identifier = {__proto__:null,journey:42}
export const parser = LRParser.deserialize({
version: 14,
states: "%^OVQ`OOO[QeO'#CoQOQ`OOOOQT'#C_'#C_OOQT'#Cf'#CfOmQeO,59ZOOQO'#Cc'#CcO!OQ`O'#CbOOQO'#Cs'#CsO!TQbO'#CsOvQ`O,59ZOOQT-E6d-E6dO!YQ`O1G.uO!bQdO,58|OOQO'#Ca'#CaOOQO,59_,59_O!gQeO1G.uO!YQ`O1G.uO!xQeO7+$aO#RQ`O7+$aOOQO'#Cd'#CdO#ZQ`O1G.hOOQO,59S,59SOOQO-E6f-E6fO#fQeO<<G{O#wQhO7+$SP#|QeO'#CfOOQO'#Ce'#CeO$[Q`O<<GnO#wQhO'#CgO$gQ`OAN=YOOQO,59R,59ROOQO-E6e-E6e",
stateData: "$u~ObOS~OeRO~OPXOSWO_UOfSO]cX~OPXOSWO_UOfSO]ca~Oh]O~O^^O~OfSO]ci~O_dO~OPXOSWO_UOfSO]ci~OPXOSWO_UOfSO]cq~OhiO]UifUi~OPXOSWO_UOfSO]cy~O`kO~OPXOSWO_UOfSO~OimO]UyfUy~OimO]U!RfU!R~Ofb~",
goto: "#_hPPPiPlow!P!S!Y!n!tPPPPPP#OPPP#RRPOR_X]WPT`bhj]VPT`bhjRe]QliRomQTPYZT`bhjQ`YSb[aRhcQnlRpnQaYQc[TgacRQOQYPQ[TXf`bhj",
nodeNames: "⚠ Keyword JourneyDiagram DiagramName LineComment Text Task TaskName Score Actor",
maxTerm: 25,
propSources: [journeyHighlighting],
skippedNodes: [0],
repeatNodeCount: 3,
tokenData: "$|~RaXY!WYZ!{Z^!Wpq!Wuv#x|}$g![!]$l!c!}$q#T#o$q#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~!]Yb~X^!Wpq!W#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~#S[f~b~XY!WYZ!{Z^!Wpq!W#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~#{Puv$O~$TSS~OY$OZ;'S$O;'S;=`$a<%lO$O~$dP;=`<%l$O~$lOi~~$qOh~~$vQd~!c!}$q#T#o$q",
tokenizers: [keywordTokens, textTokens1, textTokens2, textTokens3, 0],
topRules: {"JourneyDiagram":[0,2]},
specialized: [{term: 20, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
tokenPrec: 172
})

View File

@@ -0,0 +1,69 @@
import { ExternalTokenizer } from '@lezer/lr';
import { Keyword, text1, text2, text3 } from './journey.grammar.terms';
import type { InputStream } from '@lezer/lr';
const skipCodePoints = [-1, 9, 10, 13, 32];
const keywords = ['title', 'section'];
const isComment = (input: InputStream) => {
return input.peek(0) === 37 && input.peek(1) === 37;
};
const shouldSkip = (input: InputStream) => {
return skipCodePoints.includes(input.next) || isComment(input);
};
export const keywordTokens = new ExternalTokenizer((input) => {
if (shouldSkip(input)) return;
let tokens = '';
while (!skipCodePoints.includes(input.next)) {
tokens += String.fromCodePoint(input.next);
input.advance();
}
const activeKeyword = keywords.filter((keyword) => {
if (keyword === tokens) {
return tokens.toLowerCase().startsWith(keyword);
}
return tokens.toLowerCase().startsWith(keyword + ' '); // ensure the keyword isn't used as a token unless there's a space at the end e.g. titleStuff
});
if (activeKeyword.length > 0) {
input.acceptToken(Keyword, activeKeyword[0].length - tokens.length);
return;
}
});
export const textTokens1 = new ExternalTokenizer((input) => {
if (shouldSkip(input)) return;
while (input.next !== 10 && input.next !== -1) {
input.advance();
}
input.acceptToken(text1);
});
export const textTokens2 = new ExternalTokenizer((input) => {
if (shouldSkip(input)) return;
while (input.next !== 58 && input.next !== 10 && input.next !== -1) {
input.advance();
}
input.acceptToken(text2);
});
export const textTokens3 = new ExternalTokenizer((input) => {
if (shouldSkip(input)) return;
while (input.next !== 44 && input.next !== 10 && input.next !== -1) {
input.advance();
}
input.acceptToken(text3);
});

View File

@@ -0,0 +1,29 @@
// See this link for entire Pie chart syntax in Mermaid: https://mermaid.js.org/syntax/pie.html#syntax
@top MermaidDiagram {
preDiagramLine* (
PieDiagram |
MindmapDiagram |
FlowchartDiagram |
SequenceDiagram |
JourneyDiagram |
RequirementDiagram |
GanttDiagram
)
}
@skip { space }
@tokens {
space { $[ \t\r]+ }
}
@external tokens diagramText from "./tokens" {
preDiagramLine,
PieDiagram,
MindmapDiagram,
FlowchartDiagram,
SequenceDiagram,
JourneyDiagram,
RequirementDiagram,
GanttDiagram
}

View File

@@ -0,0 +1,8 @@
export declare const preDiagramLine: number;
export declare const MindmapDiagram: number;
export declare const PieDiagram: number;
export declare const FlowchartDiagram: number;
export declare const SequenceDiagram: number;
export declare const JourneyDiagram: number;
export declare const RequirementDiagram: number;
export declare const GanttDiagram: number;

View File

@@ -0,0 +1,11 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
preDiagramLine = 11,
PieDiagram = 1,
MindmapDiagram = 2,
FlowchartDiagram = 3,
SequenceDiagram = 4,
JourneyDiagram = 5,
RequirementDiagram = 6,
GanttDiagram = 7,
MermaidDiagram = 8

View File

@@ -0,0 +1,283 @@
import { describe, it, expect } from 'vitest';
import { parser } from './mermaid.parser.grammar';
/**
* Mermaid Grammar 测试
*
* 测试目标:验证标准的 Mermaid 综合语法是否能正确解析,不应该出现错误节点(⚠)
* 这个测试涵盖所有类型的 Mermaid 图表
*/
describe('Mermaid Grammar 解析测试', () => {
/**
* 辅助函数:解析代码并返回语法树
*/
function parseCode(code: string) {
const tree = parser.parse(code);
return tree;
}
/**
* 辅助函数:检查语法树中是否有错误节点
*/
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
tree.iterate({
enter: (node: any) => {
if (node.name === '⚠') {
errors.push({
name: node.name,
from: node.from,
to: node.to,
text: tree.toString().substring(node.from, node.to)
});
}
}
});
return {
hasError: errors.length > 0,
errors
};
}
/**
* 辅助函数:打印语法树结构(用于调试)
*/
function printTree(tree: any, code: string, maxDepth = 5) {
const lines: string[] = [];
tree.iterate({
enter: (node: any) => {
const depth = getNodeDepth(tree, node);
if (depth > maxDepth) return false; // 限制深度
const indent = ' '.repeat(depth);
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
const displayText = text.length === 30 ? text + '...' : text;
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
}
});
return lines.join('\n');
}
/**
* 获取节点深度
*/
function getNodeDepth(tree: any, targetNode: any): number {
let depth = 0;
let current = targetNode;
while (current.parent) {
depth++;
current = current.parent;
}
return depth;
}
it('应该正确解析 Pie 图', () => {
const code = `pie title Pets
"Dogs" : 386
"Cats" : 85
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Mindmap 图', () => {
const code = `mindmap
root((mindmap))
Origins
Long history
Research
On effectiveness
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Flowchart 图', () => {
const code = `flowchart TD
A[Start] --> B{Is it?}
B -->|Yes| C[OK]
B -->|No| D[End]
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Sequence 图', () => {
const code = `sequenceDiagram
Alice->>John: Hello John
John-->>Alice: Great!
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Journey 图', () => {
const code = `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Requirement 图', () => {
const code = `requirementDiagram
requirement test_req {
id: 1
text: the test text
risk: high
}
element test_entity {
type: simulation
}
test_entity - satisfies -> test_req
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 Gantt 图', () => {
const code = `gantt
dateFormat YYYY-MM-DD
title Adding GANTT diagram
section A section
Completed task :done, des1, 2014-01-06,2014-01-08
Active task :active, des2, 2014-01-09, 3d
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带注释的 flowchart 图', () => {
const code = `%% This is a comment
flowchart TD
A[Start] --> B[End]
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析带空行的 sequence 图', () => {
const code = `
sequenceDiagram
participant Alice
Alice->>John: Hello
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
it('应该正确解析 graph 类型的流程图', () => {
const code = `graph LR
A[Square Rect] -- Link text --> B((Circle))
A --> C(Round Rect)
B --> D{Rhombus}
C --> D
`;
const tree = parseCode(code);
const result = hasErrorNodes(tree);
if (result.hasError) {
console.log('语法树:');
console.log(printTree(tree, code));
console.log('错误节点:', result.errors);
}
expect(result.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,3 @@
import { LRParser } from '@lezer/lr';
export declare const parser: LRParser;

View File

@@ -0,0 +1,17 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {diagramText} from "./tokens"
export const parser = LRParser.deserialize({
version: 14,
states: "nOVQROOOOQQ'#Ce'#CeOVQROOQOQPOOOOQQ-E6c-E6c",
stateData: "q~O]OS~OPROQRORROSROTROUROVROZPO~O",
goto: "aYPPPPPPPPPZQQORSQ",
nodeNames: "⚠ PieDiagram MindmapDiagram FlowchartDiagram SequenceDiagram JourneyDiagram RequirementDiagram GanttDiagram MermaidDiagram",
maxTerm: 13,
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "j~RRXY[]^[pq[~aR]~XY[]^[pq[",
tokenizers: [0, diagramText],
topRules: {"MermaidDiagram":[0,8]},
tokenPrec: 0
})

View File

@@ -0,0 +1,52 @@
import { ExternalTokenizer } from '@lezer/lr';
import {
preDiagramLine,
MindmapDiagram,
PieDiagram,
FlowchartDiagram,
SequenceDiagram,
JourneyDiagram,
RequirementDiagram,
GanttDiagram,
} from './mermaid.grammar.terms';
const skipCodePoints = [-1, 9, 13, 32];
const diagramMap: Record<string, number> = {
mindmap: MindmapDiagram,
pie: PieDiagram,
flowchart: FlowchartDiagram,
graph: FlowchartDiagram,
sequenceDiagram: SequenceDiagram,
journey: JourneyDiagram,
requirementDiagram: RequirementDiagram,
gantt: GanttDiagram,
};
const diagrams = Object.keys(diagramMap);
export const diagramText = new ExternalTokenizer((input) => {
if (skipCodePoints.includes(input.next)) return;
let tokens = '';
while (input.next != 10 && input.next !== -1) {
tokens += String.fromCodePoint(input.next);
input.advance();
}
input.advance();
const activeDiagram = diagrams.filter((diagram) => {
return tokens.startsWith(diagram);
});
if (activeDiagram.length > 0) {
while (input.next !== -1) {
input.advance();
}
input.acceptToken(diagramMap[activeDiagram[0]]);
} else {
input.acceptToken(preDiagramLine);
}
});

View File

@@ -0,0 +1,11 @@
import { styleTags } from '@lezer/highlight';
import { mindmapTags } from '../../tags';
export const mindmapHighlighting = styleTags({
DiagramName: mindmapTags.diagramName,
LineText1: mindmapTags.lineText1,
LineText2: mindmapTags.lineText2,
LineText3: mindmapTags.lineText3,
LineText4: mindmapTags.lineText4,
LineText5: mindmapTags.lineText5,
});

Some files were not shown because too many files have changed in this diff Show More