Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d6f157ae1 | |||
| afda3d5301 | |||
| 5d4ba757aa | |||
| d12d58b15a | |||
| 627c3dc71f | |||
| 26c7a3241c | |||
| 46c5e3dd1a | |||
|
|
92a6c6bfdb | ||
| 031aa49f9f | |||
| 1d7aee4cea | |||
| dec3ef5ef4 | |||
| d42f913250 | |||
| bae4e663fb | |||
| a17e060d16 | |||
| 71946965eb | |||
| d4cd22d234 | |||
| 05f2f7d46d | |||
| 9deb2744a9 | |||
| 6fac7c42d6 | |||
| 3393bc84e3 |
325
.github/workflows/build-release.yml
vendored
Normal file
325
.github/workflows/build-release.yml
vendored
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
name: Build and Release Voidraft
|
||||||
|
|
||||||
|
on:
|
||||||
|
# 推送标签时触发(用于正式发布)
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*' # 触发条件:推送 v 开头的标签,如 v1.0.0
|
||||||
|
branches:
|
||||||
|
- main # 仅当标签在 main 分支时触发
|
||||||
|
|
||||||
|
# 手动触发(用于测试)
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release:
|
||||||
|
description: '是否创建 Release(测试时选 false)'
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
platforms:
|
||||||
|
description: '要构建的平台(用逗号分隔:windows,linux,macos-intel,macos-arm)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
default: 'windows,linux'
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096" # 防止 Node.js 内存溢出
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 准备构建配置
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||||
|
should_release: ${{ steps.check-release.outputs.should_release }}
|
||||||
|
steps:
|
||||||
|
- name: 确定构建平台
|
||||||
|
id: set-matrix
|
||||||
|
run: |
|
||||||
|
# 如果是手动触发,根据输入决定平台
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
PLATFORMS="${{ github.event.inputs.platforms }}"
|
||||||
|
else
|
||||||
|
# 标签触发,构建所有平台
|
||||||
|
PLATFORMS="windows,linux,macos-intel,macos-arm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "构建平台: $PLATFORMS"
|
||||||
|
|
||||||
|
# 构建矩阵 JSON
|
||||||
|
MATRIX='{"include":[]}'
|
||||||
|
|
||||||
|
if [[ "$PLATFORMS" == *"windows"* ]]; then
|
||||||
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"windows-latest","os":"windows","arch":"amd64","platform_dir":"windows","id":"windows"}]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PLATFORMS" == *"linux"* ]]; then
|
||||||
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"ubuntu-22.04","os":"linux","arch":"amd64","platform_dir":"linux","id":"linux"}]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PLATFORMS" == *"macos-intel"* ]]; then
|
||||||
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"amd64","platform_dir":"darwin","id":"macos-intel"}]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PLATFORMS" == *"macos-arm"* ]]; then
|
||||||
|
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"arm64","platform_dir":"darwin","id":"macos-arm"}]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 使用 -c 确保输出紧凑的单行 JSON,符合 GitHub Actions 输出格式
|
||||||
|
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 输出美化的 JSON 用于日志查看
|
||||||
|
echo "生成的矩阵:"
|
||||||
|
echo "$MATRIX" | jq .
|
||||||
|
|
||||||
|
- name: 检查是否创建 Release
|
||||||
|
id: check-release
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
# 手动触发,根据输入决定
|
||||||
|
echo "should_release=${{ github.event.inputs.release }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
# 标签触发,自动创建 Release
|
||||||
|
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
if: ${{ fromJson(needs.prepare.outputs.matrix).include[0] != null }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: 设置 Go 环境
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 设置 Node.js 环境
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
# Linux 平台依赖
|
||||||
|
- name: 安装 Linux 依赖
|
||||||
|
if: matrix.os == 'linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
# Wails 3 + 项目特定依赖
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libx11-dev \
|
||||||
|
rpm \
|
||||||
|
fuse \
|
||||||
|
file
|
||||||
|
|
||||||
|
# 依赖说明:
|
||||||
|
# - build-essential: C/C++ 编译工具链
|
||||||
|
# - pkg-config: 包配置工具
|
||||||
|
# - libgtk-3-dev: GTK3 GUI 框架
|
||||||
|
# - libwebkit2gtk-4.1-dev: WebKit2GTK 4.1 (Wails 3 要求)
|
||||||
|
# - libayatana-appindicator3-dev: 系统托盘支持
|
||||||
|
# - librsvg2-dev: SVG 图标支持
|
||||||
|
# - patchelf: 修改 ELF 二进制文件
|
||||||
|
# - libx11-dev: X11 库 (热键服务依赖)
|
||||||
|
# - rpm: RPM 打包工具
|
||||||
|
# - fuse: AppImage 运行依赖
|
||||||
|
# - file: 文件类型检测工具
|
||||||
|
|
||||||
|
# Windows 平台依赖
|
||||||
|
- name: 设置 Windows 构建环境
|
||||||
|
if: matrix.os == 'windows'
|
||||||
|
run: |
|
||||||
|
# 安装 NSIS (用于创建安装程序)
|
||||||
|
choco install nsis -y
|
||||||
|
# 将 NSIS 添加到 PATH
|
||||||
|
echo "C:\Program Files (x86)\NSIS" >> $GITHUB_PATH
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# macOS 平台依赖
|
||||||
|
- name: 设置 macOS 构建环境
|
||||||
|
if: matrix.os == 'darwin'
|
||||||
|
run: |
|
||||||
|
# Xcode 命令行工具通常已安装
|
||||||
|
xcode-select --install 2>/dev/null || true
|
||||||
|
|
||||||
|
# 安装 Wails CLI
|
||||||
|
- name: 安装 Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
- name: 安装前端依赖
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
- name: 构建前端
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# 使用 Wails Task 构建和打包应用
|
||||||
|
- name: 构建和打包 Wails 应用
|
||||||
|
run: wails3 task ${{ matrix.id }}:package PRODUCTION=true ARCH=${{ matrix.arch }}
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
APP_NAME: voidraft
|
||||||
|
BIN_DIR: bin
|
||||||
|
ROOT_DIR: .
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# 整理构建产物
|
||||||
|
- name: 整理构建产物
|
||||||
|
id: organize_artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== 构建产物列表 ==="
|
||||||
|
ls -lhR bin/ || echo "bin 目录不存在"
|
||||||
|
|
||||||
|
# 创建输出目录
|
||||||
|
mkdir -p artifacts
|
||||||
|
|
||||||
|
# 根据平台复制产物
|
||||||
|
if [ "${{ matrix.os }}" = "windows" ]; then
|
||||||
|
echo "Windows 平台:查找 NSIS 安装程序"
|
||||||
|
find . -name "*.exe" -type f
|
||||||
|
cp bin/*.exe artifacts/ 2>/dev/null || echo "未找到 .exe 文件"
|
||||||
|
|
||||||
|
elif [ "${{ matrix.os }}" = "linux" ]; then
|
||||||
|
echo "Linux 平台:查找 AppImage, deb, rpm, archlinux 包"
|
||||||
|
find bin -type f
|
||||||
|
cp bin/*.AppImage artifacts/ 2>/dev/null || echo "未找到 AppImage"
|
||||||
|
cp bin/*.deb artifacts/ 2>/dev/null || echo "未找到 deb"
|
||||||
|
cp bin/*.rpm artifacts/ 2>/dev/null || echo "未找到 rpm"
|
||||||
|
cp bin/*.pkg.tar.zst artifacts/ 2>/dev/null || echo "未找到 archlinux 包"
|
||||||
|
|
||||||
|
elif [ "${{ matrix.os }}" = "darwin" ]; then
|
||||||
|
echo "macOS 平台:查找 .app bundle"
|
||||||
|
find bin -name "*.app" -type d
|
||||||
|
# macOS: .app bundle,打包成 zip
|
||||||
|
if [ -d "bin/voidraft.app" ]; then
|
||||||
|
cd bin
|
||||||
|
zip -r ../artifacts/voidraft-darwin-${{ matrix.arch }}.app.zip voidraft.app
|
||||||
|
cd ..
|
||||||
|
else
|
||||||
|
echo "未找到 .app bundle"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 最终产物 ==="
|
||||||
|
ls -lh artifacts/ || echo "artifacts 目录为空"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# 上传构建产物到 Artifacts
|
||||||
|
- name: 上传构建产物
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: voidraft-${{ matrix.id }}
|
||||||
|
path: artifacts/*
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# 创建 GitHub Release 并上传所有构建产物
|
||||||
|
release:
|
||||||
|
needs: [prepare, build]
|
||||||
|
if: ${{ needs.prepare.outputs.should_release == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 下载所有构建产物
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: 显示下载的文件
|
||||||
|
run: |
|
||||||
|
echo "下载的构建产物:"
|
||||||
|
ls -R artifacts/
|
||||||
|
|
||||||
|
- name: 准备 Release 文件
|
||||||
|
run: |
|
||||||
|
mkdir -p release
|
||||||
|
find artifacts -type f -exec cp {} release/ \;
|
||||||
|
ls -lh release/
|
||||||
|
|
||||||
|
- name: 生成 Release 说明
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
# 获取版本号
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ github.sha }}"
|
||||||
|
VERSION_NAME="测试构建 ${VERSION:0:7}"
|
||||||
|
else
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
VERSION_NAME="${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > release_notes.md << EOF
|
||||||
|
## Voidraft ${VERSION_NAME}
|
||||||
|
|
||||||
|
### 📦 下载
|
||||||
|
|
||||||
|
根据你的操作系统选择对应的版本:
|
||||||
|
|
||||||
|
- **Windows (64位)**: \`voidraft-windows-amd64.exe\`
|
||||||
|
- **Linux (64位)**: \`voidraft-linux-amd64\`
|
||||||
|
- **macOS (Intel)**: \`voidraft-darwin-amd64.zip\`
|
||||||
|
- **macOS (Apple Silicon)**: \`voidraft-darwin-arm64.zip\`
|
||||||
|
|
||||||
|
### 📝 更新内容
|
||||||
|
|
||||||
|
请查看 [提交历史](../../commits/${{ github.ref_name }}) 了解本次更新的详细内容。
|
||||||
|
|
||||||
|
### 💡 使用说明
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
1. 下载 `voidraft-windows-amd64.exe`
|
||||||
|
2. 直接运行即可
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
1. 下载 `voidraft-linux-amd64`
|
||||||
|
2. 添加执行权限:`chmod +x voidraft-linux-amd64`
|
||||||
|
3. 运行:`./voidraft-linux-amd64`
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
1. 下载对应架构的 zip 文件
|
||||||
|
2. 解压后运行
|
||||||
|
3. 如果提示无法打开,请在 系统偏好设置 > 安全性与隐私 中允许运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
构建时间: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: 创建 GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: release/*
|
||||||
|
body_path: release_notes.md
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
tag_name: ${{ github.event_name == 'workflow_dispatch' && format('test-{0}', github.sha) || github.ref_name }}
|
||||||
|
name: ${{ github.event_name == 'workflow_dispatch' && format('测试构建 {0}', github.sha) || github.ref_name }}
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
12
Taskfile.yml
12
Taskfile.yml
@@ -12,25 +12,13 @@ vars:
|
|||||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
version:
|
|
||||||
summary: Generate version information
|
|
||||||
cmds:
|
|
||||||
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
|
|
||||||
sources:
|
|
||||||
- scripts/version.bat
|
|
||||||
- scripts/version.sh
|
|
||||||
generates:
|
|
||||||
- version.txt
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
summary: Builds the application
|
summary: Builds the application
|
||||||
deps: [version]
|
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:build"
|
- task: "{{OS}}:build"
|
||||||
|
|
||||||
package:
|
package:
|
||||||
summary: Packages a production build of the application
|
summary: Packages a production build of the application
|
||||||
deps: [version]
|
|
||||||
cmds:
|
cmds:
|
||||||
- task: "{{OS}}:package"
|
- task: "{{OS}}:package"
|
||||||
|
|
||||||
|
|||||||
293
build/COMMANDS.md
Normal file
293
build/COMMANDS.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Wails 3 命令参考表
|
||||||
|
|
||||||
|
本文档列出了 Voidraft 项目中使用的所有 Wails 3 命令和参数。
|
||||||
|
|
||||||
|
## 📋 命令总览
|
||||||
|
|
||||||
|
| 类别 | 命令 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 生成工具 | `wails3 generate` | 生成项目所需的各种资源文件 |
|
||||||
|
| 打包工具 | `wails3 tool package` | 使用 nfpm 打包应用程序 |
|
||||||
|
| 任务执行 | `wails3 task` | 执行 Taskfile.yml 中定义的任务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 详细命令参数
|
||||||
|
|
||||||
|
### 1. `wails3 generate bindings`
|
||||||
|
**生成 TypeScript 绑定文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-f` | 构建标志 | `''` (空字符串) |
|
||||||
|
| `-clean` | 清理旧绑定 | `true` |
|
||||||
|
| `-ts` | 生成 TypeScript | 无需值 |
|
||||||
|
|
||||||
|
**使用位置:** `build/Taskfile.yml:53`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate bindings -f '' -clean=true -ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `wails3 generate icons`
|
||||||
|
**从单个图片生成多平台图标**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-input` | 输入图片路径 | `appicon.png` |
|
||||||
|
| `-macfilename` | macOS 图标输出路径 | `darwin/icons.icns` |
|
||||||
|
| `-windowsfilename` | Windows 图标输出路径 | `windows/icons.ico` |
|
||||||
|
|
||||||
|
**使用位置:** `build/Taskfile.yml:64`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate icons \
|
||||||
|
-input appicon.png \
|
||||||
|
-macfilename darwin/icons.icns \
|
||||||
|
-windowsfilename windows/icons.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `wails3 generate syso`
|
||||||
|
**生成 Windows .syso 资源文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-arch` | 目标架构 | `amd64` / `arm64` |
|
||||||
|
| `-icon` | 图标文件 | `windows/icon.ico` |
|
||||||
|
| `-manifest` | 清单文件 | `windows/wails.exe.manifest` |
|
||||||
|
| `-info` | 应用信息 JSON | `windows/info.json` |
|
||||||
|
| `-out` | 输出文件路径 | `../wails_windows_amd64.syso` |
|
||||||
|
|
||||||
|
**使用位置:** `build/windows/Taskfile.yml:42`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate syso \
|
||||||
|
-arch amd64 \
|
||||||
|
-icon windows/icon.ico \
|
||||||
|
-manifest windows/wails.exe.manifest \
|
||||||
|
-info windows/info.json \
|
||||||
|
-out ../wails_windows_amd64.syso
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `wails3 generate webview2bootstrapper`
|
||||||
|
**生成 Windows WebView2 引导程序**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-dir` | 输出目录 | `build/windows/nsis` |
|
||||||
|
|
||||||
|
**使用位置:** `build/windows/Taskfile.yml:55`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate webview2bootstrapper -dir "build/windows/nsis"
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明:** 下载 Microsoft Edge WebView2 运行时安装程序,用于 NSIS 打包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `wails3 generate .desktop`
|
||||||
|
**生成 Linux .desktop 桌面文件**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 应用名称 | `voidraft` |
|
||||||
|
| `-exec` | 可执行文件名 | `voidraft` |
|
||||||
|
| `-icon` | 图标名称 | `appicon` |
|
||||||
|
| `-outputfile` | 输出文件路径 | `build/linux/voidraft.desktop` |
|
||||||
|
| `-categories` | 应用分类 | `Development;` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:107`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate .desktop \
|
||||||
|
-name "voidraft" \
|
||||||
|
-exec "voidraft" \
|
||||||
|
-icon "appicon" \
|
||||||
|
-outputfile build/linux/voidraft.desktop \
|
||||||
|
-categories "Development;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `wails3 generate appimage`
|
||||||
|
**生成 Linux AppImage 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-binary` | 二进制文件名 | `voidraft` |
|
||||||
|
| `-icon` | 图标文件路径 | `../../appicon.png` |
|
||||||
|
| `-desktopfile` | .desktop 文件路径 | `../voidraft.desktop` |
|
||||||
|
| `-outputdir` | 输出目录 | `../../../bin` |
|
||||||
|
| `-builddir` | 构建临时目录 | `build/linux/appimage/build` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:49`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 generate appimage \
|
||||||
|
-binary voidraft \
|
||||||
|
-icon ../../appicon.png \
|
||||||
|
-desktopfile ../voidraft.desktop \
|
||||||
|
-outputdir ../../../bin \
|
||||||
|
-builddir build/linux/appimage/build
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明:** 自动下载 linuxdeploy 工具并创建 AppImage。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `wails3 tool package` (deb)
|
||||||
|
**创建 Debian/Ubuntu .deb 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `deb` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:90`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format deb \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. `wails3 tool package` (rpm)
|
||||||
|
**创建 RedHat/CentOS/Fedora .rpm 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `rpm` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:95`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format rpm \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `wails3 tool package` (archlinux)
|
||||||
|
**创建 Arch Linux .pkg.tar.zst 包**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `-name` | 包名称 | `voidraft` |
|
||||||
|
| `-format` | 包格式 | `archlinux` |
|
||||||
|
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
|
||||||
|
| `-out` | 输出目录 | `./bin` |
|
||||||
|
|
||||||
|
**使用位置:** `build/linux/Taskfile.yml:100`
|
||||||
|
|
||||||
|
**完整命令:**
|
||||||
|
```bash
|
||||||
|
wails3 tool package \
|
||||||
|
-name voidraft \
|
||||||
|
-format archlinux \
|
||||||
|
-config ./build/linux/nfpm/nfpm.yaml \
|
||||||
|
-out ./bin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `wails3 task`
|
||||||
|
**执行 Taskfile.yml 中定义的任务**
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `[taskname]` | 任务名称 | `build`, `package`, `run` |
|
||||||
|
| `[VAR=value]` | 变量赋值 | `PRODUCTION=true`, `ARCH=amd64` |
|
||||||
|
|
||||||
|
**常用任务:**
|
||||||
|
|
||||||
|
| 任务 | 说明 | 命令 |
|
||||||
|
|------|------|------|
|
||||||
|
| `build` | 构建应用 | `wails3 task build PRODUCTION=true` |
|
||||||
|
| `package` | 打包应用 | `wails3 task package` |
|
||||||
|
| `run` | 运行应用 | `wails3 task run` |
|
||||||
|
|
||||||
|
**使用位置:** `build/config.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 平台对应命令表
|
||||||
|
|
||||||
|
| 平台 | 主要命令 | 产物 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **Windows** | `generate syso`<br>`generate webview2bootstrapper` | `.syso` 资源文件<br>NSIS 安装程序 |
|
||||||
|
| **Linux** | `generate .desktop`<br>`generate appimage`<br>`tool package -format deb/rpm/archlinux` | `.desktop` 文件<br>`.AppImage`<br>`.deb` / `.rpm` / `.pkg.tar.zst` |
|
||||||
|
| **macOS** | `generate icons` | `.icns` 图标<br>`.app` 应用包 |
|
||||||
|
| **通用** | `generate bindings`<br>`generate icons` | TypeScript 绑定<br>多平台图标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速参考
|
||||||
|
|
||||||
|
### 完整构建流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成绑定和图标
|
||||||
|
wails3 task common:generate:bindings
|
||||||
|
wails3 task common:generate:icons
|
||||||
|
|
||||||
|
# 2. 构建前端
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 3. 构建应用(各平台)
|
||||||
|
cd build/windows && wails3 task build PRODUCTION=true # Windows
|
||||||
|
cd build/linux && wails3 task build PRODUCTION=true # Linux
|
||||||
|
cd build/darwin && wails3 task build PRODUCTION=true # macOS
|
||||||
|
|
||||||
|
# 4. 打包应用(各平台)
|
||||||
|
cd build/windows && wails3 task package # NSIS 安装程序
|
||||||
|
cd build/linux && wails3 task package # AppImage + deb + rpm + archlinux
|
||||||
|
cd build/darwin && wails3 task package # .app bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 注意事项
|
||||||
|
|
||||||
|
1. **变量传递:** Task 命令支持通过 `VAR=value` 格式传递变量
|
||||||
|
2. **路径问题:** 相对路径基于 Taskfile.yml 所在目录
|
||||||
|
3. **依赖顺序:** 某些任务有依赖关系(通过 `deps:` 定义)
|
||||||
|
4. **环境变量:** 使用 `env:` 定义的环境变量会自动设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [Wails 3 官方文档](https://v3alpha.wails.io/)
|
||||||
|
- [Taskfile 语法](https://taskfile.dev/)
|
||||||
|
- [nfpm 打包工具](https://nfpm.goreleaser.com/)
|
||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.9 KiB |
@@ -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 注册模型与表的映射关系
|
||||||
*/
|
*/
|
||||||
|
|||||||
4424
frontend/package-lock.json
generated
4424
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -47,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Enclose abbreviations in <abbr> tags
|
||||||
|
//
|
||||||
|
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环境接口,包含缩写定义
|
||||||
|
*/
|
||||||
|
interface AbbrEnv {
|
||||||
|
abbreviations?: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-abbr 插件
|
||||||
|
* 用于支持缩写语法
|
||||||
|
*/
|
||||||
|
export default function abbr_plugin(md: MarkdownIt): void {
|
||||||
|
const escapeRE = md.utils.escapeRE;
|
||||||
|
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||||
|
|
||||||
|
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
|
||||||
|
// you can check character classes here:
|
||||||
|
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
|
||||||
|
const OTHER_CHARS = ' \r\n$+<=>^`|~';
|
||||||
|
|
||||||
|
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
|
||||||
|
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
|
||||||
|
|
||||||
|
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||||
|
let labelEnd: number;
|
||||||
|
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||||
|
const max = state.eMarks[startLine];
|
||||||
|
|
||||||
|
if (pos + 2 >= max) { return false; }
|
||||||
|
|
||||||
|
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
|
||||||
|
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
|
||||||
|
|
||||||
|
const labelStart = pos;
|
||||||
|
|
||||||
|
for (; pos < max; pos++) {
|
||||||
|
const ch = state.src.charCodeAt(pos);
|
||||||
|
if (ch === 0x5B /* [ */) {
|
||||||
|
return false;
|
||||||
|
} else if (ch === 0x5D /* ] */) {
|
||||||
|
labelEnd = pos;
|
||||||
|
break;
|
||||||
|
} else if (ch === 0x5C /* \ */) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silent) { return true; }
|
||||||
|
|
||||||
|
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
|
||||||
|
const title = state.src.slice(labelEnd! + 2, max).trim();
|
||||||
|
if (label.length === 0) { return false; }
|
||||||
|
if (title.length === 0) { return false; }
|
||||||
|
|
||||||
|
const env = state.env as AbbrEnv;
|
||||||
|
if (!env.abbreviations) { env.abbreviations = {}; }
|
||||||
|
// prepend ':' to avoid conflict with Object.prototype members
|
||||||
|
if (typeof env.abbreviations[':' + label] === 'undefined') {
|
||||||
|
env.abbreviations[':' + label] = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.line = startLine + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abbr_replace(state: StateCore): void {
|
||||||
|
const blockTokens = state.tokens;
|
||||||
|
|
||||||
|
const env = state.env as AbbrEnv;
|
||||||
|
if (!env.abbreviations) { return; }
|
||||||
|
|
||||||
|
const regSimple = new RegExp('(?:' +
|
||||||
|
Object.keys(env.abbreviations).map(function (x: string) {
|
||||||
|
return x.substr(1);
|
||||||
|
}).sort(function (a: string, b: string) {
|
||||||
|
return b.length - a.length;
|
||||||
|
}).map(escapeRE).join('|') +
|
||||||
|
')');
|
||||||
|
|
||||||
|
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||||
|
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
|
||||||
|
'(' + Object.keys(env.abbreviations).map(function (x: string) {
|
||||||
|
return x.substr(1);
|
||||||
|
}).sort(function (a: string, b: string) {
|
||||||
|
return b.length - a.length;
|
||||||
|
}).map(escapeRE).join('|') + ')' +
|
||||||
|
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||||
|
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
|
||||||
|
|
||||||
|
const reg = new RegExp(regText, 'g');
|
||||||
|
|
||||||
|
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||||
|
if (blockTokens[j].type !== 'inline') { continue; }
|
||||||
|
let tokens = blockTokens[j].children!;
|
||||||
|
|
||||||
|
// We scan from the end, to keep position when new tags added.
|
||||||
|
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||||
|
const currentToken = tokens[i];
|
||||||
|
if (currentToken.type !== 'text') { continue; }
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
const text = currentToken.content;
|
||||||
|
reg.lastIndex = 0;
|
||||||
|
const nodes: Token[] = [];
|
||||||
|
|
||||||
|
// fast regexp run to determine whether there are any abbreviated words
|
||||||
|
// in the current token
|
||||||
|
if (!regSimple.test(text)) { continue; }
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = reg.exec(text))) {
|
||||||
|
if (m.index > 0 || m[1].length > 0) {
|
||||||
|
const token = new state.Token('text', '', 0);
|
||||||
|
token.content = text.slice(pos, m.index + m[1].length);
|
||||||
|
nodes.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token_o = new state.Token('abbr_open', 'abbr', 1);
|
||||||
|
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
|
||||||
|
nodes.push(token_o);
|
||||||
|
|
||||||
|
const token_t = new state.Token('text', '', 0);
|
||||||
|
token_t.content = m[2];
|
||||||
|
nodes.push(token_t);
|
||||||
|
|
||||||
|
const token_c = new state.Token('abbr_close', 'abbr', -1);
|
||||||
|
nodes.push(token_c);
|
||||||
|
|
||||||
|
reg.lastIndex -= m[3].length;
|
||||||
|
pos = reg.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodes.length) { continue; }
|
||||||
|
|
||||||
|
if (pos < text.length) {
|
||||||
|
const token = new state.Token('text', '', 0);
|
||||||
|
token.content = text.slice(pos);
|
||||||
|
nodes.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace current node
|
||||||
|
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
|
||||||
|
|
||||||
|
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Process definition lists
|
||||||
|
//
|
||||||
|
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-deflist 插件
|
||||||
|
* 用于支持定义列表语法
|
||||||
|
*/
|
||||||
|
export default function deflist_plugin(md: MarkdownIt): void {
|
||||||
|
const isSpace = md.utils.isSpace;
|
||||||
|
|
||||||
|
// Search `[:~][\n ]`, returns next pos after marker on success
|
||||||
|
// or -1 on fail.
|
||||||
|
function skipMarker(state: StateBlock, line: number): number {
|
||||||
|
let start = state.bMarks[line] + state.tShift[line];
|
||||||
|
const max = state.eMarks[line];
|
||||||
|
|
||||||
|
if (start >= max) { return -1; }
|
||||||
|
|
||||||
|
// Check bullet
|
||||||
|
const marker = state.src.charCodeAt(start++);
|
||||||
|
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
|
||||||
|
|
||||||
|
const pos = state.skipSpaces(start);
|
||||||
|
|
||||||
|
// require space after ":"
|
||||||
|
if (start === pos) { return -1; }
|
||||||
|
|
||||||
|
// no empty definitions, e.g. " : "
|
||||||
|
if (pos >= max) { return -1; }
|
||||||
|
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTightParagraphs(state: StateBlock, idx: number): void {
|
||||||
|
const level = state.level + 2;
|
||||||
|
|
||||||
|
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
|
||||||
|
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
|
||||||
|
state.tokens[i + 2].hidden = true;
|
||||||
|
state.tokens[i].hidden = true;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||||
|
if (silent) {
|
||||||
|
// quirk: validation mode validates a dd block only, not a whole deflist
|
||||||
|
if (state.ddIndent < 0) { return false; }
|
||||||
|
return skipMarker(state, startLine) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextLine = startLine + 1;
|
||||||
|
if (nextLine >= endLine) { return false; }
|
||||||
|
|
||||||
|
if (state.isEmpty(nextLine)) {
|
||||||
|
nextLine++;
|
||||||
|
if (nextLine >= endLine) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.sCount[nextLine] < state.blkIndent) { return false; }
|
||||||
|
let contentStart = skipMarker(state, nextLine);
|
||||||
|
if (contentStart < 0) { return false; }
|
||||||
|
|
||||||
|
// Start list
|
||||||
|
const listTokIdx = state.tokens.length;
|
||||||
|
let tight = true;
|
||||||
|
|
||||||
|
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
|
||||||
|
const listLines: [number, number] = [startLine, 0];
|
||||||
|
token_dl_o.map = listLines;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Iterate list items
|
||||||
|
//
|
||||||
|
|
||||||
|
let dtLine = startLine;
|
||||||
|
let ddLine = nextLine;
|
||||||
|
|
||||||
|
// One definition list can contain multiple DTs,
|
||||||
|
// and one DT can be followed by multiple DDs.
|
||||||
|
//
|
||||||
|
// Thus, there is two loops here, and label is
|
||||||
|
// needed to break out of the second one
|
||||||
|
//
|
||||||
|
/* eslint no-labels:0,block-scoped-var:0 */
|
||||||
|
OUTER:
|
||||||
|
for (;;) {
|
||||||
|
let prevEmptyEnd = false;
|
||||||
|
|
||||||
|
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
|
||||||
|
token_dt_o.map = [dtLine, dtLine];
|
||||||
|
|
||||||
|
const token_i: Token = state.push('inline', '', 0);
|
||||||
|
token_i.map = [dtLine, dtLine];
|
||||||
|
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
|
||||||
|
token_i.children = [];
|
||||||
|
|
||||||
|
state.push('dt_close', 'dt', -1);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
|
||||||
|
const itemLines: [number, number] = [nextLine, 0];
|
||||||
|
token_dd_o.map = itemLines;
|
||||||
|
|
||||||
|
let pos = contentStart;
|
||||||
|
const max = state.eMarks[ddLine];
|
||||||
|
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
|
||||||
|
|
||||||
|
while (pos < max) {
|
||||||
|
const ch = state.src.charCodeAt(pos);
|
||||||
|
|
||||||
|
if (isSpace(ch)) {
|
||||||
|
if (ch === 0x09) {
|
||||||
|
offset += 4 - offset % 4;
|
||||||
|
} else {
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStart = pos;
|
||||||
|
|
||||||
|
const oldTight = state.tight;
|
||||||
|
const oldDDIndent = state.ddIndent;
|
||||||
|
const oldIndent = state.blkIndent;
|
||||||
|
const oldTShift = state.tShift[ddLine];
|
||||||
|
const oldSCount = state.sCount[ddLine];
|
||||||
|
const oldParentType = state.parentType;
|
||||||
|
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
|
||||||
|
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
|
||||||
|
state.sCount[ddLine] = offset;
|
||||||
|
state.tight = true;
|
||||||
|
state.parentType = 'deflist' as any;
|
||||||
|
|
||||||
|
state.md.block.tokenize(state, ddLine, endLine);
|
||||||
|
|
||||||
|
// If any of list item is tight, mark list as tight
|
||||||
|
if (!state.tight || prevEmptyEnd) {
|
||||||
|
tight = false;
|
||||||
|
}
|
||||||
|
// Item become loose if finish with empty line,
|
||||||
|
// but we should filter last element, because it means list finish
|
||||||
|
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
|
||||||
|
|
||||||
|
state.tShift[ddLine] = oldTShift;
|
||||||
|
state.sCount[ddLine] = oldSCount;
|
||||||
|
state.tight = oldTight;
|
||||||
|
state.parentType = oldParentType;
|
||||||
|
state.blkIndent = oldIndent;
|
||||||
|
state.ddIndent = oldDDIndent;
|
||||||
|
|
||||||
|
state.push('dd_close', 'dd', -1);
|
||||||
|
|
||||||
|
itemLines[1] = nextLine = state.line;
|
||||||
|
|
||||||
|
if (nextLine >= endLine) { break OUTER; }
|
||||||
|
|
||||||
|
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
|
||||||
|
contentStart = skipMarker(state, nextLine);
|
||||||
|
if (contentStart < 0) { break; }
|
||||||
|
|
||||||
|
ddLine = nextLine;
|
||||||
|
|
||||||
|
// go to the next loop iteration:
|
||||||
|
// insert DD tag and repeat checking
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLine >= endLine) { break; }
|
||||||
|
dtLine = nextLine;
|
||||||
|
|
||||||
|
if (state.isEmpty(dtLine)) { break; }
|
||||||
|
if (state.sCount[dtLine] < state.blkIndent) { break; }
|
||||||
|
|
||||||
|
ddLine = dtLine + 1;
|
||||||
|
if (ddLine >= endLine) { break; }
|
||||||
|
if (state.isEmpty(ddLine)) { ddLine++; }
|
||||||
|
if (ddLine >= endLine) { break; }
|
||||||
|
|
||||||
|
if (state.sCount[ddLine] < state.blkIndent) { break; }
|
||||||
|
contentStart = skipMarker(state, ddLine);
|
||||||
|
if (contentStart < 0) { break; }
|
||||||
|
|
||||||
|
// go to the next loop iteration:
|
||||||
|
// insert DT and DD tags and repeat checking
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finilize list
|
||||||
|
state.push('dl_close', 'dl', -1);
|
||||||
|
|
||||||
|
listLines[1] = nextLine;
|
||||||
|
|
||||||
|
state.line = nextLine;
|
||||||
|
|
||||||
|
// mark paragraphs tight if needed
|
||||||
|
if (tight) {
|
||||||
|
markTightParagraphs(state, listTokIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as bare } from './lib/bare';
|
||||||
|
export { default as light } from './lib/light';
|
||||||
|
export { default as full } from './lib/full';
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import emoji_html from './render';
|
||||||
|
import emoji_replace from './replace';
|
||||||
|
import normalize_opts, { EmojiOptions } from './normalize_opts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare emoji 插件(不包含预定义的 emoji 数据)
|
||||||
|
*/
|
||||||
|
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||||
|
const defaults: EmojiOptions = {
|
||||||
|
defs: {},
|
||||||
|
shortcuts: {},
|
||||||
|
enabled: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
|
||||||
|
|
||||||
|
md.renderer.rules.emoji = emoji_html;
|
||||||
|
|
||||||
|
md.core.ruler.after(
|
||||||
|
'linkify',
|
||||||
|
'emoji',
|
||||||
|
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
|||||||
|
// Generated, don't edit
|
||||||
|
import { EmojiDefs } from '../normalize_opts';
|
||||||
|
|
||||||
|
const emojies: EmojiDefs = {
|
||||||
|
"grinning": "😀",
|
||||||
|
"smiley": "😃",
|
||||||
|
"smile": "😄",
|
||||||
|
"grin": "😁",
|
||||||
|
"laughing": "😆",
|
||||||
|
"satisfied": "😆",
|
||||||
|
"sweat_smile": "😅",
|
||||||
|
"joy": "😂",
|
||||||
|
"wink": "😉",
|
||||||
|
"blush": "😊",
|
||||||
|
"innocent": "😇",
|
||||||
|
"heart_eyes": "😍",
|
||||||
|
"kissing_heart": "😘",
|
||||||
|
"kissing": "😗",
|
||||||
|
"kissing_closed_eyes": "😚",
|
||||||
|
"kissing_smiling_eyes": "😙",
|
||||||
|
"yum": "😋",
|
||||||
|
"stuck_out_tongue": "😛",
|
||||||
|
"stuck_out_tongue_winking_eye": "😜",
|
||||||
|
"stuck_out_tongue_closed_eyes": "😝",
|
||||||
|
"neutral_face": "😐",
|
||||||
|
"expressionless": "😑",
|
||||||
|
"no_mouth": "😶",
|
||||||
|
"smirk": "😏",
|
||||||
|
"unamused": "😒",
|
||||||
|
"relieved": "😌",
|
||||||
|
"pensive": "😔",
|
||||||
|
"sleepy": "😪",
|
||||||
|
"sleeping": "😴",
|
||||||
|
"mask": "😷",
|
||||||
|
"dizzy_face": "😵",
|
||||||
|
"sunglasses": "😎",
|
||||||
|
"confused": "😕",
|
||||||
|
"worried": "😟",
|
||||||
|
"open_mouth": "😮",
|
||||||
|
"hushed": "😯",
|
||||||
|
"astonished": "😲",
|
||||||
|
"flushed": "😳",
|
||||||
|
"frowning": "😦",
|
||||||
|
"anguished": "😧",
|
||||||
|
"fearful": "😨",
|
||||||
|
"cold_sweat": "😰",
|
||||||
|
"disappointed_relieved": "😥",
|
||||||
|
"cry": "😢",
|
||||||
|
"sob": "😭",
|
||||||
|
"scream": "😱",
|
||||||
|
"confounded": "😖",
|
||||||
|
"persevere": "😣",
|
||||||
|
"disappointed": "😞",
|
||||||
|
"sweat": "😓",
|
||||||
|
"weary": "😩",
|
||||||
|
"tired_face": "😫",
|
||||||
|
"rage": "😡",
|
||||||
|
"pout": "😡",
|
||||||
|
"angry": "😠",
|
||||||
|
"smiling_imp": "😈",
|
||||||
|
"smiley_cat": "😺",
|
||||||
|
"smile_cat": "😸",
|
||||||
|
"joy_cat": "😹",
|
||||||
|
"heart_eyes_cat": "😻",
|
||||||
|
"smirk_cat": "😼",
|
||||||
|
"kissing_cat": "😽",
|
||||||
|
"scream_cat": "🙀",
|
||||||
|
"crying_cat_face": "😿",
|
||||||
|
"pouting_cat": "😾",
|
||||||
|
"heart": "❤️",
|
||||||
|
"hand": "✋",
|
||||||
|
"raised_hand": "✋",
|
||||||
|
"v": "✌️",
|
||||||
|
"point_up": "☝️",
|
||||||
|
"fist_raised": "✊",
|
||||||
|
"fist": "✊",
|
||||||
|
"monkey_face": "🐵",
|
||||||
|
"cat": "🐱",
|
||||||
|
"cow": "🐮",
|
||||||
|
"mouse": "🐭",
|
||||||
|
"coffee": "☕",
|
||||||
|
"hotsprings": "♨️",
|
||||||
|
"anchor": "⚓",
|
||||||
|
"airplane": "✈️",
|
||||||
|
"hourglass": "⌛",
|
||||||
|
"watch": "⌚",
|
||||||
|
"sunny": "☀️",
|
||||||
|
"star": "⭐",
|
||||||
|
"cloud": "☁️",
|
||||||
|
"umbrella": "☔",
|
||||||
|
"zap": "⚡",
|
||||||
|
"snowflake": "❄️",
|
||||||
|
"sparkles": "✨",
|
||||||
|
"black_joker": "🃏",
|
||||||
|
"mahjong": "🀄",
|
||||||
|
"phone": "☎️",
|
||||||
|
"telephone": "☎️",
|
||||||
|
"envelope": "✉️",
|
||||||
|
"pencil2": "✏️",
|
||||||
|
"black_nib": "✒️",
|
||||||
|
"scissors": "✂️",
|
||||||
|
"wheelchair": "♿",
|
||||||
|
"warning": "⚠️",
|
||||||
|
"aries": "♈",
|
||||||
|
"taurus": "♉",
|
||||||
|
"gemini": "♊",
|
||||||
|
"cancer": "♋",
|
||||||
|
"leo": "♌",
|
||||||
|
"virgo": "♍",
|
||||||
|
"libra": "♎",
|
||||||
|
"scorpius": "♏",
|
||||||
|
"sagittarius": "♐",
|
||||||
|
"capricorn": "♑",
|
||||||
|
"aquarius": "♒",
|
||||||
|
"pisces": "♓",
|
||||||
|
"heavy_multiplication_x": "✖️",
|
||||||
|
"heavy_plus_sign": "➕",
|
||||||
|
"heavy_minus_sign": "➖",
|
||||||
|
"heavy_division_sign": "➗",
|
||||||
|
"bangbang": "‼️",
|
||||||
|
"interrobang": "⁉️",
|
||||||
|
"question": "❓",
|
||||||
|
"grey_question": "❔",
|
||||||
|
"grey_exclamation": "❕",
|
||||||
|
"exclamation": "❗",
|
||||||
|
"heavy_exclamation_mark": "❗",
|
||||||
|
"wavy_dash": "〰️",
|
||||||
|
"recycle": "♻️",
|
||||||
|
"white_check_mark": "✅",
|
||||||
|
"ballot_box_with_check": "☑️",
|
||||||
|
"heavy_check_mark": "✔️",
|
||||||
|
"x": "❌",
|
||||||
|
"negative_squared_cross_mark": "❎",
|
||||||
|
"curly_loop": "➰",
|
||||||
|
"loop": "➿",
|
||||||
|
"part_alternation_mark": "〽️",
|
||||||
|
"eight_spoked_asterisk": "✳️",
|
||||||
|
"eight_pointed_black_star": "✴️",
|
||||||
|
"sparkle": "❇️",
|
||||||
|
"copyright": "©️",
|
||||||
|
"registered": "®️",
|
||||||
|
"tm": "™️",
|
||||||
|
"information_source": "ℹ️",
|
||||||
|
"m": "Ⓜ️",
|
||||||
|
"black_circle": "⚫",
|
||||||
|
"white_circle": "⚪",
|
||||||
|
"black_large_square": "⬛",
|
||||||
|
"white_large_square": "⬜",
|
||||||
|
"black_medium_square": "◼️",
|
||||||
|
"white_medium_square": "◻️",
|
||||||
|
"black_medium_small_square": "◾",
|
||||||
|
"white_medium_small_square": "◽",
|
||||||
|
"black_small_square": "▪️",
|
||||||
|
"white_small_square": "▫️"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default emojies;
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Emoticons -> Emoji mapping.
|
||||||
|
//
|
||||||
|
// (!) Some patterns skipped, to avoid collisions
|
||||||
|
// without increase matcher complicity. Than can change in future.
|
||||||
|
//
|
||||||
|
// Places to look for more emoticons info:
|
||||||
|
//
|
||||||
|
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
|
||||||
|
// - https://github.com/wooorm/emoticon/blob/master/Support.md
|
||||||
|
// - http://factoryjoe.com/projects/emoticons/
|
||||||
|
//
|
||||||
|
|
||||||
|
import { EmojiShortcuts } from '../normalize_opts';
|
||||||
|
|
||||||
|
const shortcuts: EmojiShortcuts = {
|
||||||
|
angry: ['>:(', '>:-('],
|
||||||
|
blush: [':")', ':-")'],
|
||||||
|
broken_heart: ['</3', '<\\3'],
|
||||||
|
// :\ and :-\ not used because of conflict with markdown escaping
|
||||||
|
confused: [':/', ':-/'], // twemoji shows question
|
||||||
|
cry: [":'(", ":'-(", ':,(', ':,-('],
|
||||||
|
frowning: [':(', ':-('],
|
||||||
|
heart: ['<3'],
|
||||||
|
imp: [']:(', ']:-('],
|
||||||
|
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
|
||||||
|
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
|
||||||
|
kissing: [':*', ':-*'],
|
||||||
|
laughing: ['x-)', 'X-)'],
|
||||||
|
neutral_face: [':|', ':-|'],
|
||||||
|
open_mouth: [':o', ':-o', ':O', ':-O'],
|
||||||
|
rage: [':@', ':-@'],
|
||||||
|
smile: [':D', ':-D'],
|
||||||
|
smiley: [':)', ':-)'],
|
||||||
|
smiling_imp: [']:)', ']:-)'],
|
||||||
|
sob: [":,'(", ":,'-(", ';(', ';-('],
|
||||||
|
stuck_out_tongue: [':P', ':-P'],
|
||||||
|
sunglasses: ['8-)', 'B-)'],
|
||||||
|
sweat: [',:(', ',:-('],
|
||||||
|
sweat_smile: [',:)', ',:-)'],
|
||||||
|
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
|
||||||
|
wink: [';)', ';-)']
|
||||||
|
};
|
||||||
|
|
||||||
|
export default shortcuts;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import emojies_defs from './data/full';
|
||||||
|
import emojies_shortcuts from './data/shortcuts';
|
||||||
|
import bare_emoji_plugin from './bare';
|
||||||
|
import { EmojiOptions } from './normalize_opts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full emoji 插件(包含完整的 emoji 数据)
|
||||||
|
*/
|
||||||
|
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||||
|
const defaults: EmojiOptions = {
|
||||||
|
defs: emojies_defs,
|
||||||
|
shortcuts: emojies_shortcuts,
|
||||||
|
enabled: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||||
|
|
||||||
|
bare_emoji_plugin(md, opts);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import emojies_defs from './data/light';
|
||||||
|
import emojies_shortcuts from './data/shortcuts';
|
||||||
|
import bare_emoji_plugin from './bare';
|
||||||
|
import { EmojiOptions } from './normalize_opts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light emoji 插件(包含常用的 emoji 数据)
|
||||||
|
*/
|
||||||
|
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||||
|
const defaults: EmojiOptions = {
|
||||||
|
defs: emojies_defs,
|
||||||
|
shortcuts: emojies_shortcuts,
|
||||||
|
enabled: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||||
|
|
||||||
|
bare_emoji_plugin(md, opts);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Emoji 定义类型
|
||||||
|
*/
|
||||||
|
export interface EmojiDefs {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji 快捷方式类型
|
||||||
|
*/
|
||||||
|
export interface EmojiShortcuts {
|
||||||
|
[key: string]: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入选项接口
|
||||||
|
*/
|
||||||
|
export interface EmojiOptions {
|
||||||
|
defs: EmojiDefs;
|
||||||
|
shortcuts: EmojiShortcuts;
|
||||||
|
enabled: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化后的选项接口
|
||||||
|
*/
|
||||||
|
export interface NormalizedEmojiOptions {
|
||||||
|
defs: EmojiDefs;
|
||||||
|
shortcuts: { [key: string]: string };
|
||||||
|
scanRE: RegExp;
|
||||||
|
replaceRE: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义正则表达式特殊字符
|
||||||
|
*/
|
||||||
|
function quoteRE(str: string): string {
|
||||||
|
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将输入选项转换为更可用的格式并编译搜索正则表达式
|
||||||
|
*/
|
||||||
|
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
|
||||||
|
let emojies = options.defs;
|
||||||
|
|
||||||
|
// Filter emojies by whitelist, if needed
|
||||||
|
if (options.enabled.length) {
|
||||||
|
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
|
||||||
|
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten shortcuts to simple object: { alias: emoji_name }
|
||||||
|
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
|
||||||
|
// Skip aliases for filtered emojies, to reduce regexp
|
||||||
|
if (!emojies[key]) return acc;
|
||||||
|
|
||||||
|
if (Array.isArray(options.shortcuts[key])) {
|
||||||
|
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[options.shortcuts[key] as string] = key;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const keys = Object.keys(emojies);
|
||||||
|
let names: string;
|
||||||
|
|
||||||
|
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
|
||||||
|
if (keys.length === 0) {
|
||||||
|
names = '^$';
|
||||||
|
} else {
|
||||||
|
// Compile regexp
|
||||||
|
names = keys
|
||||||
|
.map((name: string) => { return `:${name}:`; })
|
||||||
|
.concat(Object.keys(shortcuts))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.map((name: string) => { return quoteRE(name); })
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
const scanRE = RegExp(names);
|
||||||
|
const replaceRE = RegExp(names, 'g');
|
||||||
|
|
||||||
|
return {
|
||||||
|
defs: emojies,
|
||||||
|
shortcuts,
|
||||||
|
scanRE,
|
||||||
|
replaceRE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Token } from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji 渲染函数
|
||||||
|
*/
|
||||||
|
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||||
|
return tokens[idx].content;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import MarkdownIt, { StateCore, Token } from 'markdown-it';
|
||||||
|
import { EmojiDefs } from './normalize_opts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji 和快捷方式替换逻辑
|
||||||
|
*
|
||||||
|
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
|
||||||
|
* 但是,谁在乎呢...
|
||||||
|
*/
|
||||||
|
export default function create_rule(
|
||||||
|
md: MarkdownIt,
|
||||||
|
emojies: EmojiDefs,
|
||||||
|
shortcuts: { [key: string]: string },
|
||||||
|
scanRE: RegExp,
|
||||||
|
replaceRE: RegExp
|
||||||
|
) {
|
||||||
|
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||||
|
const ucm = md.utils.lib.ucmicro;
|
||||||
|
const has = md.utils.has;
|
||||||
|
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
|
||||||
|
|
||||||
|
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
|
||||||
|
let last_pos = 0;
|
||||||
|
const nodes: Token[] = [];
|
||||||
|
|
||||||
|
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
|
||||||
|
let emoji_name: string;
|
||||||
|
// Validate emoji name
|
||||||
|
if (has(shortcuts, match)) {
|
||||||
|
// replace shortcut with full name
|
||||||
|
emoji_name = shortcuts[match];
|
||||||
|
|
||||||
|
// Don't allow letters before any shortcut (as in no ":/" in http://)
|
||||||
|
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
|
||||||
|
|
||||||
|
// Don't allow letters after any shortcut
|
||||||
|
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emoji_name = match.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new tokens to pending list
|
||||||
|
if (offset > last_pos) {
|
||||||
|
const token = new TokenConstructor('text', '', 0);
|
||||||
|
token.content = text.slice(last_pos, offset);
|
||||||
|
nodes.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = new TokenConstructor('emoji', '', 0);
|
||||||
|
token.markup = emoji_name;
|
||||||
|
token.content = emojies[emoji_name];
|
||||||
|
nodes.push(token);
|
||||||
|
|
||||||
|
last_pos = offset + match.length;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (last_pos < text.length) {
|
||||||
|
const token = new TokenConstructor('text', '', 0);
|
||||||
|
token.content = text.slice(last_pos);
|
||||||
|
nodes.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return function emoji_replace(state: StateCore): void {
|
||||||
|
let token: Token;
|
||||||
|
const blockTokens = state.tokens;
|
||||||
|
let autolinkLevel = 0;
|
||||||
|
|
||||||
|
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||||
|
if (blockTokens[j].type !== 'inline') { continue; }
|
||||||
|
let tokens = blockTokens[j].children!;
|
||||||
|
|
||||||
|
// We scan from the end, to keep position when new tags added.
|
||||||
|
// Use reversed logic in links start/end match
|
||||||
|
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||||
|
token = tokens[i];
|
||||||
|
|
||||||
|
if (token.type === 'link_open' || token.type === 'link_close') {
|
||||||
|
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
|
||||||
|
// replace current node
|
||||||
|
blockTokens[j].children = tokens = arrayReplaceAt(
|
||||||
|
tokens, i, splitTextToken(token.content, token.level, state.Token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脚注元数据接口
|
||||||
|
*/
|
||||||
|
interface FootnoteMeta {
|
||||||
|
id: number;
|
||||||
|
subId: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脚注列表项接口
|
||||||
|
*/
|
||||||
|
interface FootnoteItem {
|
||||||
|
label?: string;
|
||||||
|
content?: string;
|
||||||
|
tokens?: Token[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环境接口
|
||||||
|
*/
|
||||||
|
interface FootnoteEnv {
|
||||||
|
footnotes?: {
|
||||||
|
refs?: { [key: string]: number };
|
||||||
|
list?: FootnoteItem[];
|
||||||
|
};
|
||||||
|
docId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// /////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Renderer partials
|
||||||
|
|
||||||
|
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
|
||||||
|
const n = Number(tokens[idx].meta.id + 1).toString();
|
||||||
|
let prefix = '';
|
||||||
|
|
||||||
|
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
|
||||||
|
|
||||||
|
return prefix + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_caption(tokens: Token[], idx: number): string {
|
||||||
|
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||||
|
|
||||||
|
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||||
|
|
||||||
|
return `[${n}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||||
|
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||||
|
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
|
||||||
|
let refid = id;
|
||||||
|
|
||||||
|
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||||
|
|
||||||
|
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
|
||||||
|
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
|
||||||
|
'<section class="footnotes">\n' +
|
||||||
|
'<ol class="footnotes-list">\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_block_close(): string {
|
||||||
|
return '</ol>\n</section>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||||
|
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||||
|
|
||||||
|
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||||
|
|
||||||
|
return `<li id="fn${id}" class="footnote-item">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_close(): string {
|
||||||
|
return '</li>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||||
|
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||||
|
|
||||||
|
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||||
|
|
||||||
|
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||||
|
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-footnote 插件
|
||||||
|
* 用于支持脚注语法
|
||||||
|
*/
|
||||||
|
export default function footnote_plugin(md: MarkdownIt): void {
|
||||||
|
const parseLinkLabel = md.helpers.parseLinkLabel;
|
||||||
|
const isSpace = md.utils.isSpace;
|
||||||
|
|
||||||
|
md.renderer.rules.footnote_ref = render_footnote_ref;
|
||||||
|
md.renderer.rules.footnote_block_open = render_footnote_block_open;
|
||||||
|
md.renderer.rules.footnote_block_close = render_footnote_block_close;
|
||||||
|
md.renderer.rules.footnote_open = render_footnote_open;
|
||||||
|
md.renderer.rules.footnote_close = render_footnote_close;
|
||||||
|
md.renderer.rules.footnote_anchor = render_footnote_anchor;
|
||||||
|
|
||||||
|
// helpers (only used in other rules, no tokens are attached to those)
|
||||||
|
md.renderer.rules.footnote_caption = render_footnote_caption;
|
||||||
|
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
|
||||||
|
|
||||||
|
// Process footnote block definition
|
||||||
|
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||||
|
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||||
|
const max = state.eMarks[startLine];
|
||||||
|
|
||||||
|
// line should be at least 5 chars - "[^x]:"
|
||||||
|
if (start + 4 > max) return false;
|
||||||
|
|
||||||
|
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||||
|
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||||
|
|
||||||
|
let pos: number;
|
||||||
|
|
||||||
|
for (pos = start + 2; pos < max; pos++) {
|
||||||
|
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||||
|
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos === start + 2) return false; // no empty footnote labels
|
||||||
|
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
|
||||||
|
if (silent) return true;
|
||||||
|
pos++;
|
||||||
|
|
||||||
|
const env = state.env as FootnoteEnv;
|
||||||
|
if (!env.footnotes) env.footnotes = {};
|
||||||
|
if (!env.footnotes.refs) env.footnotes.refs = {};
|
||||||
|
const label = state.src.slice(start + 2, pos - 2);
|
||||||
|
env.footnotes.refs[`:${label}`] = -1;
|
||||||
|
|
||||||
|
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
|
||||||
|
token_fref_o.meta = { label };
|
||||||
|
token_fref_o.level = state.level++;
|
||||||
|
state.tokens.push(token_fref_o);
|
||||||
|
|
||||||
|
const oldBMark = state.bMarks[startLine];
|
||||||
|
const oldTShift = state.tShift[startLine];
|
||||||
|
const oldSCount = state.sCount[startLine];
|
||||||
|
const oldParentType = state.parentType;
|
||||||
|
|
||||||
|
const posAfterColon = pos;
|
||||||
|
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
|
||||||
|
let offset = initial;
|
||||||
|
|
||||||
|
while (pos < max) {
|
||||||
|
const ch = state.src.charCodeAt(pos);
|
||||||
|
|
||||||
|
if (isSpace(ch)) {
|
||||||
|
if (ch === 0x09) {
|
||||||
|
offset += 4 - offset % 4;
|
||||||
|
} else {
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tShift[startLine] = pos - posAfterColon;
|
||||||
|
state.sCount[startLine] = offset - initial;
|
||||||
|
|
||||||
|
state.bMarks[startLine] = posAfterColon;
|
||||||
|
state.blkIndent += 4;
|
||||||
|
state.parentType = 'footnote' as any;
|
||||||
|
|
||||||
|
if (state.sCount[startLine] < state.blkIndent) {
|
||||||
|
state.sCount[startLine] += state.blkIndent;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.md.block.tokenize(state, startLine, endLine);
|
||||||
|
|
||||||
|
state.parentType = oldParentType;
|
||||||
|
state.blkIndent -= 4;
|
||||||
|
state.tShift[startLine] = oldTShift;
|
||||||
|
state.sCount[startLine] = oldSCount;
|
||||||
|
state.bMarks[startLine] = oldBMark;
|
||||||
|
|
||||||
|
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
|
||||||
|
token_fref_c.level = --state.level;
|
||||||
|
state.tokens.push(token_fref_c);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process inline footnotes (^[...])
|
||||||
|
function footnote_inline(state: StateInline, silent: boolean): boolean {
|
||||||
|
const max = state.posMax;
|
||||||
|
const start = state.pos;
|
||||||
|
|
||||||
|
if (start + 2 >= max) return false;
|
||||||
|
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
|
||||||
|
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
|
||||||
|
|
||||||
|
const labelStart = start + 2;
|
||||||
|
const labelEnd = parseLinkLabel(state, start + 1);
|
||||||
|
|
||||||
|
// parser failed to find ']', so it's not a valid note
|
||||||
|
if (labelEnd < 0) return false;
|
||||||
|
|
||||||
|
// We found the end of the link, and know for a fact it's a valid link;
|
||||||
|
// so all that's left to do is to call tokenizer.
|
||||||
|
//
|
||||||
|
if (!silent) {
|
||||||
|
const env = state.env as FootnoteEnv;
|
||||||
|
if (!env.footnotes) env.footnotes = {};
|
||||||
|
if (!env.footnotes.list) env.footnotes.list = [];
|
||||||
|
const footnoteId = env.footnotes.list.length;
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
state.md.inline.parse(
|
||||||
|
state.src.slice(labelStart, labelEnd),
|
||||||
|
state.md,
|
||||||
|
state.env,
|
||||||
|
tokens
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = state.push('footnote_ref', '', 0);
|
||||||
|
token.meta = { id: footnoteId };
|
||||||
|
|
||||||
|
env.footnotes.list[footnoteId] = {
|
||||||
|
content: state.src.slice(labelStart, labelEnd),
|
||||||
|
tokens,
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos = labelEnd + 1;
|
||||||
|
state.posMax = max;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process footnote references ([^...])
|
||||||
|
function footnote_ref(state: StateInline, silent: boolean): boolean {
|
||||||
|
const max = state.posMax;
|
||||||
|
const start = state.pos;
|
||||||
|
|
||||||
|
// should be at least 4 chars - "[^x]"
|
||||||
|
if (start + 3 > max) return false;
|
||||||
|
|
||||||
|
const env = state.env as FootnoteEnv;
|
||||||
|
if (!env.footnotes || !env.footnotes.refs) return false;
|
||||||
|
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||||
|
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||||
|
|
||||||
|
let pos: number;
|
||||||
|
|
||||||
|
for (pos = start + 2; pos < max; pos++) {
|
||||||
|
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||||
|
if (state.src.charCodeAt(pos) === 0x0A) return false;
|
||||||
|
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos === start + 2) return false; // no empty footnote labels
|
||||||
|
if (pos >= max) return false;
|
||||||
|
pos++;
|
||||||
|
|
||||||
|
const label = state.src.slice(start + 2, pos - 1);
|
||||||
|
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
if (!env.footnotes.list) env.footnotes.list = [];
|
||||||
|
|
||||||
|
let footnoteId: number;
|
||||||
|
|
||||||
|
if (env.footnotes.refs[`:${label}`] < 0) {
|
||||||
|
footnoteId = env.footnotes.list.length;
|
||||||
|
env.footnotes.list[footnoteId] = { label, count: 0 };
|
||||||
|
env.footnotes.refs[`:${label}`] = footnoteId;
|
||||||
|
} else {
|
||||||
|
footnoteId = env.footnotes.refs[`:${label}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const footnoteSubId = env.footnotes.list[footnoteId].count;
|
||||||
|
env.footnotes.list[footnoteId].count++;
|
||||||
|
|
||||||
|
const token = state.push('footnote_ref', '', 0);
|
||||||
|
token.meta = { id: footnoteId, subId: footnoteSubId, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos = pos;
|
||||||
|
state.posMax = max;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glue footnote tokens to end of token stream
|
||||||
|
function footnote_tail(state: StateCore): void {
|
||||||
|
let tokens: Token[] | null = null;
|
||||||
|
let current: Token[];
|
||||||
|
let currentLabel: string;
|
||||||
|
let insideRef = false;
|
||||||
|
const refTokens: { [key: string]: Token[] } = {};
|
||||||
|
|
||||||
|
const env = state.env as FootnoteEnv;
|
||||||
|
if (!env.footnotes) { return; }
|
||||||
|
|
||||||
|
state.tokens = state.tokens.filter(function (tok) {
|
||||||
|
if (tok.type === 'footnote_reference_open') {
|
||||||
|
insideRef = true;
|
||||||
|
current = [];
|
||||||
|
currentLabel = tok.meta.label;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tok.type === 'footnote_reference_close') {
|
||||||
|
insideRef = false;
|
||||||
|
// prepend ':' to avoid conflict with Object.prototype members
|
||||||
|
refTokens[':' + currentLabel] = current;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (insideRef) { current.push(tok); }
|
||||||
|
return !insideRef;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!env.footnotes.list) { return; }
|
||||||
|
const list = env.footnotes.list;
|
||||||
|
|
||||||
|
state.tokens.push(new state.Token('footnote_block_open', '', 1));
|
||||||
|
|
||||||
|
for (let i = 0, l = list.length; i < l; i++) {
|
||||||
|
const token_fo = new state.Token('footnote_open', '', 1);
|
||||||
|
token_fo.meta = { id: i, label: list[i].label };
|
||||||
|
state.tokens.push(token_fo);
|
||||||
|
|
||||||
|
if (list[i].tokens) {
|
||||||
|
tokens = [];
|
||||||
|
|
||||||
|
const token_po = new state.Token('paragraph_open', 'p', 1);
|
||||||
|
token_po.block = true;
|
||||||
|
tokens.push(token_po);
|
||||||
|
|
||||||
|
const token_i = new state.Token('inline', '', 0);
|
||||||
|
token_i.children = list[i].tokens || null;
|
||||||
|
token_i.content = list[i].content || '';
|
||||||
|
tokens.push(token_i);
|
||||||
|
|
||||||
|
const token_pc = new state.Token('paragraph_close', 'p', -1);
|
||||||
|
token_pc.block = true;
|
||||||
|
tokens.push(token_pc);
|
||||||
|
} else if (list[i].label) {
|
||||||
|
tokens = refTokens[`:${list[i].label}`] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens) state.tokens = state.tokens.concat(tokens);
|
||||||
|
|
||||||
|
let lastParagraph: Token | null;
|
||||||
|
|
||||||
|
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
|
||||||
|
lastParagraph = state.tokens.pop()!;
|
||||||
|
} else {
|
||||||
|
lastParagraph = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = list[i].count > 0 ? list[i].count : 1;
|
||||||
|
for (let j = 0; j < t; j++) {
|
||||||
|
const token_a = new state.Token('footnote_anchor', '', 0);
|
||||||
|
token_a.meta = { id: i, subId: j, label: list[i].label };
|
||||||
|
state.tokens.push(token_a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastParagraph) {
|
||||||
|
state.tokens.push(lastParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tokens.push(new state.Token('footnote_close', '', -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tokens.push(new state.Token('footnote_block_close', '', -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
|
||||||
|
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
|
||||||
|
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
|
||||||
|
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
|
||||||
|
}
|
||||||
160
frontend/src/common/markdown-it/plugins/markdown-it-ins/index.ts
Normal file
160
frontend/src/common/markdown-it/plugins/markdown-it-ins/index.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分隔符接口定义
|
||||||
|
*/
|
||||||
|
interface Delimiter {
|
||||||
|
marker: number;
|
||||||
|
length: number;
|
||||||
|
jump: number;
|
||||||
|
token: number;
|
||||||
|
end: number;
|
||||||
|
open: boolean;
|
||||||
|
close: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描结果接口定义
|
||||||
|
*/
|
||||||
|
interface ScanResult {
|
||||||
|
can_open: boolean;
|
||||||
|
can_close: boolean;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 元数据接口定义
|
||||||
|
*/
|
||||||
|
interface TokenMeta {
|
||||||
|
delimiters?: Delimiter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-ins 插件
|
||||||
|
* 用于支持插入文本语法 ++text++
|
||||||
|
*/
|
||||||
|
export default function ins_plugin(md: MarkdownIt): void {
|
||||||
|
// Insert each marker as a separate text token, and add it to delimiter list
|
||||||
|
//
|
||||||
|
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||||
|
const start = state.pos;
|
||||||
|
const marker = state.src.charCodeAt(start);
|
||||||
|
|
||||||
|
if (silent) { return false; }
|
||||||
|
|
||||||
|
if (marker !== 0x2B/* + */) { return false; }
|
||||||
|
|
||||||
|
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||||
|
let len = scanned.length;
|
||||||
|
const ch = String.fromCharCode(marker);
|
||||||
|
|
||||||
|
if (len < 2) { return false; }
|
||||||
|
|
||||||
|
if (len % 2) {
|
||||||
|
const token: Token = state.push('text', '', 0);
|
||||||
|
token.content = ch;
|
||||||
|
len--;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i += 2) {
|
||||||
|
const token: Token = state.push('text', '', 0);
|
||||||
|
token.content = ch + ch;
|
||||||
|
|
||||||
|
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||||
|
|
||||||
|
state.delimiters.push({
|
||||||
|
marker,
|
||||||
|
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||||
|
jump: i / 2, // 1 delimiter = 2 characters
|
||||||
|
token: state.tokens.length - 1,
|
||||||
|
end: -1,
|
||||||
|
open: scanned.can_open,
|
||||||
|
close: scanned.can_close
|
||||||
|
} as Delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos += scanned.length;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through delimiter list and replace text tokens with tags
|
||||||
|
//
|
||||||
|
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||||
|
let token: Token;
|
||||||
|
const loneMarkers: number[] = [];
|
||||||
|
const max = delimiters.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
const startDelim = delimiters[i];
|
||||||
|
|
||||||
|
if (startDelim.marker !== 0x2B/* + */) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDelim.end === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDelim = delimiters[startDelim.end];
|
||||||
|
|
||||||
|
token = state.tokens[startDelim.token];
|
||||||
|
token.type = 'ins_open';
|
||||||
|
token.tag = 'ins';
|
||||||
|
token.nesting = 1;
|
||||||
|
token.markup = '++';
|
||||||
|
token.content = '';
|
||||||
|
|
||||||
|
token = state.tokens[endDelim.token];
|
||||||
|
token.type = 'ins_close';
|
||||||
|
token.tag = 'ins';
|
||||||
|
token.nesting = -1;
|
||||||
|
token.markup = '++';
|
||||||
|
token.content = '';
|
||||||
|
|
||||||
|
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||||
|
state.tokens[endDelim.token - 1].content === '+') {
|
||||||
|
loneMarkers.push(endDelim.token - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a marker sequence has an odd number of characters, it's splitted
|
||||||
|
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||||
|
// start of the sequence.
|
||||||
|
//
|
||||||
|
// So, we have to move all those markers after subsequent s_close tags.
|
||||||
|
//
|
||||||
|
while (loneMarkers.length) {
|
||||||
|
const i = loneMarkers.pop()!;
|
||||||
|
let j = i + 1;
|
||||||
|
|
||||||
|
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
j--;
|
||||||
|
|
||||||
|
if (i !== j) {
|
||||||
|
token = state.tokens[j];
|
||||||
|
state.tokens[j] = state.tokens[i];
|
||||||
|
state.tokens[i] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md.inline.ruler.before('emphasis', 'ins', tokenize);
|
||||||
|
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
|
||||||
|
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||||
|
const max = (state.tokens_meta || []).length;
|
||||||
|
|
||||||
|
postProcess(state, state.delimiters as Delimiter[]);
|
||||||
|
|
||||||
|
for (let curr = 0; curr < max; curr++) {
|
||||||
|
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||||
|
postProcess(state, tokens_meta[curr].delimiters!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import MarkdownIt, {StateInline, Token} from 'markdown-it';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分隔符接口定义
|
||||||
|
*/
|
||||||
|
interface Delimiter {
|
||||||
|
marker: number;
|
||||||
|
length: number;
|
||||||
|
jump: number;
|
||||||
|
token: number;
|
||||||
|
end: number;
|
||||||
|
open: boolean;
|
||||||
|
close: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描结果接口定义
|
||||||
|
*/
|
||||||
|
interface ScanResult {
|
||||||
|
can_open: boolean;
|
||||||
|
can_close: boolean;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 元数据接口定义
|
||||||
|
*/
|
||||||
|
interface TokenMeta {
|
||||||
|
delimiters?: Delimiter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-mark 插件
|
||||||
|
* 用于支持 ==标记文本== 语法
|
||||||
|
*/
|
||||||
|
export default function markPlugin(md: MarkdownIt): void {
|
||||||
|
// Insert each marker as a separate text token, and add it to delimiter list
|
||||||
|
//
|
||||||
|
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||||
|
const start = state.pos;
|
||||||
|
const marker = state.src.charCodeAt(start);
|
||||||
|
|
||||||
|
if (silent) { return false; }
|
||||||
|
|
||||||
|
if (marker !== 0x3D/* = */) { return false; }
|
||||||
|
|
||||||
|
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||||
|
let len = scanned.length;
|
||||||
|
const ch = String.fromCharCode(marker);
|
||||||
|
|
||||||
|
if (len < 2) { return false; }
|
||||||
|
|
||||||
|
if (len % 2) {
|
||||||
|
const token: Token = state.push('text', '', 0);
|
||||||
|
token.content = ch;
|
||||||
|
len--;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i += 2) {
|
||||||
|
const token: Token = state.push('text', '', 0);
|
||||||
|
token.content = ch + ch;
|
||||||
|
|
||||||
|
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||||
|
|
||||||
|
state.delimiters.push({
|
||||||
|
marker,
|
||||||
|
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||||
|
jump: i / 2, // 1 delimiter = 2 characters
|
||||||
|
token: state.tokens.length - 1,
|
||||||
|
end: -1,
|
||||||
|
open: scanned.can_open,
|
||||||
|
close: scanned.can_close
|
||||||
|
} as Delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos += scanned.length;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through delimiter list and replace text tokens with tags
|
||||||
|
//
|
||||||
|
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||||
|
const loneMarkers: number[] = [];
|
||||||
|
const max = delimiters.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
const startDelim = delimiters[i];
|
||||||
|
|
||||||
|
if (startDelim.marker !== 0x3D/* = */) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDelim.end === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDelim = delimiters[startDelim.end];
|
||||||
|
|
||||||
|
const token_o = state.tokens[startDelim.token];
|
||||||
|
token_o.type = 'mark_open';
|
||||||
|
token_o.tag = 'mark';
|
||||||
|
token_o.nesting = 1;
|
||||||
|
token_o.markup = '==';
|
||||||
|
token_o.content = '';
|
||||||
|
|
||||||
|
const token_c = state.tokens[endDelim.token];
|
||||||
|
token_c.type = 'mark_close';
|
||||||
|
token_c.tag = 'mark';
|
||||||
|
token_c.nesting = -1;
|
||||||
|
token_c.markup = '==';
|
||||||
|
token_c.content = '';
|
||||||
|
|
||||||
|
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||||
|
state.tokens[endDelim.token - 1].content === '=') {
|
||||||
|
loneMarkers.push(endDelim.token - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a marker sequence has an odd number of characters, it's splitted
|
||||||
|
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||||
|
// start of the sequence.
|
||||||
|
//
|
||||||
|
// So, we have to move all those markers after subsequent s_close tags.
|
||||||
|
//
|
||||||
|
while (loneMarkers.length) {
|
||||||
|
const i = loneMarkers.pop()!;
|
||||||
|
let j = i + 1;
|
||||||
|
|
||||||
|
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
j--;
|
||||||
|
|
||||||
|
if (i !== j) {
|
||||||
|
const token = state.tokens[j];
|
||||||
|
state.tokens[j] = state.tokens[i];
|
||||||
|
state.tokens[i] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md.inline.ruler.before('emphasis', 'mark', tokenize);
|
||||||
|
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
|
||||||
|
let curr: number;
|
||||||
|
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||||
|
const max = (state.tokens_meta || []).length;
|
||||||
|
|
||||||
|
postProcess(state, state.delimiters as Delimiter[]);
|
||||||
|
|
||||||
|
for (curr = 0; curr < max; curr++) {
|
||||||
|
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||||
|
postProcess(state, tokens_meta[curr].delimiters!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import mermaid from "mermaid";
|
||||||
|
import {genUid, hashCode, sleep} from "./utils";
|
||||||
|
|
||||||
|
const mermaidCache = new Map<string, HTMLElement>();
|
||||||
|
|
||||||
|
// 缓存计数器,用于清除缓存
|
||||||
|
const mermaidCacheCount = new Map<string, number>();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
|
||||||
|
let countTmo = setTimeout(() => undefined, 0);
|
||||||
|
const addCount = () => {
|
||||||
|
clearTimeout(countTmo);
|
||||||
|
countTmo = setTimeout(() => {
|
||||||
|
count++;
|
||||||
|
clearCache();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = () => {
|
||||||
|
for (const key of mermaidCacheCount.keys()) {
|
||||||
|
const value = mermaidCacheCount.get(key)!;
|
||||||
|
if (value + 3 < count) {
|
||||||
|
mermaidCache.delete(key);
|
||||||
|
mermaidCacheCount.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 mermaid
|
||||||
|
* @param code mermaid 代码
|
||||||
|
* @param targetId 目标 id
|
||||||
|
* @param count 计数器
|
||||||
|
*/
|
||||||
|
const renderMermaid = async (code: string, targetId: string, count: number) => {
|
||||||
|
let limit = 100;
|
||||||
|
while (limit-- > 0) {
|
||||||
|
const container = document.getElementById(targetId);
|
||||||
|
if (!container) {
|
||||||
|
await sleep(100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
|
||||||
|
container.innerHTML = svg;
|
||||||
|
mermaidCache.set(targetId, container);
|
||||||
|
mermaidCacheCount.set(targetId, count);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MermaidItOptions {
|
||||||
|
theme?: "default" | "dark" | "forest" | "neutral" | "base";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 mermaid 主题
|
||||||
|
*/
|
||||||
|
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: theme
|
||||||
|
});
|
||||||
|
// 清空缓存,强制重新渲染
|
||||||
|
mermaidCache.clear();
|
||||||
|
mermaidCacheCount.clear();
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mermaid 插件
|
||||||
|
* @param md markdown-it
|
||||||
|
* @param options 配置选项
|
||||||
|
* @constructor MermaidIt
|
||||||
|
*/
|
||||||
|
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
|
||||||
|
const theme = options?.theme || "default";
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: theme
|
||||||
|
});
|
||||||
|
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
|
||||||
|
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
|
||||||
|
addCount();
|
||||||
|
const token = tokens[idx];
|
||||||
|
const info = token.info.trim();
|
||||||
|
if (info === "mermaid") {
|
||||||
|
const containerId = "mermaid-container-" + hashCode(token.content);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = containerId;
|
||||||
|
if (mermaidCache.has(containerId)) {
|
||||||
|
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
|
||||||
|
mermaidCacheCount.set(containerId, count);
|
||||||
|
} else {
|
||||||
|
renderMermaid(token.content, containerId, count).then();
|
||||||
|
}
|
||||||
|
return container.outerHTML;
|
||||||
|
}
|
||||||
|
// 使用默认的渲染规则
|
||||||
|
return defaultRenderer(tokens, idx, options, env, self);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uuid 生成函数
|
||||||
|
* @param split 分隔符
|
||||||
|
*/
|
||||||
|
export const genUid = (split = "") => {
|
||||||
|
return uuidv4().split("-").join(split);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个简易的sleep函数
|
||||||
|
*/
|
||||||
|
export const sleep = async (ms: number) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算字符串的hash值
|
||||||
|
* 返回一个数字
|
||||||
|
* @param str
|
||||||
|
*/
|
||||||
|
export const hashCode = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
if (str.length === 0) return hash;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个简易的阻塞函数
|
||||||
|
*/
|
||||||
|
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (true) {
|
||||||
|
if (cb()) return true;
|
||||||
|
if (timeout && Date.now() - start > timeout) {
|
||||||
|
console.error("阻塞超时: " + errText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Process ~subscript~
|
||||||
|
|
||||||
|
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||||
|
|
||||||
|
// same as UNESCAPE_MD_RE plus a space
|
||||||
|
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||||
|
|
||||||
|
function subscript(state: StateInline, silent: boolean): boolean {
|
||||||
|
const max = state.posMax;
|
||||||
|
const start = state.pos;
|
||||||
|
|
||||||
|
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
|
||||||
|
if (silent) { return false; } // don't run any pairs in validation mode
|
||||||
|
if (start + 2 >= max) { return false; }
|
||||||
|
|
||||||
|
state.pos = start + 1;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
while (state.pos < max) {
|
||||||
|
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.md.inline.skipToken(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found || start + 1 === state.pos) {
|
||||||
|
state.pos = start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = state.src.slice(start + 1, state.pos);
|
||||||
|
|
||||||
|
// don't allow unescaped spaces/newlines inside
|
||||||
|
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||||
|
state.pos = start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// found!
|
||||||
|
state.posMax = state.pos;
|
||||||
|
state.pos = start + 1;
|
||||||
|
|
||||||
|
// Earlier we checked !silent, but this implementation does not need it
|
||||||
|
const token_so: Token = state.push('sub_open', 'sub', 1);
|
||||||
|
token_so.markup = '~';
|
||||||
|
|
||||||
|
const token_t: Token = state.push('text', '', 0);
|
||||||
|
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||||
|
|
||||||
|
const token_sc: Token = state.push('sub_close', 'sub', -1);
|
||||||
|
token_sc.markup = '~';
|
||||||
|
|
||||||
|
state.pos = state.posMax + 1;
|
||||||
|
state.posMax = max;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-sub 插件
|
||||||
|
* 用于支持下标语法 ~text~
|
||||||
|
*/
|
||||||
|
export default function sub_plugin(md: MarkdownIt): void {
|
||||||
|
md.inline.ruler.after('emphasis', 'sub', subscript);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Process ^superscript^
|
||||||
|
|
||||||
|
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||||
|
|
||||||
|
// same as UNESCAPE_MD_RE plus a space
|
||||||
|
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||||
|
|
||||||
|
function superscript(state: StateInline, silent: boolean): boolean {
|
||||||
|
const max = state.posMax;
|
||||||
|
const start = state.pos;
|
||||||
|
|
||||||
|
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
|
||||||
|
if (silent) { return false; } // don't run any pairs in validation mode
|
||||||
|
if (start + 2 >= max) { return false; }
|
||||||
|
|
||||||
|
state.pos = start + 1;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
while (state.pos < max) {
|
||||||
|
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.md.inline.skipToken(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found || start + 1 === state.pos) {
|
||||||
|
state.pos = start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = state.src.slice(start + 1, state.pos);
|
||||||
|
|
||||||
|
// don't allow unescaped spaces/newlines inside
|
||||||
|
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||||
|
state.pos = start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// found!
|
||||||
|
state.posMax = state.pos;
|
||||||
|
state.pos = start + 1;
|
||||||
|
|
||||||
|
// Earlier we checked !silent, but this implementation does not need it
|
||||||
|
const token_so: Token = state.push('sup_open', 'sup', 1);
|
||||||
|
token_so.markup = '^';
|
||||||
|
|
||||||
|
const token_t: Token = state.push('text', '', 0);
|
||||||
|
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||||
|
|
||||||
|
const token_sc: Token = state.push('sup_close', 'sup', -1);
|
||||||
|
token_sc.markup = '^';
|
||||||
|
|
||||||
|
state.pos = state.posMax + 1;
|
||||||
|
state.posMax = max;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown-it-sup 插件
|
||||||
|
* 用于支持上标语法 ^text^
|
||||||
|
*/
|
||||||
|
export default function sup_plugin(md: MarkdownIt): void {
|
||||||
|
md.inline.ruler.after('emphasis', 'sup', superscript);
|
||||||
|
}
|
||||||
329
frontend/src/common/utils/domDiff.test.ts
Normal file
329
frontend/src/common/utils/domDiff.test.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* DOM Diff 算法单元测试
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { morphNode, morphHTML, morphWithKeys } from './domDiff';
|
||||||
|
|
||||||
|
describe('DOM Diff Algorithm', () => {
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.removeChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 基础功能', () => {
|
||||||
|
test('应该更新文本节点内容', () => {
|
||||||
|
const fromNode = document.createTextNode('Hello');
|
||||||
|
const toNode = document.createTextNode('World');
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(fromNode.nodeValue).toBe('World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该保持相同的文本节点不变', () => {
|
||||||
|
const fromNode = document.createTextNode('Hello');
|
||||||
|
const toNode = document.createTextNode('Hello');
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
const originalNode = fromNode;
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(fromNode).toBe(originalNode);
|
||||||
|
expect(fromNode.nodeValue).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该替换不同类型的节点', () => {
|
||||||
|
const fromNode = document.createElement('span');
|
||||||
|
fromNode.textContent = 'Hello';
|
||||||
|
const toNode = document.createElement('div');
|
||||||
|
toNode.textContent = 'World';
|
||||||
|
container.appendChild(fromNode);
|
||||||
|
|
||||||
|
morphNode(fromNode, toNode);
|
||||||
|
|
||||||
|
expect(container.firstChild?.nodeName).toBe('DIV');
|
||||||
|
expect(container.firstChild?.textContent).toBe('World');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 属性更新', () => {
|
||||||
|
test('应该添加新属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'test');
|
||||||
|
toEl.setAttribute('id', 'myid');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('test');
|
||||||
|
expect(fromEl.getAttribute('id')).toBe('myid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该更新已存在的属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.setAttribute('class', 'old');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'new');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除不存在的属性', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.setAttribute('class', 'test');
|
||||||
|
fromEl.setAttribute('id', 'myid');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.setAttribute('class', 'test');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.getAttribute('class')).toBe('test');
|
||||||
|
expect(fromEl.hasAttribute('id')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphNode - 子节点更新', () => {
|
||||||
|
test('应该添加新子节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = '<li>1</li><li>2</li>';
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(3);
|
||||||
|
expect(fromEl.children[2].textContent).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除多余的子节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = '<li>1</li><li>2</li>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(2);
|
||||||
|
expect(fromEl.textContent).toBe('12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该更新子节点内容', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.innerHTML = '<p>Old</p>';
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.innerHTML = '<p>New</p>';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const originalP = fromEl.querySelector('p');
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
// 应该保持同一个 p 元素,只更新内容
|
||||||
|
expect(fromEl.querySelector('p')).toBe(originalP);
|
||||||
|
expect(fromEl.querySelector('p')?.textContent).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphHTML - HTML 字符串更新', () => {
|
||||||
|
test('应该从 HTML 字符串更新元素', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.innerHTML = '<p>Old</p>';
|
||||||
|
container.appendChild(element);
|
||||||
|
|
||||||
|
morphHTML(element, '<p>New</p>');
|
||||||
|
|
||||||
|
expect(element.innerHTML).toBe('<p>New</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理复杂的 HTML 结构', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.innerHTML = '<h1>Title</h1><p>Paragraph</p>';
|
||||||
|
container.appendChild(element);
|
||||||
|
|
||||||
|
morphHTML(element, '<h1>New Title</h1><p>New Paragraph</p><span>Extra</span>');
|
||||||
|
|
||||||
|
expect(element.children.length).toBe(3);
|
||||||
|
expect(element.querySelector('h1')?.textContent).toBe('New Title');
|
||||||
|
expect(element.querySelector('p')?.textContent).toBe('New Paragraph');
|
||||||
|
expect(element.querySelector('span')?.textContent).toBe('Extra');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('morphWithKeys - 基于 key 的智能 diff', () => {
|
||||||
|
test('应该保持相同 key 的节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A Updated</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const originalA = fromEl.querySelector('[data-key="a"]');
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.querySelector('[data-key="a"]')).toBe(originalA);
|
||||||
|
expect(originalA?.textContent).toBe('A Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该重新排序节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
const keys = Array.from(fromEl.children).map(child => child.getAttribute('data-key'));
|
||||||
|
expect(keys).toEqual(['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该添加新的 key 节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(3);
|
||||||
|
expect(fromEl.querySelector('[data-key="c"]')?.textContent).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该删除不存在的 key 节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="b">B</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<li data-key="a">A</li>
|
||||||
|
<li data-key="c">C</li>
|
||||||
|
`;
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphWithKeys(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.children.length).toBe(2);
|
||||||
|
expect(fromEl.querySelector('[data-key="b"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('性能测试', () => {
|
||||||
|
test('应该高效处理大量节点', () => {
|
||||||
|
const fromEl = document.createElement('ul');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `Item ${i}`;
|
||||||
|
fromEl.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEl = document.createElement('ul');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `Updated Item ${i}`;
|
||||||
|
toEl.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
expect(endTime - startTime).toBeLessThan(100); // 应该在 100ms 内完成
|
||||||
|
expect(fromEl.children.length).toBe(1000);
|
||||||
|
expect(fromEl.children[0].textContent).toBe('Updated Item 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界情况', () => {
|
||||||
|
test('应该处理空节点', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
expect(() => morphNode(fromEl, toEl)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理只有文本的节点', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.textContent = 'Hello';
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.textContent = 'World';
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.textContent).toBe('World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理嵌套的复杂结构', () => {
|
||||||
|
const fromEl = document.createElement('div');
|
||||||
|
fromEl.innerHTML = `
|
||||||
|
<div class="outer">
|
||||||
|
<div class="inner">
|
||||||
|
<span>Text</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const toEl = document.createElement('div');
|
||||||
|
toEl.innerHTML = `
|
||||||
|
<div class="outer modified">
|
||||||
|
<div class="inner">
|
||||||
|
<span>Updated Text</span>
|
||||||
|
<strong>New</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(fromEl);
|
||||||
|
|
||||||
|
morphNode(fromEl, toEl);
|
||||||
|
|
||||||
|
expect(fromEl.querySelector('.outer')?.classList.contains('modified')).toBe(true);
|
||||||
|
expect(fromEl.querySelector('span')?.textContent).toBe('Updated Text');
|
||||||
|
expect(fromEl.querySelector('strong')?.textContent).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
180
frontend/src/common/utils/domDiff.ts
Normal file
180
frontend/src/common/utils/domDiff.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 轻量级 DOM Diff 算法实现
|
||||||
|
* 基于 morphdom 思路,只更新变化的节点,保持未变化的节点不动
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较并更新两个 DOM 节点
|
||||||
|
* @param fromNode 原节点
|
||||||
|
* @param toNode 目标节点
|
||||||
|
*/
|
||||||
|
export function morphNode(fromNode: Node, toNode: Node): void {
|
||||||
|
// 节点类型不同,直接替换
|
||||||
|
if (fromNode.nodeType !== toNode.nodeType || fromNode.nodeName !== toNode.nodeName) {
|
||||||
|
fromNode.parentNode?.replaceChild(toNode.cloneNode(true), fromNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本节点:比较内容
|
||||||
|
if (fromNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (fromNode.nodeValue !== toNode.nodeValue) {
|
||||||
|
fromNode.nodeValue = toNode.nodeValue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元素节点:更新属性和子节点
|
||||||
|
if (fromNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const fromEl = fromNode as Element;
|
||||||
|
const toEl = toNode as Element;
|
||||||
|
|
||||||
|
// 更新属性
|
||||||
|
morphAttributes(fromEl, toEl);
|
||||||
|
|
||||||
|
// 更新子节点
|
||||||
|
morphChildren(fromEl, toEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新元素属性
|
||||||
|
*/
|
||||||
|
function morphAttributes(fromEl: Element, toEl: Element): void {
|
||||||
|
// 移除旧属性
|
||||||
|
const fromAttrs = fromEl.attributes;
|
||||||
|
for (let i = fromAttrs.length - 1; i >= 0; i--) {
|
||||||
|
const attr = fromAttrs[i];
|
||||||
|
if (!toEl.hasAttribute(attr.name)) {
|
||||||
|
fromEl.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加/更新新属性
|
||||||
|
const toAttrs = toEl.attributes;
|
||||||
|
for (let i = 0; i < toAttrs.length; i++) {
|
||||||
|
const attr = toAttrs[i];
|
||||||
|
const fromValue = fromEl.getAttribute(attr.name);
|
||||||
|
if (fromValue !== attr.value) {
|
||||||
|
fromEl.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新子节点(核心 diff 算法)
|
||||||
|
*/
|
||||||
|
function morphChildren(fromEl: Element, toEl: Element): void {
|
||||||
|
const fromChildren = Array.from(fromEl.childNodes);
|
||||||
|
const toChildren = Array.from(toEl.childNodes);
|
||||||
|
|
||||||
|
const fromLen = fromChildren.length;
|
||||||
|
const toLen = toChildren.length;
|
||||||
|
const minLen = Math.min(fromLen, toLen);
|
||||||
|
|
||||||
|
// 1. 更新公共部分
|
||||||
|
for (let i = 0; i < minLen; i++) {
|
||||||
|
morphNode(fromChildren[i], toChildren[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 移除多余的旧节点
|
||||||
|
if (fromLen > toLen) {
|
||||||
|
for (let i = fromLen - 1; i >= toLen; i--) {
|
||||||
|
fromEl.removeChild(fromChildren[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加新节点
|
||||||
|
if (toLen > fromLen) {
|
||||||
|
for (let i = fromLen; i < toLen; i++) {
|
||||||
|
fromEl.appendChild(toChildren[i].cloneNode(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优化版:使用 key 进行更智能的 diff(可选)
|
||||||
|
* 适用于有 data-key 属性的元素
|
||||||
|
*/
|
||||||
|
export function morphWithKeys(fromEl: Element, toEl: Element): void {
|
||||||
|
const toChildren = Array.from(toEl.children) as Element[];
|
||||||
|
|
||||||
|
// 构建 from 的 key 映射
|
||||||
|
const fromKeyMap = new Map<string, Element>();
|
||||||
|
Array.from(fromEl.children).forEach((child) => {
|
||||||
|
const key = child.getAttribute('data-key');
|
||||||
|
if (key) {
|
||||||
|
fromKeyMap.set(key, child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedKeys = new Set<string>();
|
||||||
|
|
||||||
|
// 按照 toChildren 的顺序处理
|
||||||
|
toChildren.forEach((toChild, toIndex) => {
|
||||||
|
const key = toChild.getAttribute('data-key');
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
processedKeys.add(key);
|
||||||
|
const fromChild = fromKeyMap.get(key);
|
||||||
|
|
||||||
|
if (fromChild) {
|
||||||
|
// 找到对应节点,更新内容
|
||||||
|
morphNode(fromChild, toChild);
|
||||||
|
|
||||||
|
// 确保节点在正确的位置
|
||||||
|
const currentNode = fromEl.children[toIndex];
|
||||||
|
if (currentNode !== fromChild) {
|
||||||
|
// 将 fromChild 移动到正确位置
|
||||||
|
fromEl.insertBefore(fromChild, currentNode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新节点,插入到正确位置
|
||||||
|
const currentNode = fromEl.children[toIndex];
|
||||||
|
fromEl.insertBefore(toChild.cloneNode(true), currentNode || null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除不再存在的节点(从后往前删除,避免索引问题)
|
||||||
|
const childrenToRemove: Element[] = [];
|
||||||
|
fromKeyMap.forEach((child, key) => {
|
||||||
|
if (!processedKeys.has(key)) {
|
||||||
|
childrenToRemove.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
childrenToRemove.forEach(child => {
|
||||||
|
fromEl.removeChild(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高级 API:直接从 HTML 字符串更新元素
|
||||||
|
*/
|
||||||
|
export function morphHTML(element: Element, htmlString: string): void {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = htmlString;
|
||||||
|
|
||||||
|
// 更新元素的子节点列表
|
||||||
|
morphChildren(element, tempContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新(使用 DocumentFragment)
|
||||||
|
*/
|
||||||
|
export function batchMorph(element: Element, htmlString: string): void {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.innerHTML = htmlString;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
Array.from(tempContainer.childNodes).forEach(node => {
|
||||||
|
fragment.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空原内容
|
||||||
|
while (element.firstChild) {
|
||||||
|
element.removeChild(element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
element.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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...',
|
||||||
|
|||||||
@@ -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: '搜索或输入新文档名...',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
|
|||||||
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
||||||
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {useTabStore} from "@/stores/tabStore";
|
import {useTabStore} from "@/stores/tabStore";
|
||||||
|
import type {EditorViewState} from '@/stores/editorStore';
|
||||||
|
|
||||||
export const useDocumentStore = defineStore('document', () => {
|
export const useDocumentStore = defineStore('document', () => {
|
||||||
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
|
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
|
||||||
@@ -13,6 +14,10 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
const currentDocumentId = ref<number | null>(null);
|
const currentDocumentId = ref<number | null>(null);
|
||||||
const currentDocument = ref<Document | null>(null);
|
const currentDocument = ref<Document | null>(null);
|
||||||
|
|
||||||
|
// === 编辑器状态持久化 ===
|
||||||
|
// 修复:使用统一的 EditorViewState 类型定义
|
||||||
|
const documentStates = ref<Record<number, EditorViewState>>({});
|
||||||
|
|
||||||
// === UI状态 ===
|
// === UI状态 ===
|
||||||
const showDocumentSelector = ref(false);
|
const showDocumentSelector = ref(false);
|
||||||
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
const selectorError = ref<{ docId: number; message: string } | null>(null);
|
||||||
@@ -218,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
documentList,
|
documentList,
|
||||||
currentDocumentId,
|
currentDocumentId,
|
||||||
currentDocument,
|
currentDocument,
|
||||||
|
documentStates,
|
||||||
showDocumentSelector,
|
showDocumentSelector,
|
||||||
selectorError,
|
selectorError,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -240,6 +246,6 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
persist: {
|
persist: {
|
||||||
key: 'voidraft-document',
|
key: 'voidraft-document',
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
pick: ['currentDocumentId', 'documents']
|
pick: ['currentDocumentId', 'documents', 'documentStates']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4,8 +4,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();
|
||||||
|
|
||||||
@@ -551,6 +686,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
170
frontend/src/stores/panelStore.ts
Normal file
170
frontend/src/stores/panelStore.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { EditorView } from '@codemirror/view';
|
||||||
|
import { useDocumentStore } from './documentStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个文档的预览状态
|
||||||
|
*/
|
||||||
|
interface DocumentPreviewState {
|
||||||
|
isOpen: boolean;
|
||||||
|
isClosing: boolean;
|
||||||
|
blockFrom: number;
|
||||||
|
blockTo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板状态管理 Store
|
||||||
|
* 管理编辑器中各种面板的显示状态(按文档ID区分)
|
||||||
|
*/
|
||||||
|
export const usePanelStore = defineStore('panel', () => {
|
||||||
|
// 当前编辑器视图引用
|
||||||
|
const editorView = ref<EditorView | null>(null);
|
||||||
|
|
||||||
|
// 每个文档的预览状态 Map<documentId, PreviewState>
|
||||||
|
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前文档的预览状态
|
||||||
|
*/
|
||||||
|
const markdownPreview = computed(() => {
|
||||||
|
const documentStore = useDocumentStore();
|
||||||
|
const currentDocId = documentStore.currentDocumentId;
|
||||||
|
|
||||||
|
if (currentDocId === null) {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
isClosing: false,
|
||||||
|
blockFrom: 0,
|
||||||
|
blockTo: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentPreviews.value.get(currentDocId) || {
|
||||||
|
isOpen: false,
|
||||||
|
isClosing: false,
|
||||||
|
blockFrom: 0,
|
||||||
|
blockTo: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置编辑器视图
|
||||||
|
*/
|
||||||
|
const setEditorView = (view: EditorView | null) => {
|
||||||
|
editorView.value = view;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 Markdown 预览面板
|
||||||
|
*/
|
||||||
|
const openMarkdownPreview = (from: number, to: number) => {
|
||||||
|
const documentStore = useDocumentStore();
|
||||||
|
const currentDocId = documentStore.currentDocumentId;
|
||||||
|
|
||||||
|
if (currentDocId === null) return;
|
||||||
|
|
||||||
|
documentPreviews.value.set(currentDocId, {
|
||||||
|
isOpen: true,
|
||||||
|
isClosing: false,
|
||||||
|
blockFrom: from,
|
||||||
|
blockTo: to
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始关闭 Markdown 预览面板
|
||||||
|
*/
|
||||||
|
const startClosingMarkdownPreview = () => {
|
||||||
|
const documentStore = useDocumentStore();
|
||||||
|
const currentDocId = documentStore.currentDocumentId;
|
||||||
|
|
||||||
|
if (currentDocId === null) return;
|
||||||
|
|
||||||
|
const state = documentPreviews.value.get(currentDocId);
|
||||||
|
if (state?.isOpen) {
|
||||||
|
documentPreviews.value.set(currentDocId, {
|
||||||
|
...state,
|
||||||
|
isClosing: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 Markdown 预览面板
|
||||||
|
*/
|
||||||
|
const closeMarkdownPreview = () => {
|
||||||
|
const documentStore = useDocumentStore();
|
||||||
|
const currentDocId = documentStore.currentDocumentId;
|
||||||
|
|
||||||
|
if (currentDocId === null) return;
|
||||||
|
|
||||||
|
documentPreviews.value.set(currentDocId, {
|
||||||
|
isOpen: false,
|
||||||
|
isClosing: false,
|
||||||
|
blockFrom: 0,
|
||||||
|
blockTo: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预览块的范围(用于实时预览)
|
||||||
|
*/
|
||||||
|
const updatePreviewRange = (from: number, to: number) => {
|
||||||
|
const documentStore = useDocumentStore();
|
||||||
|
const currentDocId = documentStore.currentDocumentId;
|
||||||
|
|
||||||
|
if (currentDocId === null) return;
|
||||||
|
|
||||||
|
const state = documentPreviews.value.get(currentDocId);
|
||||||
|
if (state?.isOpen) {
|
||||||
|
documentPreviews.value.set(currentDocId, {
|
||||||
|
...state,
|
||||||
|
blockFrom: from,
|
||||||
|
blockTo: to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定块是否正在预览
|
||||||
|
*/
|
||||||
|
const isBlockPreviewing = (from: number, to: number): boolean => {
|
||||||
|
const preview = markdownPreview.value;
|
||||||
|
return preview.isOpen &&
|
||||||
|
preview.blockFrom === from &&
|
||||||
|
preview.blockTo === to;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有面板状态
|
||||||
|
*/
|
||||||
|
const reset = () => {
|
||||||
|
documentPreviews.value.clear();
|
||||||
|
editorView.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理指定文档的预览状态(文档关闭时调用)
|
||||||
|
*/
|
||||||
|
const clearDocumentPreview = (documentId: number) => {
|
||||||
|
documentPreviews.value.delete(documentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
editorView,
|
||||||
|
markdownPreview,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
setEditorView,
|
||||||
|
openMarkdownPreview,
|
||||||
|
startClosingMarkdownPreview,
|
||||||
|
closeMarkdownPreview,
|
||||||
|
updatePreviewRange,
|
||||||
|
isBlockPreviewing,
|
||||||
|
reset,
|
||||||
|
clearDocumentPreview
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认导出
|
* 默认导出
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
367
frontend/src/views/editor/extensions/markdownPreview/panel.ts
Normal file
367
frontend/src/views/editor/extensions/markdownPreview/panel.ts
Normal 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();
|
||||||
|
|
||||||
|
// 只有有效的 URL(http/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();
|
||||||
|
}
|
||||||
|
|
||||||
142
frontend/src/views/editor/extensions/markdownPreview/state.ts
Normal file
142
frontend/src/views/editor/extensions/markdownPreview/state.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
356
frontend/src/views/editor/extensions/markdownPreview/styles.ts
Normal file
356
frontend/src/views/editor/extensions/markdownPreview/styles.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Markdown 预览面板相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 预览面板状态
|
||||||
|
export interface PreviewState {
|
||||||
|
documentId: number; // 预览所属的文档ID
|
||||||
|
blockFrom: number;
|
||||||
|
blockTo: number;
|
||||||
|
closing?: boolean; // 标记面板正在关闭
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,280 +10,304 @@ import type {ThemeColors} from './types';
|
|||||||
* @returns CodeMirror Extension数组
|
* @returns CodeMirror Extension数组
|
||||||
*/
|
*/
|
||||||
export function createBaseTheme(colors: ThemeColors): Extension {
|
export function createBaseTheme(colors: ThemeColors): Extension {
|
||||||
// 编辑器主题样式
|
// 编辑器主题样式
|
||||||
const theme = EditorView.theme({
|
const theme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 确保编辑器容器背景一致
|
// 确保编辑器容器背景一致
|
||||||
'.cm-editor': {
|
'.cm-editor': {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 确保滚动区域背景一致
|
// 确保滚动区域背景一致
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
|
||||||
// 编辑器内容
|
// 编辑器内容
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
caretColor: colors.cursor,
|
caretColor: colors.cursor,
|
||||||
paddingTop: '4px',
|
paddingTop: '4px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 光标
|
// 光标
|
||||||
'.cm-cursor, .cm-dropCursor': {
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
borderLeftColor: colors.cursor,
|
borderLeftColor: colors.cursor,
|
||||||
borderLeftWidth: '2px',
|
borderLeftWidth: '2px',
|
||||||
paddingTop: '4px',
|
paddingTop: '4px',
|
||||||
marginTop: '-2px',
|
marginTop: '-2px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择
|
// 选择
|
||||||
'.cm-selectionBackground': {
|
'.cm-selectionBackground': {
|
||||||
backgroundColor: colors.selectionBlur,
|
backgroundColor: colors.selectionBlur,
|
||||||
},
|
},
|
||||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||||
backgroundColor: colors.selection,
|
backgroundColor: colors.selection,
|
||||||
},
|
},
|
||||||
'.cm-content ::selection': {
|
'.cm-content ::selection': {
|
||||||
backgroundColor: colors.selection,
|
backgroundColor: colors.selection,
|
||||||
},
|
},
|
||||||
'.cm-activeLine.code-empty-block-selected': {
|
'.cm-activeLine.code-empty-block-selected': {
|
||||||
backgroundColor: colors.selection,
|
backgroundColor: colors.selection,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 当前行高亮
|
// 当前行高亮
|
||||||
'.cm-activeLine': {
|
'.cm-activeLine': {
|
||||||
backgroundColor: colors.activeLine
|
backgroundColor: colors.activeLine
|
||||||
},
|
},
|
||||||
|
|
||||||
// 行号区域
|
// 行号区域
|
||||||
'.cm-gutters': {
|
'.cm-gutters': {
|
||||||
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
|
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
|
||||||
color: colors.lineNumber,
|
color: colors.lineNumber,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRight: colors.dark ? 'none' : `1px solid ${colors.borderLight}`,
|
borderRight: colors.dark ? 'none' : `1px solid ${colors.borderLight}`,
|
||||||
padding: '0 2px 0 4px',
|
padding: '0 2px 0 4px',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
},
|
},
|
||||||
'.cm-activeLineGutter': {
|
'.cm-activeLineGutter': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
color: colors.activeLineNumber,
|
color: colors.activeLineNumber,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 折叠功能
|
// 折叠功能
|
||||||
'.cm-foldGutter': {
|
'.cm-foldGutter': {
|
||||||
marginLeft: '0px',
|
marginLeft: '0px',
|
||||||
},
|
},
|
||||||
'.cm-foldGutter .cm-gutterElement': {
|
'.cm-foldGutter .cm-gutterElement': {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: 'opacity 400ms',
|
transition: 'opacity 400ms',
|
||||||
},
|
},
|
||||||
'.cm-gutters:hover .cm-gutterElement': {
|
'.cm-gutters:hover .cm-gutterElement': {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
'.cm-foldPlaceholder': {
|
'.cm-foldPlaceholder': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: colors.comment,
|
color: colors.comment,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 面板
|
// 面板
|
||||||
'.cm-panels': {
|
'.cm-panels': {
|
||||||
backgroundColor: colors.dropdownBackground,
|
// backgroundColor: colors.dropdownBackground,
|
||||||
color: colors.foreground
|
// color: colors.foreground
|
||||||
},
|
},
|
||||||
'.cm-panels.cm-panels-top': {
|
'.cm-panels.cm-panels-top': {
|
||||||
borderBottom: '2px solid black'
|
borderBottom: '2px solid black'
|
||||||
},
|
},
|
||||||
'.cm-panels.cm-panels-bottom': {
|
'.cm-panels.cm-panels-bottom': {
|
||||||
borderTop: '2px solid black'
|
animation: 'panelSlideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||||
},
|
},
|
||||||
|
'@keyframes panelSlideUp': {
|
||||||
|
from: {
|
||||||
|
transform: 'translateY(100%)',
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'@keyframes panelSlideDown': {
|
||||||
|
from: {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
transform: 'translateY(100%)',
|
||||||
|
opacity: '0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 搜索匹配
|
// 搜索匹配
|
||||||
'.cm-searchMatch': {
|
'.cm-searchMatch': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
outline: `1px solid ${colors.searchMatch}`,
|
outline: `1px solid ${colors.searchMatch}`,
|
||||||
},
|
},
|
||||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
backgroundColor: colors.searchMatch,
|
backgroundColor: colors.searchMatch,
|
||||||
color: colors.background,
|
color: colors.background,
|
||||||
},
|
},
|
||||||
'.cm-selectionMatch': {
|
'.cm-selectionMatch': {
|
||||||
backgroundColor: colors.dark ? '#50606D' : '#e6f3ff',
|
backgroundColor: colors.dark ? '#50606D' : '#e6f3ff',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 括号匹配
|
// 括号匹配
|
||||||
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
||||||
outline: `0.5px solid ${colors.searchMatch}`,
|
outline: `0.5px solid ${colors.searchMatch}`,
|
||||||
},
|
},
|
||||||
'&.cm-focused .cm-matchingBracket': {
|
'&.cm-focused .cm-matchingBracket': {
|
||||||
backgroundColor: colors.matchingBracket,
|
backgroundColor: colors.matchingBracket,
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
},
|
},
|
||||||
'&.cm-focused .cm-nonmatchingBracket': {
|
'&.cm-focused .cm-nonmatchingBracket': {
|
||||||
outline: colors.dark ? '0.5px solid #bc8f8f' : '0.5px solid #d73a49',
|
outline: colors.dark ? '0.5px solid #bc8f8f' : '0.5px solid #d73a49',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 编辑器焦点
|
// 编辑器焦点
|
||||||
'&.cm-editor.cm-focused': {
|
'&.cm-editor.cm-focused': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 工具提示
|
// 工具提示
|
||||||
'.cm-tooltip': {
|
'.cm-tooltip': {
|
||||||
border: colors.dark ? 'none' : `1px solid ${colors.dropdownBorder}`,
|
border: colors.dark ? 'none' : `1px solid ${colors.dropdownBorder}`,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
boxShadow: colors.dark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
boxShadow: colors.dark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
},
|
},
|
||||||
'.cm-tooltip .cm-tooltip-arrow:before': {
|
'.cm-tooltip .cm-tooltip-arrow:before': {
|
||||||
borderTopColor: 'transparent',
|
borderTopColor: 'transparent',
|
||||||
borderBottomColor: 'transparent',
|
borderBottomColor: 'transparent',
|
||||||
},
|
},
|
||||||
'.cm-tooltip .cm-tooltip-arrow:after': {
|
'.cm-tooltip .cm-tooltip-arrow:after': {
|
||||||
borderTopColor: colors.surface,
|
borderTopColor: colors.surface,
|
||||||
borderBottomColor: colors.surface,
|
borderBottomColor: colors.surface,
|
||||||
},
|
},
|
||||||
'.cm-tooltip-autocomplete': {
|
'.cm-tooltip-autocomplete': {
|
||||||
'& > ul > li[aria-selected]': {
|
'& > ul > li[aria-selected]': {
|
||||||
backgroundColor: colors.activeLine,
|
backgroundColor: colors.activeLine,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 代码块层(自定义)
|
// 代码块层(自定义)
|
||||||
'.code-blocks-layer': {
|
'.code-blocks-layer': {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
'.code-blocks-layer .block-even, .code-blocks-layer .block-odd': {
|
'.code-blocks-layer .block-even, .code-blocks-layer .block-odd': {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'content-box',
|
boxSizing: 'content-box',
|
||||||
},
|
},
|
||||||
'.code-blocks-layer .block-even': {
|
'.code-blocks-layer .block-even': {
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderTop: `1px solid ${colors.borderColor}`,
|
borderTop: `1px solid ${colors.borderColor}`,
|
||||||
},
|
},
|
||||||
'.code-blocks-layer .block-even:first-child': {
|
'.code-blocks-layer .block-even:first-child': {
|
||||||
borderTop: 'none',
|
borderTop: 'none',
|
||||||
},
|
},
|
||||||
'.code-blocks-layer .block-odd': {
|
'.code-blocks-layer .block-odd': {
|
||||||
background: colors.backgroundSecondary,
|
background: colors.backgroundSecondary,
|
||||||
borderTop: `1px solid ${colors.borderColor}`,
|
borderTop: `1px solid ${colors.borderColor}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数学计算结果(自定义)
|
// 数学计算结果(自定义)
|
||||||
'.code-blocks-math-result': {
|
'.code-blocks-math-result': {
|
||||||
paddingLeft: "12px",
|
paddingLeft: "12px",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
".code-blocks-math-result .inner": {
|
".code-blocks-math-result .inner": {
|
||||||
background: colors.dark ? '#0e1217' : '#48b57e',
|
background: colors.dark ? '#0e1217' : '#48b57e',
|
||||||
color: colors.dark ? '#a0e7c7' : '#fff',
|
color: colors.dark ? '#a0e7c7' : '#fff',
|
||||||
padding: '0px 4px',
|
padding: '0px 4px',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
boxShadow: colors.dark ? '0 0 3px rgba(0,0,0, 0.3)' : '0 0 3px rgba(0,0,0, 0.1)',
|
boxShadow: colors.dark ? '0 0 3px rgba(0,0,0, 0.3)' : '0 0 3px rgba(0,0,0, 0.1)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
},
|
},
|
||||||
'.code-blocks-math-result-copied': {
|
'.code-blocks-math-result-copied': {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "0px",
|
top: "0px",
|
||||||
left: "0px",
|
left: "0px",
|
||||||
marginLeft: "calc(100% + 10px)",
|
marginLeft: "calc(100% + 10px)",
|
||||||
width: "60px",
|
width: "60px",
|
||||||
transition: "opacity 500ms",
|
transition: "opacity 500ms",
|
||||||
transitionDelay: "1000ms",
|
transitionDelay: "1000ms",
|
||||||
color: colors.dark ? 'rgba(220,240,230, 1.0)' : 'rgba(0,0,0, 0.8)',
|
color: colors.dark ? 'rgba(220,240,230, 1.0)' : 'rgba(0,0,0, 0.8)',
|
||||||
},
|
},
|
||||||
'.code-blocks-math-result-copied.fade-out': {
|
'.code-blocks-math-result-copied.fade-out': {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 代码块开始标记(自定义)
|
// 代码块开始标记(自定义)
|
||||||
'.code-block-start': {
|
'.code-block-start': {
|
||||||
height: '12px',
|
height: '12px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
'.code-block-start.first': {
|
'.code-block-start.first': {
|
||||||
height: '0px',
|
height: '0px',
|
||||||
},
|
},
|
||||||
}, {dark: colors.dark});
|
}, {dark: colors.dark});
|
||||||
|
|
||||||
// 语法高亮样式
|
// 语法高亮样式
|
||||||
const highlightStyle = HighlightStyle.define([
|
const highlightStyle = HighlightStyle.define([
|
||||||
// 关键字
|
// 关键字
|
||||||
{tag: tags.keyword, color: colors.keyword},
|
{tag: tags.keyword, color: colors.keyword},
|
||||||
|
|
||||||
// 操作符
|
|
||||||
{tag: [tags.operator, tags.operatorKeyword], color: colors.operator},
|
|
||||||
|
|
||||||
// 名称、变量
|
|
||||||
{tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: colors.variable},
|
|
||||||
{tag: [tags.variableName], color: colors.variable},
|
|
||||||
{tag: [tags.labelName], color: colors.operator},
|
|
||||||
{tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: colors.variable},
|
|
||||||
|
|
||||||
// 函数
|
|
||||||
{tag: [tags.function(tags.variableName)], color: colors.function},
|
|
||||||
{tag: [tags.propertyName], color: colors.function},
|
|
||||||
|
|
||||||
// 类型、类
|
|
||||||
{tag: [tags.typeName], color: colors.type},
|
|
||||||
{tag: [tags.className], color: colors.class},
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
{tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: colors.constant},
|
|
||||||
|
|
||||||
// 字符串
|
|
||||||
{tag: [tags.processingInstruction, tags.string, tags.inserted], color: colors.string},
|
|
||||||
{tag: [tags.special(tags.string)], color: colors.string},
|
|
||||||
{tag: [tags.quote], color: colors.comment},
|
|
||||||
|
|
||||||
// 数字
|
|
||||||
{tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: colors.number},
|
|
||||||
|
|
||||||
// 正则表达式
|
|
||||||
{tag: [tags.url, tags.escape, tags.regexp, tags.link], color: colors.regexp},
|
|
||||||
|
|
||||||
// 注释
|
|
||||||
{tag: [tags.meta, tags.comment], color: colors.comment, fontStyle: 'italic'},
|
|
||||||
|
|
||||||
// 分隔符、括号
|
|
||||||
{tag: [tags.definition(tags.name), tags.separator], color: colors.variable},
|
|
||||||
{tag: [tags.brace], color: colors.variable},
|
|
||||||
{tag: [tags.squareBracket], color: colors.dark ? '#bf616a' : colors.keyword},
|
|
||||||
{tag: [tags.angleBracket], color: colors.dark ? '#d08770' : colors.operator},
|
|
||||||
{tag: [tags.attributeName], color: colors.variable},
|
|
||||||
|
|
||||||
// 标签
|
|
||||||
{tag: [tags.tagName], color: colors.number},
|
|
||||||
|
|
||||||
// 注解
|
|
||||||
{tag: [tags.annotation], color: colors.invalid},
|
|
||||||
|
|
||||||
// 特殊样式
|
|
||||||
{tag: tags.strong, fontWeight: 'bold'},
|
|
||||||
{tag: tags.emphasis, fontStyle: 'italic'},
|
|
||||||
{tag: tags.strikethrough, textDecoration: 'line-through'},
|
|
||||||
{tag: tags.link, color: colors.variable, textDecoration: 'underline'},
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
{tag: tags.heading, fontWeight: 'bold', color: colors.heading},
|
|
||||||
{tag: [tags.heading1, tags.heading2], fontSize: '1.4em'},
|
|
||||||
{tag: [tags.heading3, tags.heading4], fontSize: '1.2em'},
|
|
||||||
{tag: [tags.heading5, tags.heading6], fontSize: '1.1em'},
|
|
||||||
|
|
||||||
// 无效内容
|
|
||||||
{tag: tags.invalid, color: colors.invalid},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
// 操作符
|
||||||
theme,
|
{tag: [tags.operator, tags.operatorKeyword], color: colors.operator},
|
||||||
syntaxHighlighting(highlightStyle),
|
|
||||||
];
|
// 名称、变量
|
||||||
|
{tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: colors.variable},
|
||||||
|
{tag: [tags.variableName], color: colors.variable},
|
||||||
|
{tag: [tags.labelName], color: colors.operator},
|
||||||
|
{tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: colors.variable},
|
||||||
|
|
||||||
|
// 函数
|
||||||
|
{tag: [tags.function(tags.variableName)], color: colors.function},
|
||||||
|
{tag: [tags.propertyName], color: colors.function},
|
||||||
|
|
||||||
|
// 类型、类
|
||||||
|
{tag: [tags.typeName], color: colors.type},
|
||||||
|
{tag: [tags.className], color: colors.class},
|
||||||
|
|
||||||
|
// 常量
|
||||||
|
{tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: colors.constant},
|
||||||
|
|
||||||
|
// 字符串
|
||||||
|
{tag: [tags.processingInstruction, tags.string, tags.inserted], color: colors.string},
|
||||||
|
{tag: [tags.special(tags.string)], color: colors.string},
|
||||||
|
{tag: [tags.quote], color: colors.comment},
|
||||||
|
|
||||||
|
// 数字
|
||||||
|
{
|
||||||
|
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
|
||||||
|
color: colors.number
|
||||||
|
},
|
||||||
|
|
||||||
|
// 正则表达式
|
||||||
|
{tag: [tags.url, tags.escape, tags.regexp, tags.link], color: colors.regexp},
|
||||||
|
|
||||||
|
// 注释
|
||||||
|
{tag: [tags.meta, tags.comment], color: colors.comment, fontStyle: 'italic'},
|
||||||
|
|
||||||
|
// 分隔符、括号
|
||||||
|
{tag: [tags.definition(tags.name), tags.separator], color: colors.variable},
|
||||||
|
{tag: [tags.brace], color: colors.variable},
|
||||||
|
{tag: [tags.squareBracket], color: colors.dark ? '#bf616a' : colors.keyword},
|
||||||
|
{tag: [tags.angleBracket], color: colors.dark ? '#d08770' : colors.operator},
|
||||||
|
{tag: [tags.attributeName], color: colors.variable},
|
||||||
|
|
||||||
|
// 标签
|
||||||
|
{tag: [tags.tagName], color: colors.number},
|
||||||
|
|
||||||
|
// 注解
|
||||||
|
{tag: [tags.annotation], color: colors.invalid},
|
||||||
|
|
||||||
|
// 特殊样式
|
||||||
|
{tag: tags.strong, fontWeight: 'bold'},
|
||||||
|
{tag: tags.emphasis, fontStyle: 'italic'},
|
||||||
|
{tag: tags.strikethrough, textDecoration: 'line-through'},
|
||||||
|
{tag: tags.link, color: colors.variable, textDecoration: 'underline'},
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
{tag: tags.heading, fontWeight: 'bold', color: colors.heading},
|
||||||
|
{tag: [tags.heading1, tags.heading2], fontSize: '1.4em'},
|
||||||
|
{tag: [tags.heading3, tags.heading4], fontSize: '1.2em'},
|
||||||
|
{tag: [tags.heading5, tags.heading6], fontSize: '1.1em'},
|
||||||
|
|
||||||
|
// 无效内容
|
||||||
|
{tag: tags.invalid, color: colors.invalid},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
theme,
|
||||||
|
syntaxHighlighting(highlightStyle),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { computed, onUnmounted } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useConfigStore } from '@/stores/configStore';
|
import { useConfigStore } from '@/stores/configStore';
|
||||||
import { useUpdateStore } from '@/stores/updateStore';
|
import { useUpdateStore } from '@/stores/updateStore';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||||
import { Remarkable } from 'remarkable';
|
import markdownit from 'markdown-it'
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore();
|
||||||
|
|
||||||
// 清理状态
|
|
||||||
onUnmounted(() => {
|
|
||||||
updateStore.clearStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化Remarkable实例并配置
|
// 初始化Remarkable实例并配置
|
||||||
const md = new Remarkable({
|
const md = markdownit({
|
||||||
html: true, // 允许HTML
|
html: true, // 允许HTML
|
||||||
xhtmlOut: false, // 不使用'/'闭合单标签
|
linkify: false, // 不解析链接
|
||||||
breaks: true, // 将'\n'转换为<br>
|
typographer: true, // 开启智能引号
|
||||||
typographer: true // 启用排版增强
|
xhtmlOut: true, // 使用xhtml语法输出
|
||||||
|
breaks: true, // 允许换行
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {nodePolyfills} from 'vite-plugin-node-polyfills';
|
|||||||
export default defineConfig(({mode}: { mode: string }): object => {
|
export default defineConfig(({mode}: { mode: string }): object => {
|
||||||
const env: Record<string, string> = loadEnv(mode, process.cwd());
|
const env: Record<string, string> = loadEnv(mode, process.cwd());
|
||||||
return {
|
return {
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
publicDir: './public',
|
publicDir: './public',
|
||||||
base: './',
|
base: './',
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -10,10 +10,10 @@ require (
|
|||||||
github.com/knadh/koanf/providers/structs v1.0.0
|
github.com/knadh/koanf/providers/structs v1.0.0
|
||||||
github.com/knadh/koanf/v2 v2.3.0
|
github.com/knadh/koanf/v2 v2.3.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.40
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/sys v0.37.0
|
golang.org/x/sys v0.38.0
|
||||||
golang.org/x/text v0.30.0
|
golang.org/x/text v0.31.0
|
||||||
modernc.org/sqlite v1.40.0
|
modernc.org/sqlite v1.40.0
|
||||||
resty.dev/v3 v3.0.0-beta.3
|
resty.dev/v3 v3.0.0-beta.3
|
||||||
)
|
)
|
||||||
@@ -77,10 +77,10 @@ require (
|
|||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
golang.org/x/crypto v0.43.0 // indirect
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/image v0.32.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/oauth2 v0.32.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -166,8 +166,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
|||||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38 h1:pknuf+fecyZtP7hLCWTILttj6xB/VXRiXoy4T/7iorQ=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.40 h1:LY0hngVwihlSXveshL5LM8ivjLTHAN6VDjOSF6szI9k=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.40/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
|
||||||
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
|
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
|
||||||
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
|
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
@@ -176,12 +176,12 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -189,13 +189,13 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -207,16 +207,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package constant
|
|||||||
// VOIDRAFT_MAIN_WINDOW_NAME is the name of the main window of the Voidcraft client.
|
// VOIDRAFT_MAIN_WINDOW_NAME is the name of the main window of the Voidcraft client.
|
||||||
const VOIDRAFT_MAIN_WINDOW_NAME = "voidraft-main-window"
|
const VOIDRAFT_MAIN_WINDOW_NAME = "voidraft-main-window"
|
||||||
|
|
||||||
const VOIDRAFT_WINDOW_TITLE = "voidraft"
|
const VOIDRAFT_APP_NAME = "voidraft"
|
||||||
|
const VOIDRAFT_APP_DESCRIPTION = "An elegant text snippet recording tool designed for developers."
|
||||||
|
|
||||||
const VOIDRAFT_WINDOW_WIDTH = 700
|
const VOIDRAFT_WINDOW_WIDTH = 700
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const (
|
|||||||
ModShift = darwin.ModShift
|
ModShift = darwin.ModShift
|
||||||
ModOption = darwin.ModOption
|
ModOption = darwin.ModOption
|
||||||
ModCmd = darwin.ModCmd
|
ModCmd = darwin.ModCmd
|
||||||
|
ModAlt = darwin.ModOption // Alias for ModOption (Alt key on macOS)
|
||||||
|
ModWin = darwin.ModCmd // Alias for ModCmd (Cmd key is like Win key)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Key represents a key.
|
// Key represents a key.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
ModCtrl = linux.ModCtrl
|
ModCtrl = linux.ModCtrl
|
||||||
ModShift = linux.ModShift
|
ModShift = linux.ModShift
|
||||||
ModAlt = linux.ModAlt // Alias for Mod1
|
ModAlt = linux.ModAlt // Alias for Mod1
|
||||||
|
ModWin = linux.Mod4 // Super/Windows key is typically Mod4 on Linux
|
||||||
Mod1 = linux.Mod1
|
Mod1 = linux.Mod1
|
||||||
Mod2 = linux.Mod2
|
Mod2 = linux.Mod2
|
||||||
Mod3 = linux.Mod3
|
Mod3 = linux.Mod3
|
||||||
|
|||||||
@@ -218,23 +218,3 @@ func NewDefaultAppConfig() *AppConfig {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion 获取配置版本
|
|
||||||
func (ac *AppConfig) GetVersion() string {
|
|
||||||
return ac.Metadata.Version
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetVersion 设置配置版本
|
|
||||||
func (ac *AppConfig) SetVersion(version string) {
|
|
||||||
ac.Metadata.Version = version
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLastUpdated 设置最后更新时间
|
|
||||||
func (ac *AppConfig) SetLastUpdated(timeStr string) {
|
|
||||||
ac.Metadata.LastUpdated = timeStr
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultConfig 获取默认配置
|
|
||||||
func (ac *AppConfig) GetDefaultConfig() any {
|
|
||||||
return NewDefaultAppConfig()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ func NewDocument(title, content string) *Document {
|
|||||||
|
|
||||||
// NewDefaultDocument 创建默认文档
|
// NewDefaultDocument 创建默认文档
|
||||||
func NewDefaultDocument() *Document {
|
func NewDefaultDocument() *Document {
|
||||||
return NewDocument("default", "∞∞∞text-a\n")
|
return NewDocument("default", "\n∞∞∞text-a\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,24 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
|
|||||||
ms.logger.Error("Failed to close database connection", "error", err)
|
ms.logger.Error("Failed to close database connection", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行原子迁移
|
// 执行原子迁移
|
||||||
if err := ms.atomicMove(ctx, srcPath, dstPath); err != nil {
|
if err := ms.atomicMove(ctx, srcPath, dstPath); err != nil {
|
||||||
return ms.failWithError(err)
|
return ms.failWithError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 迁移完成后重新连接数据库
|
||||||
|
ms.updateProgress(MigrationProgress{
|
||||||
|
Status: MigrationStatusMigrating,
|
||||||
|
Progress: 95,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ms.dbService != nil {
|
||||||
|
if err := ms.dbService.initDatabase(); err != nil {
|
||||||
|
return ms.failWithError(fmt.Errorf("failed to reconnect database: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 迁移完成
|
// 迁移完成
|
||||||
ms.updateProgress(MigrationProgress{
|
ms.updateProgress(MigrationProgress{
|
||||||
Status: MigrationStatusCompleted,
|
Status: MigrationStatusCompleted,
|
||||||
@@ -59,15 +59,14 @@ CREATE TABLE IF NOT EXISTS key_bindings (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL
|
||||||
UNIQUE(command, extension)
|
|
||||||
)`
|
)`
|
||||||
|
|
||||||
// Themes table
|
// Themes table
|
||||||
sqlCreateThemesTable = `
|
sqlCreateThemesTable = `
|
||||||
CREATE TABLE IF NOT EXISTS themes (
|
CREATE TABLE IF NOT EXISTS themes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
colors TEXT NOT NULL,
|
colors TEXT NOT NULL,
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -133,29 +132,9 @@ func (ds *DatabaseService) registerAllModels() {
|
|||||||
// ServiceStartup initializes the service when the application starts
|
// ServiceStartup initializes the service when the application starts
|
||||||
func (ds *DatabaseService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
func (ds *DatabaseService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
ds.ctx = ctx
|
ds.ctx = ctx
|
||||||
|
|
||||||
ds.cancelObserver = ds.configService.Watch("general.dataPath", ds.onDataPathChange)
|
|
||||||
|
|
||||||
return ds.initDatabase()
|
return ds.initDatabase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// onDataPathChange 数据路径配置变更回调
|
|
||||||
func (ds *DatabaseService) onDataPathChange(oldValue, newValue interface{}) {
|
|
||||||
oldPath := ""
|
|
||||||
newPath := ""
|
|
||||||
|
|
||||||
if oldValue != nil {
|
|
||||||
oldPath = fmt.Sprintf("%v", oldValue)
|
|
||||||
}
|
|
||||||
if newValue != nil {
|
|
||||||
newPath = fmt.Sprintf("%v", newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldPath != newPath {
|
|
||||||
_ = ds.OnDataPathChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initDatabase initializes the SQLite database
|
// initDatabase initializes the SQLite database
|
||||||
func (ds *DatabaseService) initDatabase() error {
|
func (ds *DatabaseService) initDatabase() error {
|
||||||
dbPath, err := ds.getDatabasePath()
|
dbPath, err := ds.getDatabasePath()
|
||||||
@@ -244,8 +223,6 @@ func (ds *DatabaseService) createIndexes() error {
|
|||||||
// Themes indexes
|
// Themes indexes
|
||||||
`CREATE INDEX IF NOT EXISTS idx_themes_type ON themes(type)`,
|
`CREATE INDEX IF NOT EXISTS idx_themes_type ON themes(type)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_themes_is_default ON themes(is_default)`,
|
`CREATE INDEX IF NOT EXISTS idx_themes_is_default ON themes(is_default)`,
|
||||||
// 条件唯一索引:确保每种类型只能有一个默认主题
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_themes_type_default ON themes(type) WHERE is_default = 1`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, index := range indexes {
|
for _, index := range indexes {
|
||||||
@@ -402,16 +379,3 @@ func (ds *DatabaseService) ServiceShutdown() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDataPathChanged handles data path changes
|
|
||||||
func (ds *DatabaseService) OnDataPathChanged() error {
|
|
||||||
// 关闭当前连接
|
|
||||||
if ds.db != nil {
|
|
||||||
if err := ds.db.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用新路径重新初始化
|
|
||||||
return ds.initDatabase()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -175,18 +175,7 @@ func (ds *DocumentService) CreateDocument(title string) (*models.Document, error
|
|||||||
if ds.databaseService == nil || ds.databaseService.db == nil {
|
if ds.databaseService == nil || ds.databaseService.db == nil {
|
||||||
return nil, errors.New("database service not available")
|
return nil, errors.New("database service not available")
|
||||||
}
|
}
|
||||||
|
doc := models.NewDocument(title, "\n∞∞∞text-a\n")
|
||||||
// Create document with default content
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
doc := &models.Document{
|
|
||||||
Title: title,
|
|
||||||
Content: "∞∞∞text-a\n",
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
IsDeleted: false,
|
|
||||||
IsLocked: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行插入操作
|
// 执行插入操作
|
||||||
result, err := ds.databaseService.db.Exec(sqlInsertDocument,
|
result, err := ds.databaseService.db.Exec(sqlInsertDocument,
|
||||||
doc.Title, doc.Content, doc.CreatedAt, doc.UpdatedAt)
|
doc.Title, doc.Content, doc.CreatedAt, doc.UpdatedAt)
|
||||||
|
|||||||
@@ -302,7 +302,9 @@ func (s *SelfUpdateService) handleUpdateSuccess(result *SelfUpdateResult) {
|
|||||||
if err := s.configService.Set("updates.version", result.LatestVersion); err != nil {
|
if err := s.configService.Set("updates.version", result.LatestVersion); err != nil {
|
||||||
s.logger.Error("update config version failed", "error", err)
|
s.logger.Error("update config version failed", "error", err)
|
||||||
}
|
}
|
||||||
|
if err := s.configService.Set("metadata.version", result.LatestVersion); err != nil {
|
||||||
|
s.logger.Error("update config version failed", "error", err)
|
||||||
|
}
|
||||||
// 执行配置迁移
|
// 执行配置迁移
|
||||||
if err := s.configService.MigrateConfig(); err != nil {
|
if err := s.configService.MigrateConfig(); err != nil {
|
||||||
s.logger.Error("migrate config failed", "error", err)
|
s.logger.Error("migrate config failed", "error", err)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (wh *WindowHelper) FocusMainWindow() bool {
|
|||||||
func (wh *WindowHelper) AutoShowMainWindow() {
|
func (wh *WindowHelper) AutoShowMainWindow() {
|
||||||
window := wh.MustGetMainWindow()
|
window := wh.MustGetMainWindow()
|
||||||
if window.IsVisible() {
|
if window.IsVisible() {
|
||||||
window.Hide()
|
window.Focus()
|
||||||
} else {
|
} else {
|
||||||
window.Show()
|
window.Show()
|
||||||
}
|
}
|
||||||
|
|||||||
8
main.go
8
main.go
@@ -39,8 +39,8 @@ func main() {
|
|||||||
// 'Bind' is a list of Go struct instances. The frontend has access to the methods of these instances.
|
// 'Bind' is a list of Go struct instances. The frontend has access to the methods of these instances.
|
||||||
// 'Mac' options tailor the application when running an macOS.
|
// 'Mac' options tailor the application when running an macOS.
|
||||||
app := application.New(application.Options{
|
app := application.New(application.Options{
|
||||||
Name: "voidraft",
|
Name: constant.VOIDRAFT_APP_NAME,
|
||||||
Description: "voidraft",
|
Description: constant.VOIDRAFT_APP_DESCRIPTION,
|
||||||
Services: serviceManager.GetServices(),
|
Services: serviceManager.GetServices(),
|
||||||
Assets: application.AssetOptions{
|
Assets: application.AssetOptions{
|
||||||
Handler: application.AssetFileServerFS(assets),
|
Handler: application.AssetFileServerFS(assets),
|
||||||
@@ -50,7 +50,7 @@ func main() {
|
|||||||
ApplicationShouldTerminateAfterLastWindowClosed: true,
|
ApplicationShouldTerminateAfterLastWindowClosed: true,
|
||||||
},
|
},
|
||||||
SingleInstance: &application.SingleInstanceOptions{
|
SingleInstance: &application.SingleInstanceOptions{
|
||||||
UniqueID: "com.voidraft",
|
UniqueID: constant.VOIDRAFT_APP_NAME,
|
||||||
EncryptionKey: encryptionKey,
|
EncryptionKey: encryptionKey,
|
||||||
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
|
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
|
||||||
if window != nil {
|
if window != nil {
|
||||||
@@ -71,7 +71,7 @@ func main() {
|
|||||||
// 'URL' is the URL that will be loaded into the webview.
|
// 'URL' is the URL that will be loaded into the webview.
|
||||||
mainWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
mainWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Name: constant.VOIDRAFT_MAIN_WINDOW_NAME,
|
Name: constant.VOIDRAFT_MAIN_WINDOW_NAME,
|
||||||
Title: constant.VOIDRAFT_WINDOW_TITLE,
|
Title: constant.VOIDRAFT_APP_NAME,
|
||||||
Width: constant.VOIDRAFT_WINDOW_WIDTH,
|
Width: constant.VOIDRAFT_WINDOW_WIDTH,
|
||||||
Height: constant.VOIDRAFT_WINDOW_HEIGHT,
|
Height: constant.VOIDRAFT_WINDOW_HEIGHT,
|
||||||
Hidden: false,
|
Hidden: false,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM Simplified version management script - Windows version
|
|
||||||
REM Auto-increment patch version from git tags or use custom version
|
|
||||||
|
|
||||||
REM Configuration section - Set custom version here if needed
|
|
||||||
set "CUSTOM_VERSION="
|
|
||||||
REM Example: set "CUSTOM_VERSION=2.0.0"
|
|
||||||
|
|
||||||
set "VERSION_FILE=version.txt"
|
|
||||||
|
|
||||||
REM Check if custom version is set
|
|
||||||
if not "%CUSTOM_VERSION%"=="" (
|
|
||||||
echo [INFO] Using custom version: %CUSTOM_VERSION%
|
|
||||||
set "VERSION=%CUSTOM_VERSION%"
|
|
||||||
goto :save_version
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Check if git is available
|
|
||||||
git --version >nul 2>&1
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo [ERROR] Git is not installed or not in PATH
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Check if in git repository
|
|
||||||
git rev-parse --git-dir >nul 2>&1
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo [ERROR] Not in a git repository
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Sync remote tags
|
|
||||||
echo [INFO] Syncing remote tags...
|
|
||||||
echo [INFO] Deleting local tags...
|
|
||||||
for /f "delims=" %%i in ('git tag -l') do git tag -d %%i >nul 2>&1
|
|
||||||
|
|
||||||
echo [INFO] Fetching remote tags...
|
|
||||||
git fetch origin --prune >nul 2>&1
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo [WARNING] Failed to fetch from remote, using local tags
|
|
||||||
) else (
|
|
||||||
echo [INFO] Remote tags synced successfully
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Get latest git tag
|
|
||||||
git describe --abbrev=0 --tags > temp_tag.txt 2>nul
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo [ERROR] No git tags found in repository
|
|
||||||
if exist temp_tag.txt del temp_tag.txt
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
set /p LATEST_TAG=<temp_tag.txt
|
|
||||||
del temp_tag.txt
|
|
||||||
|
|
||||||
if not defined LATEST_TAG (
|
|
||||||
echo [ERROR] Failed to read git tag
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [INFO] Latest git tag: %LATEST_TAG%
|
|
||||||
|
|
||||||
REM Remove v prefix
|
|
||||||
set "CLEAN_VERSION=%LATEST_TAG:v=%"
|
|
||||||
|
|
||||||
REM Split version number and increment patch
|
|
||||||
for /f "tokens=1,2,3 delims=." %%a in ("%CLEAN_VERSION%") do (
|
|
||||||
set "MAJOR=%%a"
|
|
||||||
set "MINOR=%%b"
|
|
||||||
set /a "PATCH=%%c+1"
|
|
||||||
)
|
|
||||||
|
|
||||||
set "VERSION=%MAJOR%.%MINOR%.%PATCH%"
|
|
||||||
echo [INFO] Auto-incremented patch version: %VERSION%
|
|
||||||
|
|
||||||
:save_version
|
|
||||||
REM Output version information
|
|
||||||
echo [SUCCESS] Version resolved: %VERSION%
|
|
||||||
echo VERSION=%VERSION%
|
|
||||||
|
|
||||||
REM Save to file
|
|
||||||
echo VERSION=%VERSION% > %VERSION_FILE%
|
|
||||||
|
|
||||||
echo [INFO] Version information saved to %VERSION_FILE%
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 配置区域 - 如需自定义版本,请在此处设置
|
|
||||||
CUSTOM_VERSION=""
|
|
||||||
# 示例: CUSTOM_VERSION="2.0.0"
|
|
||||||
|
|
||||||
VERSION_FILE="version.txt"
|
|
||||||
|
|
||||||
# 检查是否设置了自定义版本
|
|
||||||
if [ -n "$CUSTOM_VERSION" ]; then
|
|
||||||
echo "[INFO] Using custom version: $CUSTOM_VERSION"
|
|
||||||
VERSION="$CUSTOM_VERSION"
|
|
||||||
else
|
|
||||||
# 检查git是否可用
|
|
||||||
if ! command -v git &> /dev/null; then
|
|
||||||
echo "[ERROR] Git is not installed or not in PATH"
|
|
||||||
exit 1
|
|
||||||
elif ! git rev-parse --git-dir &> /dev/null; then
|
|
||||||
echo "[ERROR] Not in a git repository"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
# 同步远程标签
|
|
||||||
echo "[INFO] Syncing remote tags..."
|
|
||||||
echo "[INFO] Deleting local tags..."
|
|
||||||
git tag -l | xargs git tag -d &> /dev/null
|
|
||||||
|
|
||||||
echo "[INFO] Fetching remote tags..."
|
|
||||||
if git fetch origin --prune &> /dev/null; then
|
|
||||||
echo "[INFO] Remote tags synced successfully"
|
|
||||||
else
|
|
||||||
echo "[WARNING] Failed to fetch from remote, using local tags"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 获取最新的git标签
|
|
||||||
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
|
||||||
echo "[ERROR] No git tags found in repository"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "[INFO] Latest git tag: $LATEST_TAG"
|
|
||||||
|
|
||||||
# 移除v前缀
|
|
||||||
CLEAN_VERSION=${LATEST_TAG#v}
|
|
||||||
|
|
||||||
# 分割版本号并递增patch版本
|
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_VERSION"
|
|
||||||
PATCH=$((PATCH + 1))
|
|
||||||
|
|
||||||
VERSION="$MAJOR.$MINOR.$PATCH"
|
|
||||||
echo "[INFO] Auto-incremented patch version: $VERSION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 输出版本信息
|
|
||||||
echo "VERSION=$VERSION"
|
|
||||||
|
|
||||||
# 保存到文件供其他脚本使用
|
|
||||||
echo "VERSION=$VERSION" > "$VERSION_FILE"
|
|
||||||
|
|
||||||
echo "[INFO] Version information saved to $VERSION_FILE"
|
|
||||||
@@ -1 +1 @@
|
|||||||
VERSION=1.5.2
|
VERSION=1.5.3
|
||||||
|
|||||||
Reference in New Issue
Block a user