20 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-15 18:38:56 +00:00
031aa49f9f Added markdown and mermaid preview 2025-11-16 02:37:30 +08:00
1d7aee4cea 🐛 Fixed data migration issues 2025-11-14 20:16:16 +08:00
dec3ef5ef4 Added cursor protection extension 2025-11-13 21:00:35 +08:00
d42f913250 Added editor cursor state persistence 2025-11-13 20:44:47 +08:00
bae4e663fb ⬆️ Upgrade dependencies 2025-11-13 20:02:22 +08:00
a17e060d16 ⬆️ Upgrade dependencies 2025-11-13 19:36:11 +08:00
71946965eb 🐛 Fixed database constraint issues 2025-11-08 17:35:29 +08:00
d4cd22d234 🚀 Update build and release workflows 2025-11-08 17:17:07 +08:00
05f2f7d46d 🚀 Update build and release workflows 2025-11-08 17:05:31 +08:00
9deb2744a9 🚀 Update build and release workflows 2025-11-08 16:24:22 +08:00
6fac7c42d6 🚀 Update build and release workflows 2025-11-08 16:03:26 +08:00
3393bc84e3 🚀 Add build and release workflows 2025-11-08 15:50:30 +08:00
64 changed files with 11044 additions and 988 deletions

325
.github/workflows/build-release.yml vendored Normal file
View File

@@ -0,0 +1,325 @@
name: Build and Release Voidraft
on:
# 推送标签时触发(用于正式发布)
push:
tags:
- 'v*' # 触发条件:推送 v 开头的标签,如 v1.0.0
branches:
- main # 仅当标签在 main 分支时触发
# 手动触发(用于测试)
workflow_dispatch:
inputs:
release:
description: '是否创建 Release测试时选 false'
required: true
type: boolean
default: false
platforms:
description: '要构建的平台用逗号分隔windows,linux,macos-intel,macos-arm'
required: true
type: string
default: 'windows,linux'
env:
NODE_OPTIONS: "--max-old-space-size=4096" # 防止 Node.js 内存溢出
jobs:
# 准备构建配置
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
should_release: ${{ steps.check-release.outputs.should_release }}
steps:
- name: 确定构建平台
id: set-matrix
run: |
# 如果是手动触发,根据输入决定平台
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PLATFORMS="${{ github.event.inputs.platforms }}"
else
# 标签触发,构建所有平台
PLATFORMS="windows,linux,macos-intel,macos-arm"
fi
echo "构建平台: $PLATFORMS"
# 构建矩阵 JSON
MATRIX='{"include":[]}'
if [[ "$PLATFORMS" == *"windows"* ]]; then
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"windows-latest","os":"windows","arch":"amd64","platform_dir":"windows","id":"windows"}]')
fi
if [[ "$PLATFORMS" == *"linux"* ]]; then
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"ubuntu-22.04","os":"linux","arch":"amd64","platform_dir":"linux","id":"linux"}]')
fi
if [[ "$PLATFORMS" == *"macos-intel"* ]]; then
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"amd64","platform_dir":"darwin","id":"macos-intel"}]')
fi
if [[ "$PLATFORMS" == *"macos-arm"* ]]; then
MATRIX=$(echo "$MATRIX" | jq -c '.include += [{"platform":"macos-latest","os":"darwin","arch":"arm64","platform_dir":"darwin","id":"macos-arm"}]')
fi
# 使用 -c 确保输出紧凑的单行 JSON符合 GitHub Actions 输出格式
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> $GITHUB_OUTPUT
# 输出美化的 JSON 用于日志查看
echo "生成的矩阵:"
echo "$MATRIX" | jq .
- name: 检查是否创建 Release
id: check-release
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# 手动触发,根据输入决定
echo "should_release=${{ github.event.inputs.release }}" >> $GITHUB_OUTPUT
else
# 标签触发,自动创建 Release
echo "should_release=true" >> $GITHUB_OUTPUT
fi
build:
needs: prepare
if: ${{ fromJson(needs.prepare.outputs.matrix).include[0] != null }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
submodules: recursive
- name: 设置 Go 环境
uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- name: 设置 Node.js 环境
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
# Linux 平台依赖
- name: 安装 Linux 依赖
if: matrix.os == 'linux'
run: |
sudo apt-get update
# Wails 3 + 项目特定依赖
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libx11-dev \
rpm \
fuse \
file
# 依赖说明:
# - build-essential: C/C++ 编译工具链
# - pkg-config: 包配置工具
# - libgtk-3-dev: GTK3 GUI 框架
# - libwebkit2gtk-4.1-dev: WebKit2GTK 4.1 (Wails 3 要求)
# - libayatana-appindicator3-dev: 系统托盘支持
# - librsvg2-dev: SVG 图标支持
# - patchelf: 修改 ELF 二进制文件
# - libx11-dev: X11 库 (热键服务依赖)
# - rpm: RPM 打包工具
# - fuse: AppImage 运行依赖
# - file: 文件类型检测工具
# Windows 平台依赖
- name: 设置 Windows 构建环境
if: matrix.os == 'windows'
run: |
# 安装 NSIS (用于创建安装程序)
choco install nsis -y
# 将 NSIS 添加到 PATH
echo "C:\Program Files (x86)\NSIS" >> $GITHUB_PATH
shell: bash
# macOS 平台依赖
- name: 设置 macOS 构建环境
if: matrix.os == 'darwin'
run: |
# Xcode 命令行工具通常已安装
xcode-select --install 2>/dev/null || true
# 安装 Wails CLI
- name: 安装 Wails CLI
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
# 安装前端依赖
- name: 安装前端依赖
working-directory: frontend
run: npm ci
# 构建前端
- name: 构建前端
working-directory: frontend
run: npm run build
# 使用 Wails Task 构建和打包应用
- name: 构建和打包 Wails 应用
run: wails3 task ${{ matrix.id }}:package PRODUCTION=true ARCH=${{ matrix.arch }}
env:
CGO_ENABLED: 1
APP_NAME: voidraft
BIN_DIR: bin
ROOT_DIR: .
shell: bash
# 整理构建产物
- name: 整理构建产物
id: organize_artifacts
run: |
echo "=== 构建产物列表 ==="
ls -lhR bin/ || echo "bin 目录不存在"
# 创建输出目录
mkdir -p artifacts
# 根据平台复制产物
if [ "${{ matrix.os }}" = "windows" ]; then
echo "Windows 平台:查找 NSIS 安装程序"
find . -name "*.exe" -type f
cp bin/*.exe artifacts/ 2>/dev/null || echo "未找到 .exe 文件"
elif [ "${{ matrix.os }}" = "linux" ]; then
echo "Linux 平台:查找 AppImage, deb, rpm, archlinux 包"
find bin -type f
cp bin/*.AppImage artifacts/ 2>/dev/null || echo "未找到 AppImage"
cp bin/*.deb artifacts/ 2>/dev/null || echo "未找到 deb"
cp bin/*.rpm artifacts/ 2>/dev/null || echo "未找到 rpm"
cp bin/*.pkg.tar.zst artifacts/ 2>/dev/null || echo "未找到 archlinux 包"
elif [ "${{ matrix.os }}" = "darwin" ]; then
echo "macOS 平台:查找 .app bundle"
find bin -name "*.app" -type d
# macOS: .app bundle打包成 zip
if [ -d "bin/voidraft.app" ]; then
cd bin
zip -r ../artifacts/voidraft-darwin-${{ matrix.arch }}.app.zip voidraft.app
cd ..
else
echo "未找到 .app bundle"
fi
fi
echo "=== 最终产物 ==="
ls -lh artifacts/ || echo "artifacts 目录为空"
shell: bash
# 上传构建产物到 Artifacts
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: voidraft-${{ matrix.id }}
path: artifacts/*
if-no-files-found: warn
# 创建 GitHub Release 并上传所有构建产物
release:
needs: [prepare, build]
if: ${{ needs.prepare.outputs.should_release == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 下载所有构建产物
uses: actions/download-artifact@v4
with:
path: artifacts
- name: 显示下载的文件
run: |
echo "下载的构建产物:"
ls -R artifacts/
- name: 准备 Release 文件
run: |
mkdir -p release
find artifacts -type f -exec cp {} release/ \;
ls -lh release/
- name: 生成 Release 说明
id: release_notes
run: |
# 获取版本号
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.sha }}"
VERSION_NAME="测试构建 ${VERSION:0:7}"
else
VERSION="${{ github.ref_name }}"
VERSION_NAME="${VERSION}"
fi
cat > release_notes.md << EOF
## Voidraft ${VERSION_NAME}
### 📦 下载
根据你的操作系统选择对应的版本:
- **Windows (64位)**: \`voidraft-windows-amd64.exe\`
- **Linux (64位)**: \`voidraft-linux-amd64\`
- **macOS (Intel)**: \`voidraft-darwin-amd64.zip\`
- **macOS (Apple Silicon)**: \`voidraft-darwin-arm64.zip\`
### 📝 更新内容
请查看 [提交历史](../../commits/${{ github.ref_name }}) 了解本次更新的详细内容。
### 💡 使用说明
#### Windows
1. 下载 `voidraft-windows-amd64.exe`
2. 直接运行即可
#### Linux
1. 下载 `voidraft-linux-amd64`
2. 添加执行权限:`chmod +x voidraft-linux-amd64`
3. 运行:`./voidraft-linux-amd64`
#### macOS
1. 下载对应架构的 zip 文件
2. 解压后运行
3. 如果提示无法打开,请在 系统偏好设置 > 安全性与隐私 中允许运行
---
构建时间: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
EOF
- name: 创建 GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release/*
body_path: release_notes.md
draft: false
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
tag_name: ${{ github.event_name == 'workflow_dispatch' && format('test-{0}', github.sha) || github.ref_name }}
name: ${{ github.event_name == 'workflow_dispatch' && format('测试构建 {0}', github.sha) || github.ref_name }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

293
build/COMMANDS.md Normal file
View File

@@ -0,0 +1,293 @@
# Wails 3 命令参考表
本文档列出了 Voidraft 项目中使用的所有 Wails 3 命令和参数。
## 📋 命令总览
| 类别 | 命令 | 用途 |
|------|------|------|
| 生成工具 | `wails3 generate` | 生成项目所需的各种资源文件 |
| 打包工具 | `wails3 tool package` | 使用 nfpm 打包应用程序 |
| 任务执行 | `wails3 task` | 执行 Taskfile.yml 中定义的任务 |
---
## 🔧 详细命令参数
### 1. `wails3 generate bindings`
**生成 TypeScript 绑定文件**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-f` | 构建标志 | `''` (空字符串) |
| `-clean` | 清理旧绑定 | `true` |
| `-ts` | 生成 TypeScript | 无需值 |
**使用位置:** `build/Taskfile.yml:53`
**完整命令:**
```bash
wails3 generate bindings -f '' -clean=true -ts
```
---
### 2. `wails3 generate icons`
**从单个图片生成多平台图标**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-input` | 输入图片路径 | `appicon.png` |
| `-macfilename` | macOS 图标输出路径 | `darwin/icons.icns` |
| `-windowsfilename` | Windows 图标输出路径 | `windows/icons.ico` |
**使用位置:** `build/Taskfile.yml:64`
**完整命令:**
```bash
wails3 generate icons \
-input appicon.png \
-macfilename darwin/icons.icns \
-windowsfilename windows/icons.ico
```
---
### 3. `wails3 generate syso`
**生成 Windows .syso 资源文件**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-arch` | 目标架构 | `amd64` / `arm64` |
| `-icon` | 图标文件 | `windows/icon.ico` |
| `-manifest` | 清单文件 | `windows/wails.exe.manifest` |
| `-info` | 应用信息 JSON | `windows/info.json` |
| `-out` | 输出文件路径 | `../wails_windows_amd64.syso` |
**使用位置:** `build/windows/Taskfile.yml:42`
**完整命令:**
```bash
wails3 generate syso \
-arch amd64 \
-icon windows/icon.ico \
-manifest windows/wails.exe.manifest \
-info windows/info.json \
-out ../wails_windows_amd64.syso
```
---
### 4. `wails3 generate webview2bootstrapper`
**生成 Windows WebView2 引导程序**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-dir` | 输出目录 | `build/windows/nsis` |
**使用位置:** `build/windows/Taskfile.yml:55`
**完整命令:**
```bash
wails3 generate webview2bootstrapper -dir "build/windows/nsis"
```
**说明:** 下载 Microsoft Edge WebView2 运行时安装程序,用于 NSIS 打包。
---
### 5. `wails3 generate .desktop`
**生成 Linux .desktop 桌面文件**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-name` | 应用名称 | `voidraft` |
| `-exec` | 可执行文件名 | `voidraft` |
| `-icon` | 图标名称 | `appicon` |
| `-outputfile` | 输出文件路径 | `build/linux/voidraft.desktop` |
| `-categories` | 应用分类 | `Development;` |
**使用位置:** `build/linux/Taskfile.yml:107`
**完整命令:**
```bash
wails3 generate .desktop \
-name "voidraft" \
-exec "voidraft" \
-icon "appicon" \
-outputfile build/linux/voidraft.desktop \
-categories "Development;"
```
---
### 6. `wails3 generate appimage`
**生成 Linux AppImage 包**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-binary` | 二进制文件名 | `voidraft` |
| `-icon` | 图标文件路径 | `../../appicon.png` |
| `-desktopfile` | .desktop 文件路径 | `../voidraft.desktop` |
| `-outputdir` | 输出目录 | `../../../bin` |
| `-builddir` | 构建临时目录 | `build/linux/appimage/build` |
**使用位置:** `build/linux/Taskfile.yml:49`
**完整命令:**
```bash
wails3 generate appimage \
-binary voidraft \
-icon ../../appicon.png \
-desktopfile ../voidraft.desktop \
-outputdir ../../../bin \
-builddir build/linux/appimage/build
```
**说明:** 自动下载 linuxdeploy 工具并创建 AppImage。
---
### 7. `wails3 tool package` (deb)
**创建 Debian/Ubuntu .deb 包**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-name` | 包名称 | `voidraft` |
| `-format` | 包格式 | `deb` |
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
| `-out` | 输出目录 | `./bin` |
**使用位置:** `build/linux/Taskfile.yml:90`
**完整命令:**
```bash
wails3 tool package \
-name voidraft \
-format deb \
-config ./build/linux/nfpm/nfpm.yaml \
-out ./bin
```
---
### 8. `wails3 tool package` (rpm)
**创建 RedHat/CentOS/Fedora .rpm 包**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-name` | 包名称 | `voidraft` |
| `-format` | 包格式 | `rpm` |
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
| `-out` | 输出目录 | `./bin` |
**使用位置:** `build/linux/Taskfile.yml:95`
**完整命令:**
```bash
wails3 tool package \
-name voidraft \
-format rpm \
-config ./build/linux/nfpm/nfpm.yaml \
-out ./bin
```
---
### 9. `wails3 tool package` (archlinux)
**创建 Arch Linux .pkg.tar.zst 包**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `-name` | 包名称 | `voidraft` |
| `-format` | 包格式 | `archlinux` |
| `-config` | nfpm 配置文件 | `./build/linux/nfpm/nfpm.yaml` |
| `-out` | 输出目录 | `./bin` |
**使用位置:** `build/linux/Taskfile.yml:100`
**完整命令:**
```bash
wails3 tool package \
-name voidraft \
-format archlinux \
-config ./build/linux/nfpm/nfpm.yaml \
-out ./bin
```
---
### 10. `wails3 task`
**执行 Taskfile.yml 中定义的任务**
| 参数 | 说明 | 示例值 |
|------|------|--------|
| `[taskname]` | 任务名称 | `build`, `package`, `run` |
| `[VAR=value]` | 变量赋值 | `PRODUCTION=true`, `ARCH=amd64` |
**常用任务:**
| 任务 | 说明 | 命令 |
|------|------|------|
| `build` | 构建应用 | `wails3 task build PRODUCTION=true` |
| `package` | 打包应用 | `wails3 task package` |
| `run` | 运行应用 | `wails3 task run` |
**使用位置:** `build/config.yml`
---
## 📊 平台对应命令表
| 平台 | 主要命令 | 产物 |
|------|----------|------|
| **Windows** | `generate syso`<br>`generate webview2bootstrapper` | `.syso` 资源文件<br>NSIS 安装程序 |
| **Linux** | `generate .desktop`<br>`generate appimage`<br>`tool package -format deb/rpm/archlinux` | `.desktop` 文件<br>`.AppImage`<br>`.deb` / `.rpm` / `.pkg.tar.zst` |
| **macOS** | `generate icons` | `.icns` 图标<br>`.app` 应用包 |
| **通用** | `generate bindings`<br>`generate icons` | TypeScript 绑定<br>多平台图标 |
---
## 🚀 快速参考
### 完整构建流程
```bash
# 1. 生成绑定和图标
wails3 task common:generate:bindings
wails3 task common:generate:icons
# 2. 构建前端
cd frontend
npm install
npm run build
cd ..
# 3. 构建应用(各平台)
cd build/windows && wails3 task build PRODUCTION=true # Windows
cd build/linux && wails3 task build PRODUCTION=true # Linux
cd build/darwin && wails3 task build PRODUCTION=true # macOS
# 4. 打包应用(各平台)
cd build/windows && wails3 task package # NSIS 安装程序
cd build/linux && wails3 task package # AppImage + deb + rpm + archlinux
cd build/darwin && wails3 task package # .app bundle
```
---
## 📝 注意事项
1. **变量传递:** Task 命令支持通过 `VAR=value` 格式传递变量
2. **路径问题:** 相对路径基于 Taskfile.yml 所在目录
3. **依赖顺序:** 某些任务有依赖关系(通过 `deps:` 定义)
4. **环境变量:** 使用 `env:` 定义的环境变量会自动设置
---
## 🔗 相关文档
- [Wails 3 官方文档](https://v3alpha.wails.io/)
- [Taskfile 语法](https://taskfile.dev/)
- [nfpm 打包工具](https://nfpm.goreleaser.com/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,159 @@
// Enclose abbreviations in <abbr> tags
//
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
/**
* 环境接口,包含缩写定义
*/
interface AbbrEnv {
abbreviations?: { [key: string]: string };
}
/**
* markdown-it-abbr 插件
* 用于支持缩写语法
*/
export default function abbr_plugin(md: MarkdownIt): void {
const escapeRE = md.utils.escapeRE;
const arrayReplaceAt = md.utils.arrayReplaceAt;
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
// you can check character classes here:
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
const OTHER_CHARS = ' \r\n$+<=>^`|~';
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
let labelEnd: number;
let pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
if (pos + 2 >= max) { return false; }
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
const labelStart = pos;
for (; pos < max; pos++) {
const ch = state.src.charCodeAt(pos);
if (ch === 0x5B /* [ */) {
return false;
} else if (ch === 0x5D /* ] */) {
labelEnd = pos;
break;
} else if (ch === 0x5C /* \ */) {
pos++;
}
}
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
return false;
}
if (silent) { return true; }
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
const title = state.src.slice(labelEnd! + 2, max).trim();
if (label.length === 0) { return false; }
if (title.length === 0) { return false; }
const env = state.env as AbbrEnv;
if (!env.abbreviations) { env.abbreviations = {}; }
// prepend ':' to avoid conflict with Object.prototype members
if (typeof env.abbreviations[':' + label] === 'undefined') {
env.abbreviations[':' + label] = title;
}
state.line = startLine + 1;
return true;
}
function abbr_replace(state: StateCore): void {
const blockTokens = state.tokens;
const env = state.env as AbbrEnv;
if (!env.abbreviations) { return; }
const regSimple = new RegExp('(?:' +
Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') +
')');
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
'(' + Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') + ')' +
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
const reg = new RegExp(regText, 'g');
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
for (let i = tokens.length - 1; i >= 0; i--) {
const currentToken = tokens[i];
if (currentToken.type !== 'text') { continue; }
let pos = 0;
const text = currentToken.content;
reg.lastIndex = 0;
const nodes: Token[] = [];
// fast regexp run to determine whether there are any abbreviated words
// in the current token
if (!regSimple.test(text)) { continue; }
let m: RegExpExecArray | null;
while ((m = reg.exec(text))) {
if (m.index > 0 || m[1].length > 0) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos, m.index + m[1].length);
nodes.push(token);
}
const token_o = new state.Token('abbr_open', 'abbr', 1);
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
nodes.push(token_o);
const token_t = new state.Token('text', '', 0);
token_t.content = m[2];
nodes.push(token_t);
const token_c = new state.Token('abbr_close', 'abbr', -1);
nodes.push(token_c);
reg.lastIndex -= m[3].length;
pos = reg.lastIndex;
}
if (!nodes.length) { continue; }
if (pos < text.length) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos);
nodes.push(token);
}
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
}
}
}
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
}

View File

@@ -0,0 +1,209 @@
// Process definition lists
//
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
/**
* markdown-it-deflist 插件
* 用于支持定义列表语法
*/
export default function deflist_plugin(md: MarkdownIt): void {
const isSpace = md.utils.isSpace;
// Search `[:~][\n ]`, returns next pos after marker on success
// or -1 on fail.
function skipMarker(state: StateBlock, line: number): number {
let start = state.bMarks[line] + state.tShift[line];
const max = state.eMarks[line];
if (start >= max) { return -1; }
// Check bullet
const marker = state.src.charCodeAt(start++);
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
const pos = state.skipSpaces(start);
// require space after ":"
if (start === pos) { return -1; }
// no empty definitions, e.g. " : "
if (pos >= max) { return -1; }
return start;
}
function markTightParagraphs(state: StateBlock, idx: number): void {
const level = state.level + 2;
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
state.tokens[i + 2].hidden = true;
state.tokens[i].hidden = true;
i += 2;
}
}
}
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
if (silent) {
// quirk: validation mode validates a dd block only, not a whole deflist
if (state.ddIndent < 0) { return false; }
return skipMarker(state, startLine) >= 0;
}
let nextLine = startLine + 1;
if (nextLine >= endLine) { return false; }
if (state.isEmpty(nextLine)) {
nextLine++;
if (nextLine >= endLine) { return false; }
}
if (state.sCount[nextLine] < state.blkIndent) { return false; }
let contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { return false; }
// Start list
const listTokIdx = state.tokens.length;
let tight = true;
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
const listLines: [number, number] = [startLine, 0];
token_dl_o.map = listLines;
//
// Iterate list items
//
let dtLine = startLine;
let ddLine = nextLine;
// One definition list can contain multiple DTs,
// and one DT can be followed by multiple DDs.
//
// Thus, there is two loops here, and label is
// needed to break out of the second one
//
/* eslint no-labels:0,block-scoped-var:0 */
OUTER:
for (;;) {
let prevEmptyEnd = false;
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
token_dt_o.map = [dtLine, dtLine];
const token_i: Token = state.push('inline', '', 0);
token_i.map = [dtLine, dtLine];
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
token_i.children = [];
state.push('dt_close', 'dt', -1);
for (;;) {
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
const itemLines: [number, number] = [nextLine, 0];
token_dd_o.map = itemLines;
let pos = contentStart;
const max = state.eMarks[ddLine];
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
contentStart = pos;
const oldTight = state.tight;
const oldDDIndent = state.ddIndent;
const oldIndent = state.blkIndent;
const oldTShift = state.tShift[ddLine];
const oldSCount = state.sCount[ddLine];
const oldParentType = state.parentType;
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
state.sCount[ddLine] = offset;
state.tight = true;
state.parentType = 'deflist' as any;
state.md.block.tokenize(state, ddLine, endLine);
// If any of list item is tight, mark list as tight
if (!state.tight || prevEmptyEnd) {
tight = false;
}
// Item become loose if finish with empty line,
// but we should filter last element, because it means list finish
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
state.tShift[ddLine] = oldTShift;
state.sCount[ddLine] = oldSCount;
state.tight = oldTight;
state.parentType = oldParentType;
state.blkIndent = oldIndent;
state.ddIndent = oldDDIndent;
state.push('dd_close', 'dd', -1);
itemLines[1] = nextLine = state.line;
if (nextLine >= endLine) { break OUTER; }
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { break; }
ddLine = nextLine;
// go to the next loop iteration:
// insert DD tag and repeat checking
}
if (nextLine >= endLine) { break; }
dtLine = nextLine;
if (state.isEmpty(dtLine)) { break; }
if (state.sCount[dtLine] < state.blkIndent) { break; }
ddLine = dtLine + 1;
if (ddLine >= endLine) { break; }
if (state.isEmpty(ddLine)) { ddLine++; }
if (ddLine >= endLine) { break; }
if (state.sCount[ddLine] < state.blkIndent) { break; }
contentStart = skipMarker(state, ddLine);
if (contentStart < 0) { break; }
// go to the next loop iteration:
// insert DT and DD tags and repeat checking
}
// Finilize list
state.push('dl_close', 'dl', -1);
listLines[1] = nextLine;
state.line = nextLine;
// mark paragraphs tight if needed
if (tight) {
markTightParagraphs(state, listTokIdx);
}
return true;
}
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
}

View File

@@ -0,0 +1,4 @@
export { default as bare } from './lib/bare';
export { default as light } from './lib/light';
export { default as full } from './lib/full';

View File

@@ -0,0 +1,26 @@
import MarkdownIt from 'markdown-it';
import emoji_html from './render';
import emoji_replace from './replace';
import normalize_opts, { EmojiOptions } from './normalize_opts';
/**
* Bare emoji 插件(不包含预定义的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: {},
shortcuts: {},
enabled: []
};
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
md.renderer.rules.emoji = emoji_html;
md.core.ruler.after(
'linkify',
'emoji',
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
// Generated, don't edit
import { EmojiDefs } from '../normalize_opts';
const emojies: EmojiDefs = {
"grinning": "😀",
"smiley": "😃",
"smile": "😄",
"grin": "😁",
"laughing": "😆",
"satisfied": "😆",
"sweat_smile": "😅",
"joy": "😂",
"wink": "😉",
"blush": "😊",
"innocent": "😇",
"heart_eyes": "😍",
"kissing_heart": "😘",
"kissing": "😗",
"kissing_closed_eyes": "😚",
"kissing_smiling_eyes": "😙",
"yum": "😋",
"stuck_out_tongue": "😛",
"stuck_out_tongue_winking_eye": "😜",
"stuck_out_tongue_closed_eyes": "😝",
"neutral_face": "😐",
"expressionless": "😑",
"no_mouth": "😶",
"smirk": "😏",
"unamused": "😒",
"relieved": "😌",
"pensive": "😔",
"sleepy": "😪",
"sleeping": "😴",
"mask": "😷",
"dizzy_face": "😵",
"sunglasses": "😎",
"confused": "😕",
"worried": "😟",
"open_mouth": "😮",
"hushed": "😯",
"astonished": "😲",
"flushed": "😳",
"frowning": "😦",
"anguished": "😧",
"fearful": "😨",
"cold_sweat": "😰",
"disappointed_relieved": "😥",
"cry": "😢",
"sob": "😭",
"scream": "😱",
"confounded": "😖",
"persevere": "😣",
"disappointed": "😞",
"sweat": "😓",
"weary": "😩",
"tired_face": "😫",
"rage": "😡",
"pout": "😡",
"angry": "😠",
"smiling_imp": "😈",
"smiley_cat": "😺",
"smile_cat": "😸",
"joy_cat": "😹",
"heart_eyes_cat": "😻",
"smirk_cat": "😼",
"kissing_cat": "😽",
"scream_cat": "🙀",
"crying_cat_face": "😿",
"pouting_cat": "😾",
"heart": "❤️",
"hand": "✋",
"raised_hand": "✋",
"v": "✌️",
"point_up": "☝️",
"fist_raised": "✊",
"fist": "✊",
"monkey_face": "🐵",
"cat": "🐱",
"cow": "🐮",
"mouse": "🐭",
"coffee": "☕",
"hotsprings": "♨️",
"anchor": "⚓",
"airplane": "✈️",
"hourglass": "⌛",
"watch": "⌚",
"sunny": "☀️",
"star": "⭐",
"cloud": "☁️",
"umbrella": "☔",
"zap": "⚡",
"snowflake": "❄️",
"sparkles": "✨",
"black_joker": "🃏",
"mahjong": "🀄",
"phone": "☎️",
"telephone": "☎️",
"envelope": "✉️",
"pencil2": "✏️",
"black_nib": "✒️",
"scissors": "✂️",
"wheelchair": "♿",
"warning": "⚠️",
"aries": "♈",
"taurus": "♉",
"gemini": "♊",
"cancer": "♋",
"leo": "♌",
"virgo": "♍",
"libra": "♎",
"scorpius": "♏",
"sagittarius": "♐",
"capricorn": "♑",
"aquarius": "♒",
"pisces": "♓",
"heavy_multiplication_x": "✖️",
"heavy_plus_sign": "",
"heavy_minus_sign": "",
"heavy_division_sign": "➗",
"bangbang": "‼️",
"interrobang": "⁉️",
"question": "❓",
"grey_question": "❔",
"grey_exclamation": "❕",
"exclamation": "❗",
"heavy_exclamation_mark": "❗",
"wavy_dash": "〰️",
"recycle": "♻️",
"white_check_mark": "✅",
"ballot_box_with_check": "☑️",
"heavy_check_mark": "✔️",
"x": "❌",
"negative_squared_cross_mark": "❎",
"curly_loop": "➰",
"loop": "➿",
"part_alternation_mark": "〽️",
"eight_spoked_asterisk": "✳️",
"eight_pointed_black_star": "✴️",
"sparkle": "❇️",
"copyright": "©️",
"registered": "®️",
"tm": "™️",
"information_source": "",
"m": "Ⓜ️",
"black_circle": "⚫",
"white_circle": "⚪",
"black_large_square": "⬛",
"white_large_square": "⬜",
"black_medium_square": "◼️",
"white_medium_square": "◻️",
"black_medium_small_square": "◾",
"white_medium_small_square": "◽",
"black_small_square": "▪️",
"white_small_square": "▫️"
};
export default emojies;

View File

@@ -0,0 +1,45 @@
// Emoticons -> Emoji mapping.
//
// (!) Some patterns skipped, to avoid collisions
// without increase matcher complicity. Than can change in future.
//
// Places to look for more emoticons info:
//
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
// - https://github.com/wooorm/emoticon/blob/master/Support.md
// - http://factoryjoe.com/projects/emoticons/
//
import { EmojiShortcuts } from '../normalize_opts';
const shortcuts: EmojiShortcuts = {
angry: ['>:(', '>:-('],
blush: [':")', ':-")'],
broken_heart: ['</3', '<\\3'],
// :\ and :-\ not used because of conflict with markdown escaping
confused: [':/', ':-/'], // twemoji shows question
cry: [":'(", ":'-(", ':,(', ':,-('],
frowning: [':(', ':-('],
heart: ['<3'],
imp: [']:(', ']:-('],
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
kissing: [':*', ':-*'],
laughing: ['x-)', 'X-)'],
neutral_face: [':|', ':-|'],
open_mouth: [':o', ':-o', ':O', ':-O'],
rage: [':@', ':-@'],
smile: [':D', ':-D'],
smiley: [':)', ':-)'],
smiling_imp: [']:)', ']:-)'],
sob: [":,'(", ":,'-(", ';(', ';-('],
stuck_out_tongue: [':P', ':-P'],
sunglasses: ['8-)', 'B-)'],
sweat: [',:(', ',:-('],
sweat_smile: [',:)', ',:-)'],
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
wink: [';)', ';-)']
};
export default shortcuts;

View File

@@ -0,0 +1,21 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/full';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Full emoji 插件(包含完整的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -0,0 +1,21 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/light';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Light emoji 插件(包含常用的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -0,0 +1,95 @@
/**
* Emoji 定义类型
*/
export interface EmojiDefs {
[key: string]: string;
}
/**
* Emoji 快捷方式类型
*/
export interface EmojiShortcuts {
[key: string]: string | string[];
}
/**
* 输入选项接口
*/
export interface EmojiOptions {
defs: EmojiDefs;
shortcuts: EmojiShortcuts;
enabled: string[];
}
/**
* 标准化后的选项接口
*/
export interface NormalizedEmojiOptions {
defs: EmojiDefs;
shortcuts: { [key: string]: string };
scanRE: RegExp;
replaceRE: RegExp;
}
/**
* 转义正则表达式特殊字符
*/
function quoteRE(str: string): string {
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
}
/**
* 将输入选项转换为更可用的格式并编译搜索正则表达式
*/
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
let emojies = options.defs;
// Filter emojies by whitelist, if needed
if (options.enabled.length) {
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
return acc;
}, {});
}
// Flatten shortcuts to simple object: { alias: emoji_name }
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
// Skip aliases for filtered emojies, to reduce regexp
if (!emojies[key]) return acc;
if (Array.isArray(options.shortcuts[key])) {
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
return acc;
}
acc[options.shortcuts[key] as string] = key;
return acc;
}, {});
const keys = Object.keys(emojies);
let names: string;
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
if (keys.length === 0) {
names = '^$';
} else {
// Compile regexp
names = keys
.map((name: string) => { return `:${name}:`; })
.concat(Object.keys(shortcuts))
.sort()
.reverse()
.map((name: string) => { return quoteRE(name); })
.join('|');
}
const scanRE = RegExp(names);
const replaceRE = RegExp(names, 'g');
return {
defs: emojies,
shortcuts,
scanRE,
replaceRE
};
}

View File

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

View File

@@ -0,0 +1,97 @@
import MarkdownIt, { StateCore, Token } from 'markdown-it';
import { EmojiDefs } from './normalize_opts';
/**
* Emoji 和快捷方式替换逻辑
*
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
* 但是,谁在乎呢...
*/
export default function create_rule(
md: MarkdownIt,
emojies: EmojiDefs,
shortcuts: { [key: string]: string },
scanRE: RegExp,
replaceRE: RegExp
) {
const arrayReplaceAt = md.utils.arrayReplaceAt;
const ucm = md.utils.lib.ucmicro;
const has = md.utils.has;
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
let last_pos = 0;
const nodes: Token[] = [];
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
let emoji_name: string;
// Validate emoji name
if (has(shortcuts, match)) {
// replace shortcut with full name
emoji_name = shortcuts[match];
// Don't allow letters before any shortcut (as in no ":/" in http://)
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
// Don't allow letters after any shortcut
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
return '';
}
} else {
emoji_name = match.slice(1, -1);
}
// Add new tokens to pending list
if (offset > last_pos) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos, offset);
nodes.push(token);
}
const token = new TokenConstructor('emoji', '', 0);
token.markup = emoji_name;
token.content = emojies[emoji_name];
nodes.push(token);
last_pos = offset + match.length;
return '';
});
if (last_pos < text.length) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos);
nodes.push(token);
}
return nodes;
}
return function emoji_replace(state: StateCore): void {
let token: Token;
const blockTokens = state.tokens;
let autolinkLevel = 0;
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
// Use reversed logic in links start/end match
for (let i = tokens.length - 1; i >= 0; i--) {
token = tokens[i];
if (token.type === 'link_open' || token.type === 'link_close') {
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
}
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(
tokens, i, splitTextToken(token.content, token.level, state.Token)
);
}
}
}
};
}

View File

@@ -0,0 +1,390 @@
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
/**
* 脚注元数据接口
*/
interface FootnoteMeta {
id: number;
subId: number;
label: string;
}
/**
* 脚注列表项接口
*/
interface FootnoteItem {
label?: string;
content?: string;
tokens?: Token[];
count: number;
}
/**
* 环境接口
*/
interface FootnoteEnv {
footnotes?: {
refs?: { [key: string]: number };
list?: FootnoteItem[];
};
docId?: string;
}
/// /////////////////////////////////////////////////////////////////////////////
// Renderer partials
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
const n = Number(tokens[idx].meta.id + 1).toString();
let prefix = '';
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
return prefix + n;
}
function render_footnote_caption(tokens: Token[], idx: number): string {
let n = Number(tokens[idx].meta.id + 1).toString();
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
return `[${n}]`;
}
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
let refid = id;
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
}
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
'<section class="footnotes">\n' +
'<ol class="footnotes-list">\n';
}
function render_footnote_block_close(): string {
return '</ol>\n</section>\n';
}
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
return `<li id="fn${id}" class="footnote-item">`;
}
function render_footnote_close(): string {
return '</li>\n';
}
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
}
/**
* markdown-it-footnote 插件
* 用于支持脚注语法
*/
export default function footnote_plugin(md: MarkdownIt): void {
const parseLinkLabel = md.helpers.parseLinkLabel;
const isSpace = md.utils.isSpace;
md.renderer.rules.footnote_ref = render_footnote_ref;
md.renderer.rules.footnote_block_open = render_footnote_block_open;
md.renderer.rules.footnote_block_close = render_footnote_block_close;
md.renderer.rules.footnote_open = render_footnote_open;
md.renderer.rules.footnote_close = render_footnote_close;
md.renderer.rules.footnote_anchor = render_footnote_anchor;
// helpers (only used in other rules, no tokens are attached to those)
md.renderer.rules.footnote_caption = render_footnote_caption;
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
// Process footnote block definition
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// line should be at least 5 chars - "[^x]:"
if (start + 4 > max) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
if (silent) return true;
pos++;
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.refs) env.footnotes.refs = {};
const label = state.src.slice(start + 2, pos - 2);
env.footnotes.refs[`:${label}`] = -1;
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
token_fref_o.meta = { label };
token_fref_o.level = state.level++;
state.tokens.push(token_fref_o);
const oldBMark = state.bMarks[startLine];
const oldTShift = state.tShift[startLine];
const oldSCount = state.sCount[startLine];
const oldParentType = state.parentType;
const posAfterColon = pos;
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
let offset = initial;
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
state.tShift[startLine] = pos - posAfterColon;
state.sCount[startLine] = offset - initial;
state.bMarks[startLine] = posAfterColon;
state.blkIndent += 4;
state.parentType = 'footnote' as any;
if (state.sCount[startLine] < state.blkIndent) {
state.sCount[startLine] += state.blkIndent;
}
state.md.block.tokenize(state, startLine, endLine);
state.parentType = oldParentType;
state.blkIndent -= 4;
state.tShift[startLine] = oldTShift;
state.sCount[startLine] = oldSCount;
state.bMarks[startLine] = oldBMark;
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
token_fref_c.level = --state.level;
state.tokens.push(token_fref_c);
return true;
}
// Process inline footnotes (^[...])
function footnote_inline(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (start + 2 >= max) return false;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
const labelStart = start + 2;
const labelEnd = parseLinkLabel(state, start + 1);
// parser failed to find ']', so it's not a valid note
if (labelEnd < 0) return false;
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.list) env.footnotes.list = [];
const footnoteId = env.footnotes.list.length;
const tokens: Token[] = [];
state.md.inline.parse(
state.src.slice(labelStart, labelEnd),
state.md,
state.env,
tokens
);
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId };
env.footnotes.list[footnoteId] = {
content: state.src.slice(labelStart, labelEnd),
tokens,
count: 0
};
}
state.pos = labelEnd + 1;
state.posMax = max;
return true;
}
// Process footnote references ([^...])
function footnote_ref(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
// should be at least 4 chars - "[^x]"
if (start + 3 > max) return false;
const env = state.env as FootnoteEnv;
if (!env.footnotes || !env.footnotes.refs) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x0A) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos >= max) return false;
pos++;
const label = state.src.slice(start + 2, pos - 1);
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
if (!silent) {
if (!env.footnotes.list) env.footnotes.list = [];
let footnoteId: number;
if (env.footnotes.refs[`:${label}`] < 0) {
footnoteId = env.footnotes.list.length;
env.footnotes.list[footnoteId] = { label, count: 0 };
env.footnotes.refs[`:${label}`] = footnoteId;
} else {
footnoteId = env.footnotes.refs[`:${label}`];
}
const footnoteSubId = env.footnotes.list[footnoteId].count;
env.footnotes.list[footnoteId].count++;
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId, subId: footnoteSubId, label };
}
state.pos = pos;
state.posMax = max;
return true;
}
// Glue footnote tokens to end of token stream
function footnote_tail(state: StateCore): void {
let tokens: Token[] | null = null;
let current: Token[];
let currentLabel: string;
let insideRef = false;
const refTokens: { [key: string]: Token[] } = {};
const env = state.env as FootnoteEnv;
if (!env.footnotes) { return; }
state.tokens = state.tokens.filter(function (tok) {
if (tok.type === 'footnote_reference_open') {
insideRef = true;
current = [];
currentLabel = tok.meta.label;
return false;
}
if (tok.type === 'footnote_reference_close') {
insideRef = false;
// prepend ':' to avoid conflict with Object.prototype members
refTokens[':' + currentLabel] = current;
return false;
}
if (insideRef) { current.push(tok); }
return !insideRef;
});
if (!env.footnotes.list) { return; }
const list = env.footnotes.list;
state.tokens.push(new state.Token('footnote_block_open', '', 1));
for (let i = 0, l = list.length; i < l; i++) {
const token_fo = new state.Token('footnote_open', '', 1);
token_fo.meta = { id: i, label: list[i].label };
state.tokens.push(token_fo);
if (list[i].tokens) {
tokens = [];
const token_po = new state.Token('paragraph_open', 'p', 1);
token_po.block = true;
tokens.push(token_po);
const token_i = new state.Token('inline', '', 0);
token_i.children = list[i].tokens || null;
token_i.content = list[i].content || '';
tokens.push(token_i);
const token_pc = new state.Token('paragraph_close', 'p', -1);
token_pc.block = true;
tokens.push(token_pc);
} else if (list[i].label) {
tokens = refTokens[`:${list[i].label}`] || null;
}
if (tokens) state.tokens = state.tokens.concat(tokens);
let lastParagraph: Token | null;
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
lastParagraph = state.tokens.pop()!;
} else {
lastParagraph = null;
}
const t = list[i].count > 0 ? list[i].count : 1;
for (let j = 0; j < t; j++) {
const token_a = new state.Token('footnote_anchor', '', 0);
token_a.meta = { id: i, subId: j, label: list[i].label };
state.tokens.push(token_a);
}
if (lastParagraph) {
state.tokens.push(lastParagraph);
}
state.tokens.push(new state.Token('footnote_close', '', -1));
}
state.tokens.push(new state.Token('footnote_block_close', '', -1));
}
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
}

View File

@@ -0,0 +1,160 @@
import MarkdownIt, { StateInline, Token } from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-ins 插件
* 用于支持插入文本语法 ++text++
*/
export default function ins_plugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x2B/* + */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
let token: Token;
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x2B/* + */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
token = state.tokens[startDelim.token];
token.type = 'ins_open';
token.tag = 'ins';
token.nesting = 1;
token.markup = '++';
token.content = '';
token = state.tokens[endDelim.token];
token.type = 'ins_close';
token.tag = 'ins';
token.nesting = -1;
token.markup = '++';
token.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '+') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
j++;
}
j--;
if (i !== j) {
token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'ins', tokenize);
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -0,0 +1,160 @@
import MarkdownIt, {StateInline, Token} from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-mark 插件
* 用于支持 ==标记文本== 语法
*/
export default function markPlugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x3D/* = */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x3D/* = */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
const token_o = state.tokens[startDelim.token];
token_o.type = 'mark_open';
token_o.tag = 'mark';
token_o.nesting = 1;
token_o.markup = '==';
token_o.content = '';
const token_c = state.tokens[endDelim.token];
token_c.type = 'mark_close';
token_c.tag = 'mark';
token_c.nesting = -1;
token_c.markup = '==';
token_c.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '=') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
j++;
}
j--;
if (i !== j) {
const token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'mark', tokenize);
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
let curr: number;
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -0,0 +1,106 @@
import mermaid from "mermaid";
import {genUid, hashCode, sleep} from "./utils";
const mermaidCache = new Map<string, HTMLElement>();
// 缓存计数器,用于清除缓存
const mermaidCacheCount = new Map<string, number>();
let count = 0;
let countTmo = setTimeout(() => undefined, 0);
const addCount = () => {
clearTimeout(countTmo);
countTmo = setTimeout(() => {
count++;
clearCache();
}, 500);
};
const clearCache = () => {
for (const key of mermaidCacheCount.keys()) {
const value = mermaidCacheCount.get(key)!;
if (value + 3 < count) {
mermaidCache.delete(key);
mermaidCacheCount.delete(key);
}
}
};
/**
* 渲染 mermaid
* @param code mermaid 代码
* @param targetId 目标 id
* @param count 计数器
*/
const renderMermaid = async (code: string, targetId: string, count: number) => {
let limit = 100;
while (limit-- > 0) {
const container = document.getElementById(targetId);
if (!container) {
await sleep(100);
continue;
}
try {
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
container.innerHTML = svg;
mermaidCache.set(targetId, container);
mermaidCacheCount.set(targetId, count);
} catch (e) {
}
break;
}
};
export interface MermaidItOptions {
theme?: "default" | "dark" | "forest" | "neutral" | "base";
}
/**
* 更新 mermaid 主题
*/
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
mermaid.initialize({
startOnLoad: false,
theme: theme
});
// 清空缓存,强制重新渲染
mermaidCache.clear();
mermaidCacheCount.clear();
};
/**
* mermaid 插件
* @param md markdown-it
* @param options 配置选项
* @constructor MermaidIt
*/
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
const theme = options?.theme || "default";
mermaid.initialize({
startOnLoad: false,
theme: theme
});
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
addCount();
const token = tokens[idx];
const info = token.info.trim();
if (info === "mermaid") {
const containerId = "mermaid-container-" + hashCode(token.content);
const container = document.createElement("div");
container.id = containerId;
if (mermaidCache.has(containerId)) {
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
mermaidCacheCount.set(containerId, count);
} else {
renderMermaid(token.content, containerId, count).then();
}
return container.outerHTML;
}
// 使用默认的渲染规则
return defaultRenderer(tokens, idx, options, env, self);
};
};

View File

@@ -0,0 +1,49 @@
import { v4 as uuidv4 } from "uuid";
/**
* uuid 生成函数
* @param split 分隔符
*/
export const genUid = (split = "") => {
return uuidv4().split("-").join(split);
};
/**
* 一个简易的sleep函数
*/
export const sleep = async (ms: number) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
/**
* 计算字符串的hash值
* 返回一个数字
* @param str
*/
export const hashCode = (str: string) => {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
};
/**
* 一个简易的阻塞函数
*/
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
const start = Date.now();
while (true) {
if (cb()) return true;
if (timeout && Date.now() - start > timeout) {
console.error("阻塞超时: " + errText);
return false;
}
await sleep(100);
}
};

View File

@@ -0,0 +1,66 @@
// Process ~subscript~
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function subscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sub_open', 'sub', 1);
token_so.markup = '~';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sub_close', 'sub', -1);
token_sc.markup = '~';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sub 插件
* 用于支持下标语法 ~text~
*/
export default function sub_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sub', subscript);
}

View File

@@ -0,0 +1,66 @@
// Process ^superscript^
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function superscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sup_open', 'sup', 1);
token_so.markup = '^';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sup_close', 'sup', -1);
token_sc.markup = '^';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sup 插件
* 用于支持上标语法 ^text^
*/
export default function sup_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sup', superscript);
}

View File

@@ -0,0 +1,329 @@
/**
* DOM Diff 算法单元测试
*/
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
import { morphNode, morphHTML, morphWithKeys } from './domDiff';
describe('DOM Diff Algorithm', () => {
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
describe('morphNode - 基础功能', () => {
test('应该更新文本节点内容', () => {
const fromNode = document.createTextNode('Hello');
const toNode = document.createTextNode('World');
container.appendChild(fromNode);
morphNode(fromNode, toNode);
expect(fromNode.nodeValue).toBe('World');
});
test('应该保持相同的文本节点不变', () => {
const fromNode = document.createTextNode('Hello');
const toNode = document.createTextNode('Hello');
container.appendChild(fromNode);
const originalNode = fromNode;
morphNode(fromNode, toNode);
expect(fromNode).toBe(originalNode);
expect(fromNode.nodeValue).toBe('Hello');
});
test('应该替换不同类型的节点', () => {
const fromNode = document.createElement('span');
fromNode.textContent = 'Hello';
const toNode = document.createElement('div');
toNode.textContent = 'World';
container.appendChild(fromNode);
morphNode(fromNode, toNode);
expect(container.firstChild?.nodeName).toBe('DIV');
expect(container.firstChild?.textContent).toBe('World');
});
});
describe('morphNode - 属性更新', () => {
test('应该添加新属性', () => {
const fromEl = document.createElement('div');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'test');
toEl.setAttribute('id', 'myid');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('test');
expect(fromEl.getAttribute('id')).toBe('myid');
});
test('应该更新已存在的属性', () => {
const fromEl = document.createElement('div');
fromEl.setAttribute('class', 'old');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'new');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('new');
});
test('应该删除不存在的属性', () => {
const fromEl = document.createElement('div');
fromEl.setAttribute('class', 'test');
fromEl.setAttribute('id', 'myid');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'test');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('test');
expect(fromEl.hasAttribute('id')).toBe(false);
});
});
describe('morphNode - 子节点更新', () => {
test('应该添加新子节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = '<li>1</li><li>2</li>';
const toEl = document.createElement('ul');
toEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.children.length).toBe(3);
expect(fromEl.children[2].textContent).toBe('3');
});
test('应该删除多余的子节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
const toEl = document.createElement('ul');
toEl.innerHTML = '<li>1</li><li>2</li>';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.children.length).toBe(2);
expect(fromEl.textContent).toBe('12');
});
test('应该更新子节点内容', () => {
const fromEl = document.createElement('div');
fromEl.innerHTML = '<p>Old</p>';
const toEl = document.createElement('div');
toEl.innerHTML = '<p>New</p>';
container.appendChild(fromEl);
const originalP = fromEl.querySelector('p');
morphNode(fromEl, toEl);
// 应该保持同一个 p 元素,只更新内容
expect(fromEl.querySelector('p')).toBe(originalP);
expect(fromEl.querySelector('p')?.textContent).toBe('New');
});
});
describe('morphHTML - HTML 字符串更新', () => {
test('应该从 HTML 字符串更新元素', () => {
const element = document.createElement('div');
element.innerHTML = '<p>Old</p>';
container.appendChild(element);
morphHTML(element, '<p>New</p>');
expect(element.innerHTML).toBe('<p>New</p>');
});
test('应该处理复杂的 HTML 结构', () => {
const element = document.createElement('div');
element.innerHTML = '<h1>Title</h1><p>Paragraph</p>';
container.appendChild(element);
morphHTML(element, '<h1>New Title</h1><p>New Paragraph</p><span>Extra</span>');
expect(element.children.length).toBe(3);
expect(element.querySelector('h1')?.textContent).toBe('New Title');
expect(element.querySelector('p')?.textContent).toBe('New Paragraph');
expect(element.querySelector('span')?.textContent).toBe('Extra');
});
});
describe('morphWithKeys - 基于 key 的智能 diff', () => {
test('应该保持相同 key 的节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A Updated</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
const originalA = fromEl.querySelector('[data-key="a"]');
morphWithKeys(fromEl, toEl);
expect(fromEl.querySelector('[data-key="a"]')).toBe(originalA);
expect(originalA?.textContent).toBe('A Updated');
});
test('应该重新排序节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="c">C</li>
<li data-key="a">A</li>
<li data-key="b">B</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
const keys = Array.from(fromEl.children).map(child => child.getAttribute('data-key'));
expect(keys).toEqual(['c', 'a', 'b']);
});
test('应该添加新的 key 节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
expect(fromEl.children.length).toBe(3);
expect(fromEl.querySelector('[data-key="c"]')?.textContent).toBe('C');
});
test('应该删除不存在的 key 节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
expect(fromEl.children.length).toBe(2);
expect(fromEl.querySelector('[data-key="b"]')).toBeNull();
});
});
describe('性能测试', () => {
test('应该高效处理大量节点', () => {
const fromEl = document.createElement('ul');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fromEl.appendChild(li);
}
const toEl = document.createElement('ul');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Updated Item ${i}`;
toEl.appendChild(li);
}
container.appendChild(fromEl);
const startTime = performance.now();
morphNode(fromEl, toEl);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(100); // 应该在 100ms 内完成
expect(fromEl.children.length).toBe(1000);
expect(fromEl.children[0].textContent).toBe('Updated Item 0');
});
});
describe('边界情况', () => {
test('应该处理空节点', () => {
const fromEl = document.createElement('div');
const toEl = document.createElement('div');
container.appendChild(fromEl);
expect(() => morphNode(fromEl, toEl)).not.toThrow();
});
test('应该处理只有文本的节点', () => {
const fromEl = document.createElement('div');
fromEl.textContent = 'Hello';
const toEl = document.createElement('div');
toEl.textContent = 'World';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.textContent).toBe('World');
});
test('应该处理嵌套的复杂结构', () => {
const fromEl = document.createElement('div');
fromEl.innerHTML = `
<div class="outer">
<div class="inner">
<span>Text</span>
</div>
</div>
`;
const toEl = document.createElement('div');
toEl.innerHTML = `
<div class="outer modified">
<div class="inner">
<span>Updated Text</span>
<strong>New</strong>
</div>
</div>
`;
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.querySelector('.outer')?.classList.contains('modified')).toBe(true);
expect(fromEl.querySelector('span')?.textContent).toBe('Updated Text');
expect(fromEl.querySelector('strong')?.textContent).toBe('New');
});
});
});

View File

@@ -0,0 +1,180 @@
/**
* 轻量级 DOM Diff 算法实现
* 基于 morphdom 思路,只更新变化的节点,保持未变化的节点不动
*/
/**
* 比较并更新两个 DOM 节点
* @param fromNode 原节点
* @param toNode 目标节点
*/
export function morphNode(fromNode: Node, toNode: Node): void {
// 节点类型不同,直接替换
if (fromNode.nodeType !== toNode.nodeType || fromNode.nodeName !== toNode.nodeName) {
fromNode.parentNode?.replaceChild(toNode.cloneNode(true), fromNode);
return;
}
// 文本节点:比较内容
if (fromNode.nodeType === Node.TEXT_NODE) {
if (fromNode.nodeValue !== toNode.nodeValue) {
fromNode.nodeValue = toNode.nodeValue;
}
return;
}
// 元素节点:更新属性和子节点
if (fromNode.nodeType === Node.ELEMENT_NODE) {
const fromEl = fromNode as Element;
const toEl = toNode as Element;
// 更新属性
morphAttributes(fromEl, toEl);
// 更新子节点
morphChildren(fromEl, toEl);
}
}
/**
* 更新元素属性
*/
function morphAttributes(fromEl: Element, toEl: Element): void {
// 移除旧属性
const fromAttrs = fromEl.attributes;
for (let i = fromAttrs.length - 1; i >= 0; i--) {
const attr = fromAttrs[i];
if (!toEl.hasAttribute(attr.name)) {
fromEl.removeAttribute(attr.name);
}
}
// 添加/更新新属性
const toAttrs = toEl.attributes;
for (let i = 0; i < toAttrs.length; i++) {
const attr = toAttrs[i];
const fromValue = fromEl.getAttribute(attr.name);
if (fromValue !== attr.value) {
fromEl.setAttribute(attr.name, attr.value);
}
}
}
/**
* 更新子节点(核心 diff 算法)
*/
function morphChildren(fromEl: Element, toEl: Element): void {
const fromChildren = Array.from(fromEl.childNodes);
const toChildren = Array.from(toEl.childNodes);
const fromLen = fromChildren.length;
const toLen = toChildren.length;
const minLen = Math.min(fromLen, toLen);
// 1. 更新公共部分
for (let i = 0; i < minLen; i++) {
morphNode(fromChildren[i], toChildren[i]);
}
// 2. 移除多余的旧节点
if (fromLen > toLen) {
for (let i = fromLen - 1; i >= toLen; i--) {
fromEl.removeChild(fromChildren[i]);
}
}
// 3. 添加新节点
if (toLen > fromLen) {
for (let i = fromLen; i < toLen; i++) {
fromEl.appendChild(toChildren[i].cloneNode(true));
}
}
}
/**
* 优化版:使用 key 进行更智能的 diff可选
* 适用于有 data-key 属性的元素
*/
export function morphWithKeys(fromEl: Element, toEl: Element): void {
const toChildren = Array.from(toEl.children) as Element[];
// 构建 from 的 key 映射
const fromKeyMap = new Map<string, Element>();
Array.from(fromEl.children).forEach((child) => {
const key = child.getAttribute('data-key');
if (key) {
fromKeyMap.set(key, child);
}
});
const processedKeys = new Set<string>();
// 按照 toChildren 的顺序处理
toChildren.forEach((toChild, toIndex) => {
const key = toChild.getAttribute('data-key');
if (!key) return;
processedKeys.add(key);
const fromChild = fromKeyMap.get(key);
if (fromChild) {
// 找到对应节点,更新内容
morphNode(fromChild, toChild);
// 确保节点在正确的位置
const currentNode = fromEl.children[toIndex];
if (currentNode !== fromChild) {
// 将 fromChild 移动到正确位置
fromEl.insertBefore(fromChild, currentNode);
}
} else {
// 新节点,插入到正确位置
const currentNode = fromEl.children[toIndex];
fromEl.insertBefore(toChild.cloneNode(true), currentNode || null);
}
});
// 删除不再存在的节点(从后往前删除,避免索引问题)
const childrenToRemove: Element[] = [];
fromKeyMap.forEach((child, key) => {
if (!processedKeys.has(key)) {
childrenToRemove.push(child);
}
});
childrenToRemove.forEach(child => {
fromEl.removeChild(child);
});
}
/**
* 高级 API直接从 HTML 字符串更新元素
*/
export function morphHTML(element: Element, htmlString: string): void {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = htmlString;
// 更新元素的子节点列表
morphChildren(element, tempContainer);
}
/**
* 批量更新(使用 DocumentFragment
*/
export function batchMorph(element: Element, htmlString: string): void {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = htmlString;
const fragment = document.createDocumentFragment();
Array.from(tempContainer.childNodes).forEach(node => {
fragment.appendChild(node);
});
// 清空原内容
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// 批量插入
element.appendChild(fragment);
}

View File

@@ -13,16 +13,20 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages'; import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode'; import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
import {createDebounce} from '@/common/utils/debounce'; import {createDebounce} from '@/common/utils/debounce';
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
import {usePanelStore} from '@/stores/panelStore';
const editorStore = readonly(useEditorStore()); const editorStore = readonly(useEditorStore());
const configStore = readonly(useConfigStore()); const configStore = readonly(useConfigStore());
const updateStore = readonly(useUpdateStore()); const updateStore = readonly(useUpdateStore());
const windowStore = readonly(useWindowStore()); const windowStore = readonly(useWindowStore());
const systemStore = readonly(useSystemStore()); const systemStore = readonly(useSystemStore());
const panelStore = readonly(usePanelStore());
const {t} = useI18n(); const {t} = useI18n();
const router = useRouter(); const router = useRouter();
const canFormatCurrentBlock = ref(false); const canFormatCurrentBlock = ref(false);
const canPreviewMarkdown = ref(false);
const isLoaded = shallowRef(false); const isLoaded = shallowRef(false);
const { documentStats } = toRefs(editorStore); const { documentStats } = toRefs(editorStore);
@@ -33,6 +37,11 @@ const isCurrentWindowOnTop = computed(() => {
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop; return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
}); });
// 当前文档的预览是否打开
const isCurrentBlockPreviewing = computed(() => {
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
});
// 切换窗口置顶状态 // 切换窗口置顶状态
const toggleAlwaysOnTop = async () => { const toggleAlwaysOnTop = async () => {
const currentlyOnTop = isCurrentWindowOnTop.value; const currentlyOnTop = isCurrentWindowOnTop.value;
@@ -60,11 +69,22 @@ const formatCurrentBlock = () => {
formatBlockContent(editorStore.editorView); formatBlockContent(editorStore.editorView);
}; };
// 格式化按钮状态更新 - 使用更高效的检查逻辑 // 切换 Markdown 预览
const updateFormatButtonState = () => { const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
const view = editorStore.editorView; if (!canPreviewMarkdown.value || !editorStore.editorView) return;
toggleMarkdownPreview(editorStore.editorView as any);
}, { delay: 200 });
const togglePreview = () => {
debouncedTogglePreview();
};
// 统一更新按钮状态
const updateButtonStates = () => {
const view: any = editorStore.editorView;
if (!view) { if (!view) {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return; return;
} }
@@ -75,20 +95,25 @@ const updateFormatButtonState = () => {
// 提前返回,减少不必要的计算 // 提前返回,减少不必要的计算
if (!activeBlock) { if (!activeBlock) {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return; return;
} }
const language = getLanguage(activeBlock.language.name as any); const languageName = activeBlock.language.name;
const language = getLanguage(languageName as any);
canFormatCurrentBlock.value = Boolean(language?.prettier); canFormatCurrentBlock.value = Boolean(language?.prettier);
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
} catch (error) { } catch (error) {
console.warn('Error checking format capability:', error); console.warn('Error checking block capabilities:', error);
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
} }
}; };
// 创建带1s防抖的更新函数 // 创建带1s防抖的更新函数
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce( const { debouncedFn: debouncedUpdateButtonStates, cancel: cancelDebounce } = createDebounce(
updateFormatButtonState, updateButtonStates,
{ delay: 1000 } { delay: 1000 }
); );
@@ -102,9 +127,9 @@ const setupEditorListeners = (view: any) => {
// 使用对象缓存事件处理器,避免重复创建 // 使用对象缓存事件处理器,避免重复创建
const eventHandlers = { const eventHandlers = {
click: updateFormatButtonState, click: updateButtonStates,
keyup: debouncedUpdateFormat, keyup: debouncedUpdateButtonStates,
focus: updateFormatButtonState focus: updateButtonStates
} as const; } as const;
const events = Object.entries(eventHandlers).map(([type, handler]) => ({ const events = Object.entries(eventHandlers).map(([type, handler]) => ({
@@ -131,11 +156,12 @@ watch(
if (newView) { if (newView) {
// 初始更新状态 // 初始更新状态
updateFormatButtonState(); updateButtonStates();
// 设置新监听器 // 设置新监听器
cleanupListeners = setupEditorListeners(newView); cleanupListeners = setupEditorListeners(newView);
} else { } else {
canFormatCurrentBlock.value = false; canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
} }
}); });
}, },
@@ -145,8 +171,8 @@ watch(
// 组件生命周期 // 组件生命周期
onMounted(async () => { onMounted(async () => {
isLoaded.value = true; isLoaded.value = true;
// 首次更新格式化状态 // 首次更新按钮状态
updateFormatButtonState(); updateButtonStates();
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value); await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
}); });
@@ -229,6 +255,21 @@ const statsData = computed(() => ({
<!-- 块语言选择器 --> <!-- 块语言选择器 -->
<BlockLanguageSelector/> <BlockLanguageSelector/>
<!-- Markdown预览按钮 -->
<div
v-if="canPreviewMarkdown"
class="preview-button"
:class="{ 'active': isCurrentBlockPreviewing }"
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
@click="togglePreview"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</div>
<!-- 格式化按钮 - 支持点击操作 --> <!-- 格式化按钮 - 支持点击操作 -->
<div <div
v-if="canFormatCurrentBlock" v-if="canFormatCurrentBlock"
@@ -512,6 +553,42 @@ const statsData = computed(() => ({
} }
} }
.preview-button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
&.active {
background-color: rgba(100, 149, 237, 0.2);
svg {
stroke: #6495ed;
}
}
svg {
width: 14px;
height: 14px;
stroke: var(--text-muted);
transition: stroke 0.2s ease;
}
&:hover svg {
stroke: var(--text-secondary);
}
}
.settings-btn { .settings-btn {
background: none; background: none;
border: none; border: none;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { EditorView } from '@codemirror/view';
import { useDocumentStore } from './documentStore';
/**
* 单个文档的预览状态
*/
interface DocumentPreviewState {
isOpen: boolean;
isClosing: boolean;
blockFrom: number;
blockTo: number;
}
/**
* 面板状态管理 Store
* 管理编辑器中各种面板的显示状态按文档ID区分
*/
export const usePanelStore = defineStore('panel', () => {
// 当前编辑器视图引用
const editorView = ref<EditorView | null>(null);
// 每个文档的预览状态 Map<documentId, PreviewState>
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
/**
* 获取当前文档的预览状态
*/
const markdownPreview = computed(() => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) {
return {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
}
return documentPreviews.value.get(currentDocId) || {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
});
/**
* 设置编辑器视图
*/
const setEditorView = (view: EditorView | null) => {
editorView.value = view;
};
/**
* 打开 Markdown 预览面板
*/
const openMarkdownPreview = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: true,
isClosing: false,
blockFrom: from,
blockTo: to
});
};
/**
* 开始关闭 Markdown 预览面板
*/
const startClosingMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
isClosing: true
});
}
};
/**
* 关闭 Markdown 预览面板
*/
const closeMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
});
};
/**
* 更新预览块的范围(用于实时预览)
*/
const updatePreviewRange = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
blockFrom: from,
blockTo: to
});
}
};
/**
* 检查指定块是否正在预览
*/
const isBlockPreviewing = (from: number, to: number): boolean => {
const preview = markdownPreview.value;
return preview.isOpen &&
preview.blockFrom === from &&
preview.blockTo === to;
};
/**
* 重置所有面板状态
*/
const reset = () => {
documentPreviews.value.clear();
editorView.value = null;
};
/**
* 清理指定文档的预览状态(文档关闭时调用)
*/
const clearDocumentPreview = (documentId: number) => {
documentPreviews.value.delete(documentId);
};
return {
// 状态
editorView,
markdownPreview,
// 方法
setEditorView,
openMarkdownPreview,
startClosingMarkdownPreview,
closeMarkdownPreview,
updatePreviewRange,
isBlockPreviewing,
reset,
clearDocumentPreview
};
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, // 允许换行
}); });
// 计算属性 // 计算属性

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"

View File

@@ -1 +1 @@
VERSION=1.5.2 VERSION=1.5.3