Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28072c7f90 | |||
| 991a89147e | |||
| a08c0d8448 | |||
| 59db8dd177 | |||
| 29693f1baf |
@@ -93,13 +93,32 @@ export default defineConfig({
|
||||
items: [
|
||||
{text: '简介', link: '/zh/guide/introduction'},
|
||||
{text: '安装', link: '/zh/guide/installation'},
|
||||
{text: '快速开始', link: '/zh/guide/getting-started'}
|
||||
{text: '快速开始', link: '/zh/guide/getting-started'},
|
||||
{text: '界面总览', link: '/zh/guide/ui-overview'},
|
||||
{text: '块语法与结构', link: '/zh/guide/block-syntax'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能特性',
|
||||
text: '编辑与效率',
|
||||
items: [
|
||||
{text: '功能概览', link: '/zh/guide/features'}
|
||||
{text: '键盘快捷键', link: '/zh/guide/keyboard-shortcuts'},
|
||||
{text: '多窗口与标签页', link: '/zh/guide/multiwindow-tabs'},
|
||||
{text: '扩展与插件', link: '/zh/guide/extensions'},
|
||||
{text: 'HTTP 客户端', link: '/zh/guide/http-client'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '个性化与数据',
|
||||
items: [
|
||||
{text: '设置与配置', link: '/zh/guide/settings'},
|
||||
{text: '主题与外观', link: '/zh/guide/themes'},
|
||||
{text: '备份与更新', link: '/zh/guide/backup-update'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '问题处理',
|
||||
items: [
|
||||
{text: '常见问题与故障排查', link: '/zh/guide/troubleshooting'}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
BIN
frontend/docs/src/public/img/placeholder-backup.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/docs/src/public/img/placeholder-block-flow.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/docs/src/public/img/placeholder-extensions.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/docs/src/public/img/placeholder-http.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/docs/src/public/img/placeholder-main-ui.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/docs/src/public/img/placeholder-multiwindow.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/docs/src/public/img/placeholder-settings.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/docs/src/public/img/placeholder-shortcuts.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/docs/src/public/img/placeholder-themes.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/docs/src/public/img/placeholder-troubleshooting.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
60
frontend/docs/src/zh/guide/backup-update.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 备份与更新
|
||||
|
||||

|
||||
> 替换为备份设置、推送状态、更新提示的截图。
|
||||
|
||||
## Git 备份
|
||||
`BackupService` 将 `dataPath` 转化为 Git 仓库,并提供自动/手动推送。
|
||||
|
||||
### 初始化
|
||||
1. 在设置 > 备份中开启「启用备份」。
|
||||
2. 填写远程仓库 URL(HTTPS 或 SSH)。
|
||||
3. 选择认证方式:
|
||||
- **Token**:适用于 GitHub/Gitea https 仓库。
|
||||
- **SSH Key**:指定私钥路径和 passphrase。
|
||||
- **用户名/密码**:适合自建 HTTP 仓库。
|
||||
4. 点击“测试连接”(按钮在计划中,可先在终端测试)。
|
||||
|
||||
### 自动备份
|
||||
- 勾选 “自动备份” + 设置 `BackupInterval`(分钟)。
|
||||
- 服务会创建 ticker 定时 `git add -> commit -> push`。
|
||||
- Commit 消息形如 `Auto backup <timestamp>`,包含 `voidraft.db`, `extensions.json`, `config.json`, `voidraft_data.bin`。
|
||||
|
||||
### 手动推送
|
||||
- 打开工具栏消息中心或设置页点击“立即推送”。
|
||||
- `backupStore.pushToRemote` 会显示状态气泡(成功/失败提示 3~5 秒)。
|
||||
|
||||
### 常见问题
|
||||
| 提示 | 解决 |
|
||||
| --- | --- |
|
||||
| `repository not found` | 检查 Repo URL 与权限,必要时创建空仓库 |
|
||||
| `authentication required` | 选择正确认证方式,确认 token scope(需 repo 权限) |
|
||||
| `auto backup stopped` | 查看日志,可能是网络不通或凭据失效;修改配置后服务会自动重启 |
|
||||
|
||||
## 自动更新
|
||||
`SelfUpdateService` 负责检测、下载、应用新版。
|
||||
|
||||
### 检查更新
|
||||
- 启动时若勾选 “自动更新” 会自动检查。
|
||||
- 也可在设置 > 更新点击 “检查更新” 或在工具栏更新图标处触发。
|
||||
- 服务优先访问 `primarySource`,失败时回退 `backupSource`。
|
||||
|
||||
### 下载与应用
|
||||
1. 检测到更新后,界面提示版本号与变更信息(从 Release Notes 获取)。
|
||||
2. 点击 “下载并安装” 后,后台执行下载,完成后提示“准备重启”。
|
||||
3. 选择 “立即重启” 将调用 `RestartApplication`,自动重新打开上次的文档。
|
||||
4. 若启用了 “更新前备份”,在下载前会触发一次 Git push。
|
||||
|
||||
### 失败处理
|
||||
| 场景 | 建议 |
|
||||
| --- | --- |
|
||||
| 下载失败 | 检查网络/代理,切换至备用源 |
|
||||
| 校验失败 | 删除 `%LOCALAPPDATA%/voidraft/update-cache` 再重试 |
|
||||
| 应用后无法启动 | 从 Git 备份回滚数据,下载旧版本安装包覆盖 |
|
||||
|
||||
## 发布渠道
|
||||
- 官方 GitHub Releases:`https://github.com/landaiqing/voidraft/releases`
|
||||
- 自建 Gitea:`https://git.landaiqing.cn/landaiqing/voidraft`
|
||||
- 可在设置中替换 Owner/Repo/BaseURL,以指向企业私有镜像。
|
||||
|
||||
> 建议将备份仓库设为私有,并在更新前后验证数据完整性。若要接入 S3/OSS 等备份方式,可关注 roadmap 或自行扩展。
|
||||
78
frontend/docs/src/zh/guide/block-syntax.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 块语法与结构
|
||||
|
||||

|
||||
> 替换为展示分隔符(`∞∞∞language`)、块内容、语言标签的截图。
|
||||
|
||||
## 结构定义
|
||||
每个块都由 **分隔符 + 内容** 组成:
|
||||
|
||||
```
|
||||
∞∞∞language[-a]\n
|
||||
<内容>
|
||||
```
|
||||
|
||||
- `language`:`lang-parser/languages.ts` 中的 token,例如 `text`、`javascript`、`python`、`md`、`http`、`math`、`sql`。
|
||||
- `-a`:可选自动检测后缀,表示忽略显式语言,由 `lang-detect/autodetect.ts` 根据内容猜测。
|
||||
- 内容允许空行;块之间无需额外空格。
|
||||
|
||||
`parser.ts` 会将块解析为:
|
||||
```ts
|
||||
{
|
||||
language: { name: "javascript", auto: false },
|
||||
delimiter: { from, to },
|
||||
content: { from, to },
|
||||
range: { from, to }
|
||||
}
|
||||
```
|
||||
这些字段供格式化、HTTP 运行、块操作等扩展示例使用。
|
||||
|
||||
## 快捷命令
|
||||
| 命令 | 默认快捷键 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| blockAddAfterCurrent | `Ctrl+Enter` | 插入新块(下方) |
|
||||
| blockAddBeforeCurrent | `Ctrl+Shift+Enter` | 插入新块(上方) |
|
||||
| blockGotoPrevious/Next | `Alt+↑ / Alt+↓` | 在块之间跳转 |
|
||||
| blockSelectAll | `Ctrl+Shift+A` | 选中当前块(含分隔符) |
|
||||
| blockDelete | `Alt+Delete` | 删除整个块 |
|
||||
| blockMoveUp/Down | `Ctrl+Shift+↑ / Ctrl+Shift+↓` | 重排块顺序 |
|
||||
| blockFormat | `Ctrl+Shift+F` | 针对当前块执行 Prettier |
|
||||
|
||||
## 语言与能力矩阵
|
||||
| 语言 | 适配特性 |
|
||||
| --- | --- |
|
||||
| `text`/`note` | 基础文本,没有特殊扩展 |
|
||||
| `md` | Markdown 预览、checkbox、高亮 |
|
||||
| `javascript`/`typescript`/`json` | Prettier 格式化、彩虹括号、折叠、颜色选择 |
|
||||
| `go`/`rust`/`python`/`java` | 高亮、折叠、自动缩进、语法跳转 |
|
||||
| `http` | HTTP DSL、变量、响应插入(详见 [HTTP 客户端](/zh/guide/http-client))|
|
||||
| `math` | `mathBlock` 运行器,支持 `prev` 引用上一次结果 |
|
||||
| `sql`/`yaml`/`toml` | 语法高亮、格式化(由 Prettier/插件支持) |
|
||||
|
||||
> 若需要不在列表中的语言,可先使用 `text` 块输入,再在工具栏搜索语言名;也可以在 `lang-parser/languages.ts` 中添加条目。
|
||||
|
||||
## 自动检测策略
|
||||
- 当分隔符以 `∞∞∞text-a` 写成时,`AUTO_DETECT_SUFFIX` 生效,`lang-detect` 会基于内容统计 + Levenshtein 距离预测语言。
|
||||
- 自动结果会写入块状态,但不会覆盖分隔符原文,因此可通过工具栏明确指定。
|
||||
|
||||
## 特殊块
|
||||
### HTTP 块
|
||||
- 语法位于 `extensions/httpclient/language`,支持 `@var/@json/@form/@multipart` 等指令。
|
||||
- 运行器会在块尾生成 `### Response`,包含状态码、耗时、headers、body。
|
||||
|
||||
### 数学块
|
||||
- 语言设为 `math`,逐行计算。
|
||||
- `prev` 变量表示上一行结果,可完成链式运算。
|
||||
- 结果挂件可点击复制,或显示格式化/定点值。
|
||||
|
||||
### Markdown 块
|
||||
- 工具栏中的 Preview 按钮会调用 `toggleMarkdownPreview`,右侧打开面板。
|
||||
- 预览状态按文档隔离,不会影响其他文档。
|
||||
|
||||
## 分隔符校验
|
||||
- `parser.ts` 暴露 `isValidDelimiter` 方法,格式错误时分隔符会以红色底纹标记。
|
||||
- 复制/剪切操作会自动扩选到整个块,确保分隔符完整。
|
||||
|
||||
## 维护建议
|
||||
- 保持每个逻辑主题占用一个块,并用 `md` 块写标题。
|
||||
- 大量多语言内容时,可用 `text` + 自动检测,待语言确定后再改分隔符。
|
||||
- 若文档出现“无法解析块”提示,可运行 `格式化文档` 或在命令面板触发“重建语法树”。
|
||||
42
frontend/docs/src/zh/guide/extensions.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 扩展与插件
|
||||
|
||||

|
||||
> 替换为展示扩展设置面板或功能合集(小地图、搜索、翻译)的截图。
|
||||
|
||||
voidraft 的扩展系统由 `internal/models/extensions.go` + 前端 `ExtensionManager` 驱动。扩展配置存储在 `%USERPROFILE%/.voidraft/data/extensions.json`,可在设置页面勾选启用或调整参数。
|
||||
|
||||
## 核心扩展
|
||||
| 扩展 ID | 功能 | 关键文件 |
|
||||
| --- | --- | --- |
|
||||
| `editor` | 基础 CodeMirror 行为、光标保护、滚轮缩放 | `frontend/src/views/editor/basic/*` |
|
||||
| `codeblock` | 块解析、拖拽、复制、格式化、数学、HTTP DSL | `extensions/codeblock` |
|
||||
| `vscodeSearch` | VSCode 风格搜索替换面板 | `extensions/vscodeSearch` |
|
||||
| `markdownPreview` | Markdown 实时预览 | `extensions/markdownPreview` |
|
||||
|
||||
## 编辑增强
|
||||
- **Rainbow Brackets (`rainbowBrackets`)**:彩虹色括号匹配。
|
||||
- **Fold (`fold`)**:代码折叠/展开,支持 `Ctrl+Alt+[`/`]`。
|
||||
- **Hyperlink (`hyperlink`)**:识别 URL/邮箱,`Ctrl+Click` 打开。
|
||||
- **Color Selector (`colorSelector`)**:悬浮配色器,支持 HEX/RGB/HSL。
|
||||
- **Checkbox (`checkbox`)**:Markdown 任务列表交互式勾选。
|
||||
- **Text Highlight (`textHighlight`)**:`Mod+Shift+H` 快速标记重点,可自定义颜色/透明度。
|
||||
|
||||
## 工具扩展
|
||||
- **Translator (`translator`)**:选区翻译;配置项包括默认翻译器、最短/最长字符数。后端集成 Bing/Google/Youdao/DeepL/TartuNLP。
|
||||
- **Minimap (`minimap`)**:右侧迷你地图,支持悬浮/常驻、显示字符/块,突出当前选区。
|
||||
- **Search (`search`)**:补充 VSCode 风格搜索,暴露命令给快捷键系统。
|
||||
- **HTTP Client (`httpclient`)**:DSL + 运行器,详见 [HTTP 客户端](/zh/guide/http-client)。
|
||||
|
||||
## 未来扩展(欢迎参与)
|
||||
- Vim / Emacs 键位层(正在计划)。
|
||||
- 自定义命令面板(Command Palette)。
|
||||
- 代码片段/模板库扩展。
|
||||
- AI 助手(文生代码/注释)。
|
||||
|
||||
## 开发者指南概述
|
||||
1. **注册扩展**:在 `extensionManager.registerFactory` 中添加自定义扩展工厂。
|
||||
2. **配置项**:在 `extensions.json` 中声明默认配置,并在设置面板暴露 UI(Vue 组件)。
|
||||
3. **热更新**:调用 `manager.updateExtensionImmediate(id, enabled, config)` 实时切换,无需刷新窗口。
|
||||
4. **后端交互**:通过 `ExtensionService.UpdateExtensionState` 将配置写入 SQLite。
|
||||
|
||||
> 如果需要编写自用扩展,可 fork 项目在 `frontend/src/views/editor/extensions` 中添加文件,再通过 PR 贡献给社区。
|
||||
@@ -1,163 +1,86 @@
|
||||
# 功能特性
|
||||
|
||||
探索 voidraft 的强大功能,让它成为开发者的优秀工具。
|
||||

|
||||
> 替换为展示彩虹括号、小地图、搜索工具条等扩展组合的截图。
|
||||
|
||||
## 块状编辑
|
||||
## 1. 编辑体验
|
||||
### 块状编辑全流程
|
||||
- `∞∞∞language[-a]` 语法由 `codeblock/lang-parser` 解析,支持自动检测、分隔符校验、块范围缓存。
|
||||
- `blockState` 暴露 API(`getActiveBlock/getFirstBlock/getLastBlock`),供格式化、重排、复制、HTTP 执行等插件共享。
|
||||
- `mathBlock` 可在块尾展示计算结果,点击可复制;`CURRENCIES_LOADED` 注解在汇率更新时刷新缓存。
|
||||
|
||||
voidraft 的核心功能是其块状编辑系统:
|
||||
### 语言支持
|
||||
- 内建 30+ 语言模版(`lang-parser/languages.ts`),覆盖 JS/TS/HTML/CSS/Go/Rust/Python/SQL/YAML/HTTP/Markdown/Plain/Text/Math。
|
||||
- 语言切换下拉实时更新分隔符;支持自定义别名(例如 `∞∞∞shell`)。
|
||||
|
||||
- 每个块可以有不同的编程语言
|
||||
- 块之间由分隔符分隔(`∞∞∞语言`)
|
||||
- 快速在块之间导航
|
||||
- 独立格式化每个块
|
||||
### 语法高亮与主题
|
||||
- `rainbowBracket`、`fold`、`hyperlink`、`colorSelector` 等扩展组合提供接近 VSCode 的体验。
|
||||
- `ThemeService` 预置 12+ 暗/亮主题,可在设置中克隆、修改 JSON 色板,并立即生效。
|
||||
|
||||
## 语法高亮
|
||||
### 文本统计与滚轮缩放
|
||||
- `statsExtension` 实时统计行数、字符数和选区,展示在状态栏。
|
||||
- `wheelZoomExtension` 让 `Ctrl + 鼠标滚轮` 调整字体大小,同时同步 `configStore`。
|
||||
|
||||
支持 30+ 种语言的专业语法高亮:
|
||||
## 2. 高效工具箱
|
||||
### VSCode 式搜索替换
|
||||
- `extensions/vscodeSearch` 提供悬浮面板,支持大小写/整词/正则、向上/向下跳转、批量替换。
|
||||
- 对应快捷键:`Ctrl+F`、`Ctrl+H`、`Alt+Enter`(替换全部)。
|
||||
|
||||
- 自动语言检测
|
||||
- 可自定义配色方案
|
||||
- 支持嵌套语言
|
||||
- 代码折叠支持
|
||||
### Markdown 预览
|
||||
- `panelStore` 为每个文档维护预览状态,保证不同文档互不影响。
|
||||
- 选中 Markdown 块后点击工具栏预览按钮即可在右侧展开实时渲染面板。
|
||||
|
||||
## HTTP 客户端
|
||||
### HTTP 客户端
|
||||
- Request DSL + 运行器在 [专章](/zh/guide/http-client) 详细说明。
|
||||
- 支持变量、响应插入、多种请求体、定制 header、复制 cURL。
|
||||
|
||||
用于 API 测试的内置 HTTP 客户端:
|
||||
### 翻译助手
|
||||
- `translator` 扩展监听选区,符合长度阈值后显示按钮;由 `TranslationService` 调用 Bing/Google/Youdao/DeepL/TartuNLP。
|
||||
- 支持语种缓存、复制译文、切换译文方向。
|
||||
|
||||
### 请求类型
|
||||
- GET、POST、PUT、DELETE、PATCH
|
||||
- 自定义请求头
|
||||
- 多种请求体格式:JSON、FormData、URL 编码、XML、文本
|
||||
### 颜色与高亮
|
||||
- `colorSelector` 识别 `#fff/rgba/hsl`、打开取色器;`textHighlight` 用 `Mod+Shift+H` 标记重要行。
|
||||
|
||||
### 请求变量
|
||||
定义和重用变量:
|
||||
## 3. 复杂布局能力
|
||||
### 多窗口
|
||||
- `WindowService` 允许为任意文档创建独立 WebView,URL 自动携带 `?documentId=`。
|
||||
- `WindowSnapService` 根据主窗口位置吸附子窗口(上下左右+四角),并缓存尺寸、位置。
|
||||
- 支持全局热键(默认 `Alt+X`)一键显示或隐藏所有窗口。
|
||||
|
||||
```http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "your-api-token"
|
||||
}
|
||||
### 标签页
|
||||
- `tabStore` 通过 `enableTabs` 控制;支持拖拽排序、关闭其他/左侧/右侧标签。
|
||||
- 与多窗口互斥:当文档被新窗口接管后会从标签栏移除,避免重复。
|
||||
|
||||
GET "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
}
|
||||
```
|
||||
### 系统托盘与置顶
|
||||
- `TrayService` 控制关闭时隐藏到托盘或直接退出。
|
||||
- 工具栏提供图钉按钮,可即时切换 `AlwaysOnTop`(支持临时置顶和永久置顶)。
|
||||
|
||||
### 响应处理
|
||||
- 查看格式化的 JSON 响应
|
||||
- 查看响应时间和大小
|
||||
- 检查响应头
|
||||
- 保存响应以供日后使用
|
||||
## 4. 数据守护
|
||||
### SQLite + 自动迁移
|
||||
- `DatabaseService` 启动时执行 PRAGMA + 表结构校验,缺失字段自动 `ALTER TABLE`。
|
||||
- 默认生成 `documents/extensions/key_bindings/themes` 等表,支持软删除与锁定。
|
||||
|
||||
## 代码格式化
|
||||
### Git 备份
|
||||
- `BackupService` 将 `dataPath` 初始化为 Git 仓库,支持 Token/SSHKey/用户名密码三种方式。
|
||||
- 自动任务按分钟运行(`BackupInterval`),包括 add/commit/push;也可从 UI 触发一次性 push。
|
||||
|
||||
集成 Prettier 支持:
|
||||
### 配置快照
|
||||
- 所有设置存于 `config.json`,包含 `metadata.version/lastUpdated`,方便手工回滚。
|
||||
- `ConfigService.Watch` 为窗口吸附、托盘、热键等服务提供实时响应。
|
||||
|
||||
- 保存时格式化(可选)
|
||||
- 格式化选区或整个块
|
||||
- 支持 JavaScript、TypeScript、CSS、HTML、JSON 等
|
||||
- 可自定义格式化规则
|
||||
### 自动更新
|
||||
- `SelfUpdateService` 先检查主源(Gitea),失败再回退到 GitHub;下载完成后可一键「重启并更新」。
|
||||
- 更新前可选自动触发 Git 备份(`backupBeforeUpdate`)。
|
||||
|
||||
## 编辑器扩展
|
||||
## 5. 自动化与集成
|
||||
- **启动时动作**:可开启开机自启(`StartupService`)、默认最小化至托盘。
|
||||
- **HTTP 运行挂钩**:`response-inserter` 可在响应块尾部插入 `// @timestamp` 等自定义标记。
|
||||
- **Math/汇率**:`mathBlock` 可引用上一次结果 (`prev`),配合 `CURRENCIES_LOADED` 注解支撑货币换算。
|
||||
- **系统信息**:`SystemService` 暴露内存、GC、Goroutine 数量,可在调试面板查看。
|
||||
|
||||
### VSCode 风格搜索
|
||||
- 查找和替换,支持正则表达式
|
||||
- 区分大小写和全字匹配选项
|
||||
- 跨所有块搜索
|
||||
|
||||
### 小地图
|
||||
- 文档的鸟瞰图
|
||||
- 快速导航
|
||||
- 可自定义大小和位置
|
||||
|
||||
### 彩虹括号
|
||||
- 彩色括号配对
|
||||
- 更容易匹配括号
|
||||
- 可自定义颜色
|
||||
|
||||
### 颜色选择器
|
||||
- 可视化颜色选择
|
||||
- 支持 hex、RGB、HSL
|
||||
- 实时预览
|
||||
|
||||
### 翻译工具
|
||||
- 翻译选定的文本
|
||||
- 支持多种语言
|
||||
- 快速键盘访问
|
||||
|
||||
### 文本高亮
|
||||
- 高亮重要文本
|
||||
- 多种高亮颜色
|
||||
- 持久化高亮
|
||||
|
||||
## 多窗口支持
|
||||
|
||||
高效使用多个窗口:
|
||||
|
||||
- 每个窗口都是独立的
|
||||
- 独立的文档
|
||||
- 同步的设置
|
||||
- 窗口状态持久化
|
||||
|
||||
## 主题自定义
|
||||
|
||||
完全控制编辑器外观:
|
||||
|
||||
### 内置主题
|
||||
- 深色模式
|
||||
- 浅色模式
|
||||
- 根据系统自动切换
|
||||
|
||||
### 自定义主题
|
||||
- 创建你自己的主题
|
||||
- 自定义每种颜色
|
||||
- 保存和分享主题
|
||||
- 导入社区主题
|
||||
|
||||
## 自动更新系统
|
||||
|
||||
通过自动更新保持最新:
|
||||
|
||||
- 后台更新检查
|
||||
- 新版本通知
|
||||
- 一键更新
|
||||
- 更新历史
|
||||
- 支持多个更新源(GitHub、Gitea)
|
||||
|
||||
## 数据备份
|
||||
|
||||
使用基于 Git 的备份保护你的数据:
|
||||
|
||||
- 自动备份
|
||||
- 手动触发备份
|
||||
- 支持 GitHub 和 Gitea
|
||||
- 多种认证方式(SSH、Token、密码)
|
||||
- 可配置备份间隔
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
广泛的键盘支持:
|
||||
|
||||
- 可自定义快捷键
|
||||
- Vim/Emacs 按键绑定(计划中)
|
||||
- 快速命令面板
|
||||
- 上下文感知快捷键
|
||||
|
||||
## 性能
|
||||
|
||||
专为速度而构建:
|
||||
|
||||
- 快速启动时间
|
||||
- 流畅滚动
|
||||
- 高效内存使用
|
||||
- 支持大文件
|
||||
|
||||
## 隐私与安全
|
||||
|
||||
你的数据是安全的:
|
||||
|
||||
- 本地优先存储
|
||||
- 可选云备份
|
||||
- 无遥测或跟踪
|
||||
- 开源代码库
|
||||
## 6. 可配置的快捷键
|
||||
- 详见 [键盘快捷键](/zh/guide/keyboard-shortcuts)。默认绑定定义在 `internal/models/key_bindings.go`,前端设置页可逐项修改、禁用。
|
||||
|
||||
## 7. 文档 & 帮助
|
||||
- 文档站以 VitePress 构建(`frontend/docs`),内置中英双语导航,可一键部署到 GitHub Pages。
|
||||
- `README` 与本文档同步介绍核心功能;建议将常用工作流截图补充到每个「图片占位」中。
|
||||
|
||||
@@ -1,107 +1,82 @@
|
||||
# 快速开始
|
||||
|
||||
学习使用 voidraft 的基础知识并创建你的第一个文档。
|
||||

|
||||
> 替换为展示块分隔符、语言标签、内容的截图,帮助读者直观理解 `∞∞∞language` 结构。
|
||||
|
||||
## 编辑器界面
|
||||
## 5 分钟上手流程
|
||||
1. **启动应用**:等待加载动画结束,默认会打开 `default` 文档。
|
||||
2. **新建文档**:点击工具栏的文档列表按钮,输入标题后创建;也可在设置里开启标签页,以便同时挂载多个文档。
|
||||
3. **创建首个块**:
|
||||
- 在空白处输入 `∞∞∞javascript` 并回车。
|
||||
- 输入代码或文本,`CodeBlockExtension` 会自动匹配语法高亮。
|
||||
4. **格式化与预览**:
|
||||
- 选中块后点击工具栏的「Format」或使用 `Ctrl+Shift+F`。
|
||||
- 如果块语言是 `md`,可点击「Preview」按钮开启 Markdown 侧栏。
|
||||
5. **运行 HTTP 请求**:创建 `∞∞∞http` 块,填写请求,再点击行号旁的 Run 按钮即可获取响应。
|
||||
6. **打开第二窗口**:在文档列表中右键文档 -> “在新窗口中打开”。`WindowService` 会创建无边框窗口并自动贴靠主窗口。
|
||||
|
||||
当你打开 voidraft 时,你将看到:
|
||||
## 界面导览
|
||||
- **主编辑区**:CodeMirror 视图,支持鼠标滚轮 + `Ctrl` 缩放(`wheelZoomExtension`)。
|
||||
- **右侧小地图**:`extensions/minimap` 提供鸟瞰和选区同步。
|
||||
- **底部状态**:`editorStore.documentStats` 实时展示行数/字符/选区。
|
||||
- **工具栏**(`Toolbar.vue`):包含文档切换、块语言下拉、窗口置顶、格式化、Markdown 预览、更新提示、进入设置等。
|
||||
|
||||
- **主编辑器**:编写和编辑的中心区域
|
||||
- **工具栏**:快速访问常用操作
|
||||
- **状态栏**:显示当前块的语言和其他信息
|
||||
## 块的基本操作
|
||||
| 操作 | 快捷键 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新建块(下方) | `Ctrl+Enter` | 在当前块后插入 `∞∞∞text-a` 分隔符 |
|
||||
| 新建块(上方) | `Ctrl+Shift+Enter` | 在当前块前插入 |
|
||||
| 跳到上/下一个块 | `Alt+Up / Alt+Down` | 通过 `blockGotoPrevious/Next` 命令 |
|
||||
| 删除块 | `Alt+Delete` | 仅删除块内容,不影响其他块 |
|
||||
| 块排序 | `Ctrl+Shift+↑/↓` | `moveLines` 结合块范围移动 |
|
||||
| 复制块 | `Ctrl+C`(光标在块上即可) | `copyPaste.ts` 自动扩展选区至整个块 |
|
||||
|
||||
## 创建代码块
|
||||
## 自动语言与格式化
|
||||
- 当分隔符写成 `∞∞∞text-a` 时,会触发语言自动检测(`lang-detect/autodetect.ts`),常用于粘贴未知代码。
|
||||
- `formatCode.ts` 调用 Prettier,自动选择 parser;若语言不支持,会提示不可格式化。
|
||||
- 块语言可在工具栏下拉中修改,列表由 `lang-parser/languages.ts` 提供。
|
||||
|
||||
voidraft 使用基于块的编辑系统。每个块可以有不同的语言:
|
||||
## Markdown / 待办
|
||||
1. 使用 `∞∞∞md` 分隔符。
|
||||
2. 在块内写 Markdown,点击工具栏预览按钮。
|
||||
3. 勾选/取消 Checkbox(`extensions/checkbox`)即可同步更新文本。
|
||||
|
||||
1. 按 `Ctrl+Enter` 创建新块
|
||||
2. 输入 `∞∞∞` 后跟语言名称(例如 `∞∞∞javascript`)
|
||||
3. 在该块中开始编码
|
||||
|
||||
### 支持的语言
|
||||
|
||||
voidraft 支持 30+ 种编程语言,包括:
|
||||
- JavaScript、TypeScript
|
||||
- Python、Go、Rust
|
||||
- HTML、CSS、Sass
|
||||
- SQL、YAML、JSON
|
||||
- 以及更多...
|
||||
|
||||
## 基本操作
|
||||
|
||||
### 导航
|
||||
|
||||
- `Ctrl+Up/Down`:在块之间移动
|
||||
- `Ctrl+Home/End`:跳转到第一个/最后一个块
|
||||
- `Ctrl+F`:在文档中搜索
|
||||
|
||||
### 编辑
|
||||
|
||||
- `Ctrl+D`:复制当前行
|
||||
- `Ctrl+/`:切换注释
|
||||
- `Alt+Up/Down`:向上/向下移动行
|
||||
- `Ctrl+Shift+F`:格式化代码(如果语言支持 Prettier)
|
||||
|
||||
### 块管理
|
||||
|
||||
- `Ctrl+Enter`:创建新块
|
||||
- `Ctrl+Shift+Enter`:在上方创建块
|
||||
- `Alt+Delete`:删除当前块
|
||||
|
||||
## 使用 HTTP 客户端
|
||||
|
||||
voidraft 包含用于测试 API 的内置 HTTP 客户端:
|
||||
|
||||
1. 创建一个 HTTP 语言的块
|
||||
2. 编写你的 HTTP 请求:
|
||||
## 翻译与文本标注
|
||||
- 选中文本后会浮现翻译入口(`translator` 扩展),点击即可在块内查看结果、复制、切换目标语言。
|
||||
- `textHighlight` 扩展提供 `Mod+Shift+H` 高亮当前选区,颜色可在扩展设置中调整。
|
||||
|
||||
## HTTP 客户端概览
|
||||
```http
|
||||
POST "https://api.example.com/users" {
|
||||
∞∞∞http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "{{secrets.token}}"
|
||||
}
|
||||
|
||||
POST "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
content-type: "application/json"
|
||||
|
||||
|
||||
@json {
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com"
|
||||
name: "voidraft",
|
||||
role: "developer"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `parser/request-parser.ts` 会将变量与请求体解析为结构化对象。
|
||||
- 点击 gutter Run 获取响应,`response-inserter.ts` 会将结果写入 `### Response` 区块。
|
||||
|
||||
3. 点击运行按钮执行请求
|
||||
4. 内联查看响应
|
||||
## 自动保存与版本安全
|
||||
- `editorStore` 为每个文档维护 `autoSaveTimer`,默认 2000 ms,可在设置 > 编辑 调整。
|
||||
- `documentStates` 记录每个文档的光标位置,切换文档或重启应用都会恢复。
|
||||
- 若开启 Git 备份,可在工具栏或设置中查看最近一次 `push` 是否成功。
|
||||
|
||||
## 多窗口支持
|
||||
|
||||
同时处理多个文档:
|
||||
|
||||
1. 转到 `文件 > 新建窗口`(或 `Ctrl+Shift+N`)
|
||||
2. 每个窗口都是独立的
|
||||
3. 更改会自动保存
|
||||
|
||||
## 自定义主题
|
||||
|
||||
个性化你的编辑器:
|
||||
|
||||
1. 打开设置(`Ctrl+,`)
|
||||
2. 转到外观
|
||||
3. 选择主题或创建自己的主题
|
||||
4. 根据你的偏好自定义颜色
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
学习基本快捷键:
|
||||
|
||||
| 操作 | 快捷键 |
|
||||
|-----|--------|
|
||||
| 新建窗口 | `Ctrl+Shift+N` |
|
||||
| 搜索 | `Ctrl+F` |
|
||||
| 替换 | `Ctrl+H` |
|
||||
| 格式化代码 | `Ctrl+Shift+F` |
|
||||
| 切换主题 | `Ctrl+Shift+T` |
|
||||
| 命令面板 | `Ctrl+Shift+P` |
|
||||
|
||||
## 下一步
|
||||
|
||||
现在你已经了解了基础知识:
|
||||
|
||||
- 详细探索[功能特性](/zh/guide/features)
|
||||
## 最佳实践
|
||||
- 使用 Markdown 块为每组代码加标题/注释,便于导航。
|
||||
- 重要文档启用“锁定”以避免被删除(文档右键菜单)。
|
||||
- 多窗口 + 吸附用于常驻参考资料,标签页用于在一个窗口内快速切换。
|
||||
- 善用「窗口置顶」图钉,让 voidraft 叠放在 VSCode/浏览器之上。
|
||||
|
||||
接下来:
|
||||
- [界面总览](/zh/guide/ui-overview)
|
||||
- [块语法与结构](/zh/guide/block-syntax)
|
||||
|
||||
72
frontend/docs/src/zh/guide/http-client.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# HTTP 客户端
|
||||
|
||||

|
||||
> 替换为 HTTP 块 + 响应卡片的截图。
|
||||
|
||||
voidraft 将 HTTP 测试写成块(`∞∞∞http`),语法与 JetBrains Http Client 类似。解析与执行由 `frontend/src/views/editor/extensions/httpclient` 完成。
|
||||
|
||||
## 基本语法
|
||||
```http
|
||||
∞∞∞http
|
||||
GET "https://api.example.com/users" {
|
||||
accept: "application/json"
|
||||
}
|
||||
```
|
||||
- 方法 + URL 必须用双引号包裹。
|
||||
- Header 以 `key: "value"` 格式编写。
|
||||
- 请求体使用内联指令(见下文)。
|
||||
|
||||
## 变量与环境
|
||||
```http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "{{secrets.token}}"
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
}
|
||||
```
|
||||
- `@var` 块使用 JSON 语法。
|
||||
- 变量在任意请求中以 `{{name}}` 引用。
|
||||
- `variable-resolver.ts` 支持嵌套、默认值、外部 secrets 映射。
|
||||
|
||||
## 请求体助手
|
||||
| 指令 | 示例 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `@json` | `@json { "name": "voidraft" }` | 自动 `content-type: application/json` 并格式化 |
|
||||
| `@form` | `@form { username: "demo" }` | 转为 `application/x-www-form-urlencoded` |
|
||||
| `@multipart` | `@multipart { file: @"C:\tmp\a.txt" }` | 读取文件、多段表单 |
|
||||
| `@text` | `@text <raw body>` | 自由文本 |
|
||||
|
||||
## 运行与响应
|
||||
1. 将光标置于 HTTP 块内。
|
||||
2. 点击行号左侧的 Run(三角形图标),或按下自定义快捷键。
|
||||
3. 运行结果会插入到块尾的 `### Response` 中,包含:
|
||||
- 状态行 + 响应时间 + 体积。
|
||||
- Headers(可折叠)。
|
||||
- 响应体(自动格式化 JSON / XML / HTML / Text)。
|
||||
- 复制、另存为、再次发送等快捷按钮。
|
||||
|
||||
## 多请求文档
|
||||
- 每个 `∞∞∞http` 块被视为独立请求。
|
||||
- `request-parser.ts` 会解析同一块内的多个请求(以 `###` 分隔)。
|
||||
- 使用 Markdown 块写注释或分组标题。
|
||||
|
||||
## 变量注入顺序
|
||||
1. 块内 `@var`。
|
||||
2. 文档级变量(计划中)。
|
||||
3. 环境变量(`EnvironmentService` 预留)。
|
||||
|
||||
## 调试技巧
|
||||
- 运行器会在控制台打印完整请求信息,可通过 `wails3 dev` 查看。
|
||||
- 如果响应过大,可右键响应块选择“折叠正文”或“导出到文件”。
|
||||
- 网络错误会在响应卡片顶部以红条展示,内容来自 `HttpClientService`。
|
||||
- 需要代理时确保系统代理已设置,voidraft 会自动继承。
|
||||
|
||||
## 与其他功能配合
|
||||
- 运行结果可直接与 Markdown/代码块混排,形成 API 使用手册。
|
||||
- 配合 Git 备份可版本化 API 调试记录。
|
||||
- 可将响应复制到其他块(例如 JSON → Prettier 之后用于 mock)。
|
||||
|
||||
> 欢迎在 Issue 中提交你希望支持的额外 DSL 指令(例如 GraphQL、WebSocket、gRPC)。
|
||||
@@ -1,63 +1,77 @@
|
||||
# 安装
|
||||
|
||||
本指南将帮助你在系统上安装 voidraft。
|
||||

|
||||
> 替换为安装向导或设置页截图,展示关键开关(置顶、数据目录、自动更新等)。
|
||||
|
||||
## 系统要求
|
||||
## 系统要求(2025.11)
|
||||
| 项目 | 最低配置 | 推荐配置 |
|
||||
| --- | --- | --- |
|
||||
| 操作系统 | Windows 10 19045 / Windows 11 21H2 | Windows 11 23H2(macOS/Linux 版本开发中) |
|
||||
| CPU | x86_64 双核 | 4 核以上 |
|
||||
| 内存 | 4 GB | ≥ 8 GB |
|
||||
| 磁盘空间 | 200 MB(含 SQLite 数据) | 1 GB 以上以保存附件/备份 |
|
||||
| 运行环境 | Go 1.21+, Node.js 18+(仅开发者编译时需要) | 同左 + pnpm 8 用于前端 |
|
||||
|
||||
- **操作系统**:Windows 10 或更高版本(macOS 和 Linux 支持计划中)
|
||||
- **内存**:最低 4GB,推荐 8GB
|
||||
- **磁盘空间**:200MB 可用空间
|
||||
## 获取发行版
|
||||
1. 打开 [GitHub Releases](https://github.com/landaiqing/voidraft/releases) 或自建 Gitea 镜像。
|
||||
2. 下载 `voidraft-windows-amd64-installer.exe`(安装版)或 `voidraft-portable.zip`(绿色版)。
|
||||
3. (可选)验证 SHA256:
|
||||
```powershell
|
||||
Get-FileHash .\voidraft-windows-amd64-installer.exe -Algorithm SHA256
|
||||
```
|
||||
4. 双击安装包,按向导完成安装;或解压绿色版至任意目录并创建快捷方式。
|
||||
|
||||
## 下载
|
||||
## 首次启动流程
|
||||
1. 启动后将创建数据目录:`%USERPROFILE%\.voidraft\data`(含 `voidraft.db`、`config.json`、`extensions.json`)。
|
||||
2. 默认会生成 `default` 文档和一段示例块 `∞∞∞text-a`。
|
||||
3. 若检测到旧版本数据,`ConfigMigrationService` 会自动迁移字段;`DataMigrationService` 确保表结构一致。
|
||||
4. 首次运行建议立刻打开「设置 > 备份」配置远程 Git 仓库。
|
||||
|
||||
访问[发布页面](https://github.com/landaiqing/voidraft/releases)并下载适合你平台的最新版本:
|
||||
## 开发者手动构建
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/landaiqing/voidraft.git
|
||||
cd voidraft
|
||||
|
||||
- **Windows**:`voidraft-windows-amd64-installer.exe`
|
||||
# 安装前端依赖
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
## 安装步骤
|
||||
# 构建/运行桌面应用
|
||||
wails3 dev # 启动调试
|
||||
wails3 package # 生成安装包(输出位于 bin/)
|
||||
```
|
||||
> 若遇到 `wails3` 未找到,请先执行 `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`。
|
||||
|
||||
### Windows
|
||||
## 数据目录与可执行文件
|
||||
| 类型 | 默认位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 安装目录 | `C:\Program Files\voidraft` | 包含主程序与嵌入式前端资源 |
|
||||
| 数据目录 | `C:\Users\<you>\.voidraft\data` | 可在设置 > 通用修改 `dataPath`,修改后需重启 |
|
||||
| 备份仓库 | `dataPath/.git` | `BackupService` 初始化或使用现有仓库 |
|
||||
| 日志 | `%LOCALAPPDATA%/voidraft/logs/*.log` | 通过 Wails `application.Log` 输出 |
|
||||
|
||||
1. 从发布页面下载安装程序
|
||||
2. 运行 `voidraft-windows-amd64-installer.exe` 文件
|
||||
3. 按照安装向导操作
|
||||
4. 从开始菜单或桌面快捷方式启动 voidraft
|
||||
## 常用 CLI 检查
|
||||
```powershell
|
||||
# 查看版本
|
||||
& "C:\Program Files\voidraft\voidraft.exe" --version
|
||||
|
||||
## 首次启动
|
||||
# 清理缓存(若前端异常)
|
||||
Remove-Item "$env:APPDATA\voidraft\Cache" -Recurse -Force
|
||||
```
|
||||
|
||||
首次启动 voidraft 时:
|
||||
## 防火墙与代理
|
||||
- voidraft 仅在使用 HTTP 客户端、更新检测、REST 翻译器时发起网络请求。
|
||||
- 若处于企业代理,请在系统代理中放行 `voidraft.exe` 或设置环境变量 `HTTP(S)_PROXY`,HTTP 客户端会继承系统代理。
|
||||
|
||||
1. 应用程序将创建一个数据目录来存储你的文档
|
||||
2. 你将看到带有欢迎块的主编辑器界面
|
||||
3. 开始输入或创建你的第一个代码块!
|
||||
|
||||
## 配置
|
||||
|
||||
voidraft 将其配置和数据存储在:
|
||||
|
||||
- **Windows**:`%APPDATA%/voidraft/`
|
||||
|
||||
你可以自定义各种设置,包括:
|
||||
- 编辑器主题(深色/浅色模式)
|
||||
- 代码格式化偏好
|
||||
- 备份设置
|
||||
- 键盘快捷键
|
||||
|
||||
## 更新
|
||||
|
||||
voidraft 包含自动更新功能,会在有新版本时通知你。你可以:
|
||||
|
||||
- 从设置中手动检查更新
|
||||
- 启用自动更新
|
||||
- 选择首选的更新源
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果在安装过程中遇到任何问题:
|
||||
|
||||
1. 确保你有管理员权限
|
||||
2. 检查杀毒软件是否阻止了安装
|
||||
3. 访问我们的 [GitHub issues](https://github.com/landaiqing/voidraft/issues) 页面寻求帮助
|
||||
|
||||
下一步:[快速开始 →](/zh/guide/getting-started)
|
||||
## 常见安装问题
|
||||
| 症状 | 处理方案 |
|
||||
| --- | --- |
|
||||
| 安装向导被安全策略阻止 | 使用签名哈希进行白名单设置或改用便携版 |
|
||||
| 启动后白屏 | 删除 `%APPDATA%/voidraft/Cache`,确保显卡驱动支持 WebView2 |
|
||||
| `wails3 dev` 报错缺少 WebView2 | 安装 [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) |
|
||||
| 便携版无法写入 | 检查解压目录是否具有写权限,或在设置内切换 `dataPath` 至可写分区 |
|
||||
|
||||
> 继续阅读:[快速开始](/zh/guide/getting-started)
|
||||
|
||||
@@ -1,50 +1,73 @@
|
||||
# 简介
|
||||
|
||||
欢迎使用 voidraft —— 一个专为开发者设计的优雅文本片段记录工具。
|
||||
> voidraft 是一款面向开发者的「块式工作台」,用 CodeMirror 6 打造 Heynote 风格的体验,并结合 Wails3 + Go 后端提供系统托盘、全局热键、自动备份等桌面级能力。
|
||||
|
||||
## 什么是 voidraft?
|
||||

|
||||
> 将 `/img/placeholder-main-ui.png` 替换为真实的应用主界面截图,演示数据面板、工具栏和右侧小地图。
|
||||
|
||||
voidraft 是一个现代化的桌面应用程序,帮助开发者管理文本片段、代码块、API 响应、会议笔记和日常待办事项。它为开发工作流程提供了流畅而优雅的编辑体验和强大的功能。
|
||||
## 产品定位
|
||||
- **核心诉求**:在一处快速记录代码/配置/API 响应/待办清单,并能随时重排、格式化、运行或搜索。
|
||||
- **目标用户**:需要跨项目管理零碎文本的开发者、DevOps、测试或产品技术写作者。
|
||||
- **设计理念**:所有内容都拆成可重排的块(`∞∞∞language`);每个块拥有独立语言、格式化器与扩展;多窗口/多标签保证同一份数据的不同视角。
|
||||
|
||||
## 核心特性
|
||||
## 面向场景
|
||||
1. **临时代码/脚本草稿**:支持 30+ 语言高亮、Prettier 格式化、彩虹括号、文本高亮。
|
||||
2. **API 调试台**:HTTP 块内置运行器、变量解析、响应插入;请求和响应始终和文档共存。
|
||||
3. **会议 & 需求记录**:Markdown 块 + Checkbox 扩展 + 颜色标注快速整理想法。
|
||||
4. **翻译与研究**:选中文本即可调 Bing/Google/DeepL/TartuNLP/有道翻译,结果内联呈现。
|
||||
5. **多窗口资料墙**:重要文档可弹出独立无边框窗口,依附(Snap)在主窗口侧边。
|
||||
|
||||
### 块状编辑模式
|
||||
## 核心概念
|
||||
### 块式编辑器
|
||||
- 解析器位于 `frontend/src/views/editor/extensions/codeblock`,依赖自研 Lezer 语法树确保 `∞∞∞` 分隔符稳定。
|
||||
- 块结构(语言、是否自动检测、正文范围)存入 `blockState`,供格式化、移动、复制、HTTP 执行等扩展复用。
|
||||
- `math` 块使用 `math.js` 运行器,`http` 块调用 request parser + gutter run widget。
|
||||
|
||||
voidraft 使用受 Heynote 启发的独特块状编辑系统。你可以将内容分割为独立的代码块,每个块具有:
|
||||
- 不同的编程语言设置
|
||||
- 语法高亮
|
||||
- 独立格式化
|
||||
- 轻松在块之间导航
|
||||
### 扩展驱动
|
||||
- 后端通过 `internal/models/extensions.go` 定义扩展 ID/配置,`ExtensionService` 负责持久化。
|
||||
- 前端 `ExtensionManager` 根据扩展配置动态拼装 CodeMirror Extension pipeline(小地图、VSCode Search、Translator、Color Picker 等)。
|
||||
- 所有扩展都可在设置页热切换,立即同步到当前与所有已打开的编辑器实例。
|
||||
|
||||
### 开发者工具
|
||||
### 数据与安全
|
||||
- SQLite 数据保存在 `%USERPROFILE%/.voidraft/data/voidraft.db`(可在设置中自定义 dataPath)。
|
||||
- `DatabaseService` 自动迁移表结构,`DocumentService` 提供软删除/锁定机制避免误删默认草稿。
|
||||
- `BackupService` 基于 go-git(SSH/Token/用户名密码)把 dataPath git 化,可按分钟全量提交、推送到 GitHub/Gitea 等。
|
||||
- `SelfUpdateService` 同时轮询 GitHub/Gitea Release,支持自动下载 + 一键重启。
|
||||
|
||||
- **HTTP 客户端**:直接在编辑器中测试 API
|
||||
- **代码格式化**:内置 Prettier 支持多种语言
|
||||
- **语法高亮**:支持 30+ 种编程语言
|
||||
- **自动语言检测**:自动识别代码块语言类型
|
||||
## 系统架构概览
|
||||
| 层级 | 说明 | 关键路径 |
|
||||
| --- | --- | --- |
|
||||
| 桌面容器 | Wails3 + Go 1.21,负责窗口、托盘、热键、服务注入 | `main.go`, `internal/services` |
|
||||
| 后端服务 | Config/Document/Extension/Theme/Backup/Window/Hotkey/Translation 等 | `internal/services/*.go` |
|
||||
| 数据模型 | Document、Theme、KeyBinding、GitBackup、Config | `internal/models` |
|
||||
| 前端应用 | Vue 3 + Vite + Pinia + vue-router | `frontend/src` |
|
||||
| 编辑器内核 | CodeMirror 6 扩展及自研块解析、HTTP DSL、Markdown 预览 | `frontend/src/views/editor` |
|
||||
| 文档站点 | VitePress,多语言导航 | `frontend/docs` |
|
||||
|
||||
### 自定义
|
||||
## 模块速览
|
||||
- **文档存储**:`DocumentService` 支持创建/重命名/软删除/恢复、多窗口并发打开同一文档。
|
||||
- **编辑器实例管理**:`editorStore` 使用 LRU 缓存 + 自动保存计时器,确保在多文档切换时保留光标位置、未保存内容。
|
||||
- **HTTP 客户端**:`extensions/httpclient` 包括 Lezer 语法、变量解析、响应插入与运行 gutter;支持 JSON/FormData/GraphQL 等多体格式。
|
||||
- **Markdown 预览**:`panelStore` 管理逐文档的预览状态,可随块实时刷新。
|
||||
- **多窗口/吸附**:`WindowService` + `WindowSnapService` 根据主窗口位置智能吸附子窗口、自动记忆尺寸。
|
||||
- **全局热键**:`HotkeyService` 监听系统级组合键,切换窗口显隐(默认 Alt+X,可配置)。
|
||||
- **系统托盘**:`systray.SetupSystemTray` 注入显示/隐藏、退出、开机启动等操作。
|
||||
- **翻译生态**:`TranslationService` 聚合 Bing/Google/Youdao/DeepL/TartuNLP,前端 `translator` 扩展提供 Tooltip + 复制。
|
||||
- **主题与外观**:`ThemeService` 预置 12+ 主题,可重置/克隆;前端 `createThemeExtension` 即时应用。
|
||||
|
||||
- **自定义主题**:创建并保存你自己的编辑器主题
|
||||
- **扩展功能**:丰富的编辑器扩展,包括小地图、彩虹括号、颜色选择器等
|
||||
- **多窗口**:同时处理多个文档
|
||||
## 数据流(从键盘到持久化)
|
||||
1. 用户按键 -> CodeMirror extensions 更新文档。
|
||||
2. `contentChangeExtension` 记录脏状态并刷新 `documentStats`(行数、字符数、选区字符数)。
|
||||
3. 触发自动保存计时器(默认 2s) -> `DocumentService.UpdateDocumentContent` 写入 SQLite。
|
||||
4. 若开启 Git 自动备份,每次 Commit 会序列化数据库 + 附带 `voidraft_data.bin`。
|
||||
5. 配置变更(Pinia store)通过 `ConfigService.Set` 传回 Go,并触发观察者(如 WindowSnap/Hotkey/Backup)。
|
||||
|
||||
### 数据管理
|
||||
|
||||
- **Git 备份**:使用 Git 仓库自动备份
|
||||
- **云同步**:跨设备同步你的数据
|
||||
- **自动更新**:及时获取最新功能
|
||||
|
||||
## 为什么选择 voidraft?
|
||||
|
||||
- **专注开发者**:考虑开发者需求而构建
|
||||
- **现代技术栈**:使用前沿技术(Wails3、Vue 3、CodeMirror 6)
|
||||
- **跨平台**:支持 Windows(macOS 和 Linux 支持计划中)
|
||||
- **开源**:MIT 许可证,社区驱动开发
|
||||
|
||||
## 开始使用
|
||||
|
||||
准备好开始了吗?从我们的[发布页面](https://github.com/landaiqing/voidraft/releases)下载最新版本,或继续阅读文档了解更多。
|
||||
|
||||
下一步:[安装 →](/zh/guide/installation)
|
||||
## 版本节奏与路线图
|
||||
- ✅ 当前实现:多窗口、标签页、HTTP 客户端、Markdown Preview、数学块、彩虹括号、翻译、Git 备份、自动更新。
|
||||
- 🚧 进行中:自定义扩展导入、键位模版、Linux/macOS 原生打包。
|
||||
- 🗺️ 规划中:剪贴板历史、团队同步、云端模板市场。
|
||||
|
||||
## 下一步
|
||||
- [安装 voidraft](/zh/guide/installation)
|
||||
- [界面总览](/zh/guide/ui-overview)
|
||||
- [快速开始](/zh/guide/getting-started)
|
||||
|
||||
74
frontend/docs/src/zh/guide/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 键盘快捷键
|
||||
|
||||

|
||||
> 替换为展示快捷键设置界面或常用快捷键速查表的截图。
|
||||
|
||||
快捷键定义源自 `internal/models/key_bindings.go`,在设置 > 键位 中可以启用/禁用或改写。下表列出常用组合:
|
||||
|
||||
## 块管理
|
||||
| 功能 | 默认快捷键 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 新建块(下方) | `Ctrl+Enter` | `blockAddAfterCurrent` |
|
||||
| 新建块(上方) | `Ctrl+Shift+Enter` | `blockAddBeforeCurrent` |
|
||||
| 跳到上/下一个块 | `Alt+↑ / Alt+↓` | `blockGotoPrevious/Next` |
|
||||
| 选择当前块 | `Ctrl+Shift+A` | `blockSelectAll` |
|
||||
| 删除块 | `Alt+Delete` | `blockDelete` |
|
||||
| 块上移/下移 | `Ctrl+Shift+↑ / Ctrl+Shift+↓` | `blockMoveUp/Down` |
|
||||
| 复制块 | `Ctrl+C`(块获得焦点) | `blockCopy` |
|
||||
| 剪切块 | `Ctrl+X` | `blockCut` |
|
||||
| 粘贴块 | `Ctrl+V` | `blockPaste` |
|
||||
|
||||
## 行与文本编辑
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 行复制(上/下) | `Shift+Alt+↑ / Shift+Alt+↓` |
|
||||
| 行移动(上/下) | `Alt+↑ / Alt+↓`(在块内部) |
|
||||
| 插入空行 | `Ctrl+Enter`(块尾后仍可插入) |
|
||||
| 选择整行 | `Alt+L` |
|
||||
| 语法级跳转 | `Ctrl+Alt+Left / Ctrl+Alt+Right` |
|
||||
| 匹配括号 | `Shift+Ctrl+\` |
|
||||
| 注释/块注释 | `Ctrl+/` / `Shift+Alt+A` |
|
||||
| Tab 缩进/反向缩进 | `Ctrl+]` / `Ctrl+[` |
|
||||
| 删除单词(向前/向后) | `Ctrl+Backspace` / `Ctrl+Delete` |
|
||||
|
||||
## 搜索与替换
|
||||
| 功能 | 快捷键 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| 打开搜索 | `Ctrl+F` | `showSearch` |
|
||||
| 打开替换 | `Ctrl+H` | `searchShowReplace` |
|
||||
| 切换大小写/整词/正则 | `Alt+C / Alt+W / Alt+R` | `searchToggleCase/Word/Regex` |
|
||||
| 替换全部 | `Alt+Enter` | `searchReplaceAll` |
|
||||
|
||||
## Markdown/预览/格式化
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 格式化块 | `Ctrl+Shift+F` |
|
||||
| 打开 Markdown 预览 | 工具栏按钮(建议映射到 `Ctrl+Shift+M`) |
|
||||
| 高亮文本 | `Mod+Shift+H` |
|
||||
|
||||
## 窗口与系统
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 新建窗口 | `Ctrl+Shift+N`(命令面板) |
|
||||
| 全局显示/隐藏所有窗口 | 默认 `Alt+X`(可在设置 > 通用 > 全局热键中修改) |
|
||||
| 打开设置 | `Ctrl+,` |
|
||||
| 切换主题 | `Ctrl+Shift+T`(可自定义) |
|
||||
|
||||
## HTTP 客户端
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 运行请求 | 点击行号旁 Run 或自定义 `Ctrl+Alt+R` |
|
||||
| 复制响应正文 | `Ctrl+Alt+C`(响应块聚焦时) |
|
||||
|
||||
## 翻译工具
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 显示翻译浮层 | 选中 ≥ `minSelectionLength` 的文本后按 `Ctrl+'`(可自定义) |
|
||||
| 复制译文 | 在浮层中按 `Ctrl+C` |
|
||||
|
||||
## 自定义与导出
|
||||
1. 打开设置 > 键位,列表会加载来自 `ExtensionService.GetAllKeyBindings()` 的数据。
|
||||
2. 可单独禁用某个绑定或录入新组合;存储在 `%USERPROFILE%/.voidraft/data/key_bindings.json`。
|
||||
3. 需要与系统级快捷键冲突时,可勾选“忽略系统修饰键”。
|
||||
|
||||
> 建议将以上表格打印贴在工作区,或在文档中保留常用组合,方便新同事查阅。
|
||||
67
frontend/docs/src/zh/guide/multiwindow-tabs.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 多窗口与标签页
|
||||
|
||||

|
||||
> 替换为展示主窗口 + 侧边浮窗(子窗口)或标签页齐开的截图。
|
||||
|
||||
## 多窗口工作流
|
||||
- `WindowService.OpenDocumentWindow` 会根据文档 ID 创建新 WebView 窗口,URL 自动附加 `?documentId=<id>`。
|
||||
- `windowStore` 通过查询字符串判断当前是否为子窗口(非主窗口)。
|
||||
- 子窗口具备:
|
||||
- 独立的 CodeMirror 实例与扩展栈。
|
||||
- 与主窗口共享的 Document/Config Store,因此编辑内容实时同步(SQLite 数据库为唯一来源)。
|
||||
- `WindowSnapService` 提供吸附:拖动靠近主窗口边缘时自动贴靠;支持上下左右以及四个角。
|
||||
- 关闭时自动注销吸附状态,避免悬挂引用。
|
||||
|
||||
### 操作步骤
|
||||
1. 打开文档列表(工具栏图标或 `Ctrl+Shift+O`)。
|
||||
2. 右键目标文档 → 选择 “在新窗口中打开”。
|
||||
3. 若文档已在标签页打开,会自动从标签栏移除,防止重复。
|
||||
4. 关闭窗口:
|
||||
- 点击自定义标题栏关闭按钮。
|
||||
- 系统托盘菜单选择退出。
|
||||
|
||||
### 使用建议
|
||||
- 将参考资料或检查清单放在子窗口中,配合“窗口置顶”保持常驻。
|
||||
- 通过 Windows Snap + voidraft Snap 组合,可快速排版 2-4 个窗口。
|
||||
- 若想在多窗口之间同步滚动,可尝试启用“共享视图状态”扩展(计划中)。
|
||||
|
||||
## 标签页模式
|
||||
- 在设置 > 通用中开启“启用标签页”(`config.general.enableTabs`)。
|
||||
- `tabStore` 维护 `tabsMap` + `tabOrder`,支持:
|
||||
- 拖拽排序(拖动标签即可)。
|
||||
- 关闭单个/其他/左侧/右侧标签。
|
||||
- 检测当前文档是否已存在标签。
|
||||
- 标签栏位于主窗口顶部,紧贴工具栏下方。
|
||||
|
||||
### 常用操作
|
||||
| 操作 | 方法 |
|
||||
| --- | --- |
|
||||
| 关闭标签 | 点击标签上的叉号或中键 |
|
||||
| 关闭其他 | 右键标签 → “关闭其他标签” |
|
||||
| 关闭右侧/左侧 | 右键标签 → 选择对应菜单 |
|
||||
| 固定标签(计划中) | 将在后续版本中提供 pin 功能 |
|
||||
|
||||
### Tabs vs 窗口
|
||||
| 项目 | 标签页 | 新窗口 |
|
||||
| --- | --- | --- |
|
||||
| UI 占用 | 集成在一个窗口中 | 独立操作系统窗口 |
|
||||
| 跨屏 | 不方便 | 可拖到其他显示器 |
|
||||
| 独立置顶 | 不可单独置顶 | 每个窗口可单独置顶 |
|
||||
| 推荐场景 | 同一背景的多个文档 | 跨项目/跨显示器对比 |
|
||||
|
||||
## 系统托盘与热键
|
||||
- 勾选 “启用系统托盘” 后,关闭窗口默认隐藏至托盘。
|
||||
- 全局热键(默认 `Alt+X`)由 `HotkeyService` 控制:
|
||||
- 若 main window 可见 → 隐藏所有 voidraft 窗口。
|
||||
- 若 main window 隐藏 → Show + Restore + Focus。
|
||||
- `TrayService` 还提供“最小化到托盘”“显示主窗口”等菜单项。
|
||||
|
||||
## 常见问题
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 新窗口无法打开 | 文档 ID 不存在或被锁定 | 在文档列表确认状态,必要时解锁 |
|
||||
| 子窗口未吸附 | WindowSnap 未启用 | 设置 > 通用 → 勾选“窗口吸附” |
|
||||
| 关闭窗口直接退出应用 | 未启用托盘模式 | 设置 > 通用 → 启用系统托盘 |
|
||||
| 标签页切换慢 | 同时开启标签 + 多窗口导致资源占用 | 关闭暂不需要的窗口或减少标签 |
|
||||
|
||||
> 如果需要更复杂的布局(如平铺窗口、快捷布局),欢迎在 Issue 中提出建议。
|
||||
71
frontend/docs/src/zh/guide/settings.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 设置与配置
|
||||
|
||||

|
||||
> 替换为设置页截图,突出通用/编辑/外观/更新/备份等分栏。
|
||||
|
||||
所有设置都映射到 `internal/models/config.go`,持久化文件位于 `%USERPROFILE%/.voidraft/data/config.json`。前端 `configStore` 负责与后端 `ConfigService` 同步。
|
||||
|
||||
## 通用(General)
|
||||
| 选项 | 说明 | 后端键 |
|
||||
| --- | --- | --- |
|
||||
| 窗口置顶 (`alwaysOnTop`) | 永久置顶主窗口 | `general.alwaysOnTop` |
|
||||
| 数据目录 (`dataPath`) | SQLite + 备份所在目录,修改后需重启 | `general.dataPath` |
|
||||
| 系统托盘 (`enableSystemTray`) | 关闭窗口后隐藏到托盘而非退出 | `general.enableSystemTray` |
|
||||
| 开机自启 (`startAtLogin`) | 调用 `StartupService` 注册 | `general.startAtLogin` |
|
||||
| 窗口吸附 (`enableWindowSnap`) | `WindowSnapService` 是否启用 | `general.enableWindowSnap` |
|
||||
| 全局热键 (`enableGlobalHotkey` + `globalHotkey`) | 默认 Alt+X,控制显隐 | `general.globalHotkey` |
|
||||
| 标签页 (`enableTabs`) | 启用多标签界面 | `general.enableTabs` |
|
||||
| 加载动画 (`enableLoadingAnimation`) | 切换文档时显示动画 | `general.enableLoadingAnimation` |
|
||||
|
||||
## 编辑(Editing)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Font Size/Family/Weight/Line Height | 立即作用于所有编辑器实例 |
|
||||
| Tab Size/Tab Type/Enable Tab Indent | 映射 `tabExtension` 行为 |
|
||||
| Auto Save Delay | ms,影响 `editorStore` 自动保存周期 |
|
||||
|
||||
## 外观(Appearance)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Language | UI 语言(`zh-CN`/`en-US`) |
|
||||
| System Theme | 深色/浅色/跟随系统 |
|
||||
| Current Theme | 选择预设或自定义主题(详见 [主题与外观](/zh/guide/themes)) |
|
||||
|
||||
## 更新(Updates)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Auto Update | 启动时自动检查更新 |
|
||||
| Primary/Backup Source | `github` 或 `gitea`,对应 `UpdatesConfig` |
|
||||
| Backup Before Update | 下载更新前执行 Git 备份 |
|
||||
| Update Timeout | HTTP 请求超时 |
|
||||
| GitHub/Gitea 仓库 | owner/repo/baseURL,可指向自建镜像 |
|
||||
|
||||
## 备份(Backup)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Enabled | 开关 Git 备份 |
|
||||
| Repo URL | 远程仓库地址(HTTPS 或 SSH) |
|
||||
| Auth Method | `token` / `ssh_key` / `user_pass` |
|
||||
| Username/Password/Token/SSH Key Path | 根据认证方式填写 |
|
||||
| Backup Interval | 自动备份间隔(分钟) |
|
||||
| Auto Backup | 是否按间隔自动推送 |
|
||||
|
||||
## 键位(Key Bindings)
|
||||
- 列表由 `ExtensionService.GetAllKeyBindings()` 提供。可搜索命令 ID 或组合。
|
||||
- 允许将命令禁用(关闭开关)或录入新组合。
|
||||
- 更改立即影响所有编辑器实例。
|
||||
|
||||
## 扩展(Extensions)
|
||||
- 显示 `ExtensionSettings` 中的所有扩展。
|
||||
- 每项可开关并展示 JSON 配置(背景色、最小选区、最小化提示等)。
|
||||
- 修改后调用 `ExtensionService.UpdateExtensionState` 并通知 `ExtensionManager` 热更新。
|
||||
|
||||
## 配置文件备份
|
||||
- 每次修改配置都会更新 `metadata.lastUpdated`,可用 Git 备份追踪历史。
|
||||
- 若出现配置损坏,可删除 `config.json`,应用会写入 `NewDefaultAppConfig`。
|
||||
|
||||
## 导入/导出(建议)
|
||||
- 目前可手动复制 `config.json`/`extensions.json`/`key_bindings.json`。
|
||||
- 计划提供 UI 层面的导入导出按钮,便于跨设备同步。
|
||||
|
||||
> 修改高级选项(如 dataPath)后建议重启,以确保后台服务(数据库、备份、窗口吸附等)读取到最新配置。
|
||||
44
frontend/docs/src/zh/guide/themes.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 主题与外观
|
||||
|
||||

|
||||
> 替换为主题切换界面或自定义主题编辑器的截图。
|
||||
|
||||
voidraft 的主题由后端 `ThemeService` 管理,存储在 `themes` 表。前端通过 `themeStore` + `createThemeExtension` 应用色板。
|
||||
|
||||
## 预设主题
|
||||
| 名称 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| default-dark | Dark | 默认暗色,适合低光环境 |
|
||||
| default-light | Light | 默认亮色 |
|
||||
| dracula | Dark | 高对比度紫色系 |
|
||||
| aura | Dark | 柔和霓虹风 |
|
||||
| github-dark / github-light | Dark/Light | 与 GitHub 主题接近 |
|
||||
| material-dark / light | Dark/Light | Material Design 色板 |
|
||||
| one-dark | Dark | VSCode 经典主题 |
|
||||
| solarized-dark / light | Dark/Light | Solarized 配色 |
|
||||
| tokyo-night / storm / day | Dark/Light | Tokyo Night 三件套 |
|
||||
|
||||
## 自定义主题
|
||||
1. 打开设置 > 外观,选择「创建主题」。
|
||||
2. 颜色字段对应 `ThemeColorConfig`,包含 `editor.background`, `editor.foreground`, `gutter`, `selection`, `bracket`, `keyword`, `string`, `comment`, `accent` 等。
|
||||
3. 保存后立即写入数据库,可通过 `Reset` 按钮恢复为预设值。
|
||||
4. 前端 `themeExtension` 会向 CodeMirror 注入新的 `EditorView.theme`。
|
||||
|
||||
## 动态切换
|
||||
- 切换主题会立即影响所有已打开的编辑器实例;`updateEditorTheme` 逐个更新 `EditorView`。
|
||||
- `SystemTheme` 设为 `auto` 时,voidraft 会监听操作系统深浅模式并自动切换到 `default-dark` 或 `default-light`。
|
||||
|
||||
## 字体与行高
|
||||
- 字体配置来自设置 > 编辑,`createFontExtensionFromBackend` 会同步 `fontFamily/fontSize/fontWeight/lineHeight`。
|
||||
- 可在通用设置中的“滚轮缩放”手势下临时调整字号。
|
||||
|
||||
## 小地图/装饰色
|
||||
- `minimap` 扩展读取主题中的 `accent` 颜色,用于高亮当前视区。
|
||||
- `textHighlight` 扩展的默认背景色可在扩展设置中配置。
|
||||
|
||||
## 截图建议
|
||||
- 展示暗/亮主题对比。
|
||||
- 展示主题编辑对话框,标出关键字段。
|
||||
- 展示自定义主题应用后的编辑器界面。
|
||||
|
||||
> 如果希望导入 VSCode `.json` 主题,可将颜色映射到 `ThemeColorConfig` 后写入数据库,或等待官方导入工具上线。
|
||||
53
frontend/docs/src/zh/guide/troubleshooting.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 常见问题与故障排查
|
||||
|
||||

|
||||
> 替换为错误提示或日志查看界面的截图。
|
||||
|
||||
## 安装与启动
|
||||
| 问题 | 可能原因 | 解决步骤 |
|
||||
| --- | --- | --- |
|
||||
| 启动白屏 | WebView2 缺失或缓存损坏 | 安装 WebView2 Runtime;删除 `%APPDATA%/voidraft/Cache` 后重启 |
|
||||
| 双击无反应 | 被安全策略拦截 | 以管理员运行或使用便携版;验证 SHA256 后加入白名单 |
|
||||
| `wails3 dev` 报错 | Go/Node 版本不符或缺少 WebView2 | 确保 Go 1.21+、Node 18+,安装 WebView2 |
|
||||
|
||||
## 编辑器
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 格式化按钮灰色 | 当前块语言无对应 Prettier parser | 更换语言或安装支持的语言扩展(未来版本) |
|
||||
| 块解析错误 | 分隔符格式不正确 | 确认 `∞∞∞language` 后跟换行;可用自动重建语法树命令 |
|
||||
| 翻译浮层不出现 | 选区过短或超过最大长度 | 在设置 > 扩展 > translator 中调整阈值 |
|
||||
| 小地图不同步 | 编辑器实例未刷新 | 切换文档或重开应用,检查扩展是否被禁用 |
|
||||
|
||||
## 窗口与多实例
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 子窗口未吸附 | WindowSnap 未启用 | 设置 > 通用 → 勾选“窗口吸附” |
|
||||
| 全局热键无效 | 与系统/其他软件冲突 | 在设置 > 通用改用非系统占用组合,比如 `Ctrl+Alt+Space` |
|
||||
| 关闭窗口直接退出 | 未启用托盘模式 | 设置 > 通用 → 启用系统托盘 |
|
||||
|
||||
## HTTP 客户端
|
||||
| 问题 | 可能原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 发送失败,提示 `proxy` | 系统代理配置异常 | 在系统代理或环境变量中设置 HTTP(S)_PROXY,或关闭代理再试 |
|
||||
| 响应乱码 | 服务器未声明编码 | 手动在请求头中加 `accept-charset: utf-8`,或在响应视图切换编码(计划) |
|
||||
| 变量未替换 | 变量名拼写或作用域错误 | 确认 `@var` 定义位置,使用 `{{name}}` 语法 |
|
||||
|
||||
## 数据与备份
|
||||
| 问题 | 可能原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 自动备份停在 “未初始化” | Repo URL/认证缺失 | 补全备份配置或关闭自动备份 |
|
||||
| Push 失败 | Token 权限不足或网络问题 | 为 Token 开启 `repo` scope;检查代理;稍后再试 |
|
||||
| 数据目录迁移后文件缺失 | 未重启或权限不足 | 修改 `dataPath` 后重启应用;确保目标目录可写 |
|
||||
|
||||
## 更新
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 检查更新超时 | 主源不可达 | 切换到备用源或关闭代理重试 |
|
||||
| 下载完成但未重启 | 权限或文件被占用 | 以管理员运行,关闭杀毒软件后重试 |
|
||||
|
||||
## 收集日志
|
||||
- Wails 日志:`%LOCALAPPDATA%/voidraft/logs/*.log`。
|
||||
- 终端调试:运行 `wails3 dev` 并观察控制台输出。
|
||||
- 若提交 Issue,请附上:系统版本、voidraft 版本、日志片段、复现步骤。
|
||||
|
||||
> 仍未解决?请到 GitHub Issues 提交反馈,并尽可能附上截图和日志。
|
||||
46
frontend/docs/src/zh/guide/ui-overview.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 界面总览
|
||||
|
||||

|
||||
> 替换为包含顶部工具栏、块区域、右侧小地图、底部状态栏的完整截图。
|
||||
|
||||
voidraft 的主窗口由四个区域组成:
|
||||
|
||||
| 区域 | 位置 | 作用 | 相关代码 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工具栏 | 顶部浮层 | 文档切换、块语言选择、格式化、Markdown 预览、窗口置顶、更新提示、进入设置 | `frontend/src/components/toolbar/Toolbar.vue` |
|
||||
| 编辑器主体 | 中央 | CodeMirror 6 视图,承载块编辑、HTTP 运行器、翻译按钮等 | `frontend/src/views/editor/Editor.vue` + `extensions` |
|
||||
| 导航辅助 | 右侧 | 小地图、滚动条、块徽标、HTTP 运行按钮 | `extensions/minimap`, `codeblock/decorations.ts` |
|
||||
| 底部状态 | 左下角 | 行数、字符数、选区统计、文档脏状态 | `editorStore.documentStats` |
|
||||
|
||||
## 工具栏详解
|
||||
| 项 | 说明 | 快捷入口 |
|
||||
| --- | --- | --- |
|
||||
| 文档切换器 | 展开后列出全部文档,支持搜索、创建、在新窗口打开 | 同步 `DocumentService.ListAllDocumentsMeta` |
|
||||
| 块语言下拉 | 当前块语言,列表取自 `lang-parser/languages.ts`,支持搜索 | 鼠标选择或输入语言 token |
|
||||
| Pin(窗口置顶) | 临时 / 永久置顶切换,调用 `SystemService.SetWindowOnTop` 与 `config.general.alwaysOnTop` | Alt+Space(自定义) |
|
||||
| Format / Preview | 对当前块执行 Prettier 或打开 Markdown 预览 | `Ctrl+Shift+F` / 工具栏按钮 |
|
||||
| 更新提示 | 轮询 `SelfUpdateService`,有更新时显示小点,可直接“检查/下载/重启” | 设置 > 更新 |
|
||||
| 设置入口 | 跳转到 Vue Router 的 `/settings` 页面 | `Ctrl+,` |
|
||||
|
||||
## 多文档视图
|
||||
- **标签页(可选)**:在设置 > 通用中启用“标签页模式”,`tabStore` 将当前文档加入 tab bar,支持拖拽、批量关闭。
|
||||
- **多窗口**:以文档列表右键「在新窗口中打开」或命令面板为入口。`WindowService` 会根据文档 ID 命名窗口,`WindowSnapService` 自动吸附。
|
||||
- **系统托盘**:关闭窗口时默认最小化到托盘,可在托盘图标中重新唤醒或彻底退出。
|
||||
|
||||
## 面板与浮层
|
||||
- **Markdown 预览**:针对选中的 Markdown 块,面板会贴在右侧,支持实时滚动同步、关闭动画。
|
||||
- **HTTP 响应**:运行后在块底部自动插入 `### Response`,可展开查看头部/体/耗时。
|
||||
- **翻译浮层**:选中文本后自动出现按钮,点击后显示结果卡片,附带复制、语种切换。
|
||||
|
||||
## 快捷状态
|
||||
- **底部统计**:
|
||||
- `Ln`:当前块内行号。
|
||||
- `Ch`:字符数。
|
||||
- `Sel`:选区字符数。
|
||||
- **右上角加载动画**:当编辑器实例加载或切换文档时显示,遵循 `enableLoadingAnimation` 设置。
|
||||
|
||||
## 建议截图
|
||||
1. 默认深色主题 + 多块示例。
|
||||
2. 打开 Markdown 预览 + 小地图。
|
||||
3. 展示 HTTP 块运行按钮与响应卡片。
|
||||
4. 展示标签页或多窗口。
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
鸿蒙字体压缩工具
|
||||
使用 fonttools 库压缩 TTF 字体文件,减小文件大小
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic"):
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return
|
||||
|
||||
# 查找所有字体文件
|
||||
font_files = find_font_files(font_directory)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" ✓ 成功: {format_file_size(original_size)} → {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" ✗ 失败: 输出文件未生成")
|
||||
else:
|
||||
print(f" ✗ 失败: 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("鸿蒙字体压缩工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取当前脚本所在目录
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 设置默认字体目录
|
||||
font_directory = current_dir
|
||||
|
||||
print(f"字体目录: {font_directory}")
|
||||
|
||||
# 让用户选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-ExtraLight.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.otf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/otf/Monocraft-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-ExtraLight.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-Light.woff2
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.ttf
Normal file
BIN
frontend/src/assets/fonts/Monocraft/ttf/Monocraft-SemiBold.woff2
Normal file
179
frontend/src/assets/fonts/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 字体压缩工具使用指南
|
||||
|
||||
## 📖 简介
|
||||
|
||||
`font_compressor.py` 是一个通用的字体压缩工具,可以:
|
||||
- ✅ 将 TTF、OTF、WOFF 字体文件转换为 WOFF2 格式
|
||||
- ✅ 支持相对路径和绝对路径
|
||||
- ✅ 自动生成 CSS 字体定义文件
|
||||
- ✅ 智能识别字体字重和样式
|
||||
- ✅ 批量处理整个目录(包括子目录)
|
||||
|
||||
## 🚀 前置要求
|
||||
|
||||
安装 Python 依赖包:
|
||||
|
||||
```bash
|
||||
pip install fonttools brotli
|
||||
```
|
||||
|
||||
## 📝 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```bash
|
||||
# 进入 fonts 目录
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 交互式模式处理当前目录
|
||||
python font_compressor.py
|
||||
|
||||
# 处理相对路径的 Monocraft 目录
|
||||
python font_compressor.py Monocraft
|
||||
|
||||
# 处理相对路径并指定压缩级别
|
||||
python font_compressor.py Monocraft -l basic
|
||||
```
|
||||
|
||||
### 生成 CSS 文件
|
||||
|
||||
```bash
|
||||
# 压缩 Monocraft 字体并生成 CSS 文件
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 Hack 字体并生成 CSS
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
|
||||
# 压缩 OpenSans 字体并生成 CSS
|
||||
python font_compressor.py OpenSans -l medium -c ../styles/opensans_fonts.css
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
```bash
|
||||
# 使用绝对路径
|
||||
python font_compressor.py E:\Go_WorkSpace\voidraft\frontend\src\assets\fonts\Monocraft -l basic -c monocraft.css
|
||||
|
||||
# 不同压缩级别
|
||||
python font_compressor.py Monocraft -l basic # 基础压缩,保留所有功能
|
||||
python font_compressor.py Monocraft -l medium # 中等压缩,平衡大小和功能
|
||||
python font_compressor.py Monocraft -l aggressive # 激进压缩,最小文件
|
||||
```
|
||||
|
||||
## ⚙️ 命令行参数
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `directory` | 字体目录(相对/绝对路径) | `Monocraft` 或 `/path/to/fonts` |
|
||||
| `-l, --level` | 压缩级别 (basic/medium/aggressive) | `-l basic` |
|
||||
| `-c, --css` | CSS 输出文件路径 | `-c monocraft.css` |
|
||||
| `--version` | 显示版本信息 | `--version` |
|
||||
| `-h, --help` | 显示帮助信息 | `-h` |
|
||||
|
||||
## 📊 压缩级别说明
|
||||
|
||||
### basic(基础) - 推荐
|
||||
- 保留大部分字体功能
|
||||
- 适合网页使用
|
||||
- 压缩率约 30-40%
|
||||
|
||||
### medium(中等)
|
||||
- 移除一些不常用的功能
|
||||
- 平衡文件大小和功能
|
||||
- 压缩率约 40-50%
|
||||
|
||||
### aggressive(激进)
|
||||
- 最大程度减小文件大小
|
||||
- 可能影响高级排版功能
|
||||
- 压缩率约 50-60%
|
||||
|
||||
## 📁 输出结果
|
||||
|
||||
### 字体文件
|
||||
压缩后的 `.woff2` 文件会保存在原文件相同的目录下,例如:
|
||||
- `Monocraft/ttf/Monocraft-Bold.ttf` → `Monocraft/ttf/Monocraft-Bold.woff2`
|
||||
- `Hack/hack-regular.ttf` → `Hack/hack-regular.woff2`
|
||||
|
||||
### CSS 文件
|
||||
生成的 CSS 文件会包含:
|
||||
- 自动识别的字体家族名称
|
||||
- 正确的字重和样式设置
|
||||
- 使用相对路径的字体引用
|
||||
- 按字重排序的 `@font-face` 定义
|
||||
|
||||
生成的 CSS 示例:
|
||||
|
||||
```css
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 实际使用示例
|
||||
|
||||
### 示例 1: 压缩 Monocraft 字体
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
```
|
||||
|
||||
这将:
|
||||
1. 扫描 `Monocraft/ttf` 和 `Monocraft/otf` 目录
|
||||
2. 将所有字体文件转换为 WOFF2
|
||||
3. 在 `frontend/src/assets/styles/monocraft_fonts.css` 生成 CSS 文件
|
||||
|
||||
### 示例 2: 批量处理多个字体目录
|
||||
|
||||
```bash
|
||||
cd frontend/src/assets/fonts
|
||||
|
||||
# 压缩 Monocraft
|
||||
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
|
||||
|
||||
# 压缩 OpenSans
|
||||
python font_compressor.py OpenSans -l basic -c ../styles/opensans_fonts.css
|
||||
|
||||
# 压缩 Hack(已有 CSS,只需生成新版本对比)
|
||||
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
|
||||
```
|
||||
|
||||
## 🔍 字体信息自动识别
|
||||
|
||||
工具会自动从文件名识别:
|
||||
- **字重**:Thin(100), Light(300), Regular(400), Medium(500), SemiBold(600), Bold(700), Black(900)
|
||||
- **样式**:normal, italic
|
||||
- **字体家族**:自动去除字重和样式后缀
|
||||
|
||||
支持的命名格式:
|
||||
- `FontName-Bold.ttf`
|
||||
- `FontName_Bold.otf`
|
||||
- `FontName-BoldItalic.ttf`
|
||||
- `FontName_SemiBold_Italic.woff`
|
||||
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
```bash
|
||||
python font_compressor.py --help
|
||||
```
|
||||
|
||||
494
frontend/src/assets/fonts/font_compressor.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用字体压缩工具
|
||||
使用 fonttools 库将字体文件转换为 WOFF2 格式,减小文件大小
|
||||
支持 TTF、OTF、WOFF 等格式的字体文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
def check_dependencies():
|
||||
"""检查必要的依赖是否已安装"""
|
||||
missing_packages = []
|
||||
|
||||
# 检查 fonttools
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
missing_packages.append('fonttools')
|
||||
|
||||
# 检查 brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
missing_packages.append('brotli')
|
||||
|
||||
# 检查 pyftsubset 命令是否可用
|
||||
try:
|
||||
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
except FileNotFoundError:
|
||||
if 'fonttools' not in missing_packages:
|
||||
missing_packages.append('fonttools[subset]')
|
||||
|
||||
if missing_packages:
|
||||
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
|
||||
print("请运行以下命令安装:")
|
||||
print(f"pip install {' '.join(missing_packages)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""获取文件大小(字节)"""
|
||||
return os.path.getsize(file_path)
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小显示"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
|
||||
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
|
||||
"""
|
||||
压缩单个字体文件
|
||||
|
||||
Args:
|
||||
input_path: 输入字体文件路径
|
||||
output_path: 输出字体文件路径
|
||||
compression_level: 压缩级别 ("basic", "medium", "aggressive")
|
||||
|
||||
Returns:
|
||||
bool: 压缩是否成功
|
||||
"""
|
||||
try:
|
||||
# 基础压缩参数
|
||||
base_args = [
|
||||
"pyftsubset", input_path,
|
||||
"--output-file=" + output_path,
|
||||
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
|
||||
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
|
||||
]
|
||||
|
||||
# 根据压缩级别设置不同的参数
|
||||
if compression_level == "basic":
|
||||
# 基础压缩:保留常用字符和功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
|
||||
"--layout-features=*", # 保留所有布局特性
|
||||
"--glyph-names", # 保留字形名称
|
||||
"--symbol-cmap", # 保留符号映射
|
||||
"--legacy-cmap", # 保留传统字符映射
|
||||
"--notdef-glyph", # 保留 .notdef 字形
|
||||
"--recommended-glyphs", # 保留推荐字形
|
||||
"--name-IDs=*", # 保留所有名称ID
|
||||
"--name-legacy", # 保留传统名称
|
||||
]
|
||||
elif compression_level == "medium":
|
||||
# 中等压缩:移除一些不常用的功能
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
|
||||
"--layout-features=kern,liga,clig", # 只保留关键布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
|
||||
]
|
||||
else: # aggressive
|
||||
# 激进压缩:最大程度减小文件大小
|
||||
args = base_args + [
|
||||
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
|
||||
"--no-layout-features", # 移除所有布局特性
|
||||
"--no-glyph-names", # 移除字形名称
|
||||
"--no-symbol-cmap", # 移除符号映射
|
||||
"--no-legacy-cmap", # 移除传统映射
|
||||
"--notdef-glyph",
|
||||
"--name-IDs=1,2", # 只保留最基本的名称
|
||||
"--desubroutinize", # 去子程序化(可能减小CFF字体大小)
|
||||
]
|
||||
|
||||
# 执行压缩命令
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
print(f"压缩失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"压缩过程中出现错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def find_font_files(directory: str, exclude_woff2: bool = False) -> List[str]:
|
||||
"""查找目录中的所有字体文件"""
|
||||
if exclude_woff2:
|
||||
font_extensions = ['.ttf', '.otf', '.woff']
|
||||
else:
|
||||
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
|
||||
font_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.lower().endswith(ext) for ext in font_extensions):
|
||||
font_files.append(os.path.join(root, file))
|
||||
|
||||
return font_files
|
||||
|
||||
def parse_font_info(filename: str) -> Dict[str, any]:
|
||||
"""
|
||||
从字体文件名解析字体信息(字重、样式等)
|
||||
|
||||
Args:
|
||||
filename: 字体文件名(不含路径)
|
||||
|
||||
Returns:
|
||||
包含字体信息的字典
|
||||
"""
|
||||
# 移除扩展名
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# 字重映射
|
||||
weight_mapping = {
|
||||
'thin': (100, 'Thin'),
|
||||
'extralight': (200, 'ExtraLight'),
|
||||
'light': (300, 'Light'),
|
||||
'regular': (400, 'Regular'),
|
||||
'normal': (400, 'Regular'),
|
||||
'medium': (500, 'Medium'),
|
||||
'semibold': (600, 'SemiBold'),
|
||||
'bold': (700, 'Bold'),
|
||||
'extrabold': (800, 'ExtraBold'),
|
||||
'black': (900, 'Black'),
|
||||
'heavy': (900, 'Heavy'),
|
||||
}
|
||||
|
||||
# 默认值
|
||||
font_weight = 400
|
||||
font_style = 'normal'
|
||||
weight_name = 'Regular'
|
||||
|
||||
# 检查是否为斜体
|
||||
if re.search(r'italic', name_without_ext, re.IGNORECASE):
|
||||
font_style = 'italic'
|
||||
|
||||
# 检查字重
|
||||
name_lower = name_without_ext.lower()
|
||||
for weight_key, (weight_value, weight_label) in weight_mapping.items():
|
||||
if weight_key in name_lower:
|
||||
font_weight = weight_value
|
||||
weight_name = weight_label
|
||||
break
|
||||
|
||||
# 提取字体家族名称(移除字重和样式后缀)
|
||||
family_name = name_without_ext
|
||||
for weight_key, (_, weight_label) in weight_mapping.items():
|
||||
family_name = re.sub(r'[-_]?' + weight_label, '', family_name, flags=re.IGNORECASE)
|
||||
family_name = re.sub(r'[-_]?italic', '', family_name, flags=re.IGNORECASE)
|
||||
family_name = family_name.strip('-_')
|
||||
|
||||
return {
|
||||
'family': family_name,
|
||||
'weight': font_weight,
|
||||
'style': font_style,
|
||||
'weight_name': weight_name,
|
||||
'full_name': name_without_ext
|
||||
}
|
||||
|
||||
def generate_css(font_files: List[str], output_css_path: str, css_base_path: str):
|
||||
"""
|
||||
生成CSS字体文件
|
||||
|
||||
Args:
|
||||
font_files: 字体文件路径列表(woff2文件)
|
||||
output_css_path: 输出CSS文件路径
|
||||
css_base_path: CSS文件相对于字体文件的基础路径
|
||||
"""
|
||||
# 按字体家族分组
|
||||
font_groups: Dict[str, List[Dict]] = {}
|
||||
|
||||
for font_file in font_files:
|
||||
if not font_file.endswith('.woff2'):
|
||||
continue
|
||||
|
||||
filename = os.path.basename(font_file)
|
||||
font_info = parse_font_info(filename)
|
||||
|
||||
# 计算相对路径
|
||||
font_dir = os.path.dirname(font_file)
|
||||
css_dir = os.path.dirname(output_css_path)
|
||||
|
||||
try:
|
||||
# 计算从CSS文件到字体文件的相对路径
|
||||
rel_path = os.path.relpath(font_file, css_dir)
|
||||
# 统一使用正斜杠(适用于Web)
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
except ValueError:
|
||||
# 如果在不同驱动器上,使用绝对路径
|
||||
rel_path = font_file.replace('\\', '/')
|
||||
|
||||
font_info['path'] = rel_path
|
||||
|
||||
family = font_info['family']
|
||||
if family not in font_groups:
|
||||
font_groups[family] = []
|
||||
font_groups[family].append(font_info)
|
||||
|
||||
# 生成CSS内容
|
||||
css_lines = ['/* 自动生成的字体文件 */', '/* 由 font_compressor.py 生成 */', '']
|
||||
|
||||
for family, fonts in sorted(font_groups.items()):
|
||||
css_lines.append(f'/* {family} 字体家族 */')
|
||||
css_lines.append('')
|
||||
|
||||
# 按字重排序
|
||||
fonts.sort(key=lambda x: (x['weight'], x['style']))
|
||||
|
||||
for font in fonts:
|
||||
css_lines.append(f"/* {family} {font['weight_name']}{' Italic' if font['style'] == 'italic' else ''} */")
|
||||
css_lines.append('@font-face {')
|
||||
css_lines.append(f" font-family: '{family}';")
|
||||
css_lines.append(f" src: url('{font['path']}') format('woff2');")
|
||||
css_lines.append(f" font-weight: {font['weight']};")
|
||||
css_lines.append(f" font-style: {font['style']};")
|
||||
css_lines.append(' font-display: swap;')
|
||||
css_lines.append('}')
|
||||
css_lines.append('')
|
||||
|
||||
# 写入CSS文件
|
||||
with open(output_css_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(css_lines))
|
||||
|
||||
print(f"[OK] CSS文件已生成: {output_css_path}")
|
||||
print(f" 包含 {sum(len(fonts) for fonts in font_groups.values())} 个字体定义")
|
||||
print(f" 字体家族: {', '.join(sorted(font_groups.keys()))}")
|
||||
|
||||
def compress_fonts_batch(font_directory: str, compression_level: str = "basic") -> List[str]:
|
||||
"""
|
||||
批量压缩字体文件
|
||||
|
||||
Args:
|
||||
font_directory: 字体文件目录
|
||||
compression_level: 压缩级别
|
||||
|
||||
Returns:
|
||||
生成的woff2文件路径列表
|
||||
"""
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"错误: 目录 {font_directory} 不存在")
|
||||
return []
|
||||
|
||||
# 查找所有字体文件(排除已经是woff2的)
|
||||
font_files = find_font_files(font_directory, exclude_woff2=True)
|
||||
|
||||
if not font_files:
|
||||
print("未找到字体文件")
|
||||
return []
|
||||
|
||||
print(f"找到 {len(font_files)} 个字体文件")
|
||||
print(f"压缩级别: {compression_level}")
|
||||
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
|
||||
print("-" * 60)
|
||||
|
||||
total_original_size = 0
|
||||
total_compressed_size = 0
|
||||
successful_compressions = 0
|
||||
generated_woff2_files = []
|
||||
|
||||
for i, font_file in enumerate(font_files, 1):
|
||||
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
|
||||
|
||||
# 获取原始文件大小
|
||||
original_size = get_file_size(font_file)
|
||||
total_original_size += original_size
|
||||
|
||||
# 生成输出文件名(保持原文件名,只改变扩展名)
|
||||
file_dir = os.path.dirname(font_file)
|
||||
base_name = os.path.splitext(os.path.basename(font_file))[0]
|
||||
output_file = os.path.join(file_dir, f"{base_name}.woff2")
|
||||
|
||||
# 压缩字体
|
||||
if compress_font(font_file, output_file, compression_level):
|
||||
if os.path.exists(output_file):
|
||||
compressed_size = get_file_size(output_file)
|
||||
total_compressed_size += compressed_size
|
||||
successful_compressions += 1
|
||||
generated_woff2_files.append(output_file)
|
||||
|
||||
# 计算压缩率
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
print(f" [OK] 成功: {format_file_size(original_size)} -> {format_file_size(compressed_size)} "
|
||||
f"(压缩 {compression_ratio:.1f}%)")
|
||||
else:
|
||||
print(f" [失败] 输出文件未生成")
|
||||
else:
|
||||
print(f" [失败] 压缩过程出错")
|
||||
|
||||
print()
|
||||
|
||||
# 显示总结
|
||||
print("=" * 60)
|
||||
print("压缩完成!")
|
||||
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
|
||||
|
||||
if successful_compressions > 0:
|
||||
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
|
||||
print(f"总大小: {format_file_size(total_original_size)} → {format_file_size(total_compressed_size)}")
|
||||
print(f"总压缩率: {total_compression_ratio:.1f}%")
|
||||
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
|
||||
|
||||
return generated_woff2_files
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(
|
||||
description='通用字体压缩工具 - 将字体文件转换为 WOFF2 格式并生成CSS',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
使用示例:
|
||||
%(prog)s # 交互式模式,处理当前目录
|
||||
%(prog)s Monocraft # 处理相对路径目录
|
||||
%(prog)s Monocraft -l basic # 使用基础压缩级别
|
||||
%(prog)s Monocraft -l basic -c monocraft.css # 压缩并生成CSS文件
|
||||
%(prog)s /path/to/fonts -l medium -c fonts.css # 使用绝对路径
|
||||
|
||||
压缩级别说明:
|
||||
basic - 基础压缩:保留大部分功能,适合网页使用
|
||||
medium - 中等压缩:平衡文件大小和功能
|
||||
aggressive - 激进压缩:最小文件大小,可能影响显示效果
|
||||
|
||||
CSS生成说明:
|
||||
使用 -c/--css 选项生成CSS文件,自动使用相对路径引用字体文件
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='字体文件目录路径(支持相对/绝对路径,默认为当前脚本所在目录)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-l', '--level',
|
||||
choices=['basic', 'medium', 'aggressive'],
|
||||
default=None,
|
||||
help='压缩级别:basic(基础)、medium(中等)、aggressive(激进)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--css',
|
||||
default=None,
|
||||
help='生成CSS文件路径(相对于脚本位置或绝对路径)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
action='version',
|
||||
version='%(prog)s 2.0'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("通用字体压缩工具 v2.0")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查依赖
|
||||
if not check_dependencies():
|
||||
return
|
||||
|
||||
# 获取脚本所在目录
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 确定字体目录
|
||||
if args.directory:
|
||||
# 支持相对路径和绝对路径
|
||||
if os.path.isabs(args.directory):
|
||||
font_directory = args.directory
|
||||
else:
|
||||
font_directory = os.path.join(script_dir, args.directory)
|
||||
font_directory = os.path.abspath(font_directory)
|
||||
else:
|
||||
# 默认使用当前脚本所在目录
|
||||
font_directory = script_dir
|
||||
|
||||
# 检查目录是否存在
|
||||
if not os.path.exists(font_directory):
|
||||
print(f"\n错误: 目录不存在: {font_directory}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n字体目录: {font_directory}")
|
||||
|
||||
# 确定压缩级别
|
||||
compression_level = args.level
|
||||
|
||||
if compression_level is None:
|
||||
# 交互式选择压缩级别
|
||||
print("\n请选择压缩级别:")
|
||||
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
|
||||
print("2. 中等压缩 (平衡文件大小和功能)")
|
||||
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
|
||||
|
||||
while True:
|
||||
choice = input("\n请输入选择 (1-3): ").strip()
|
||||
if choice == "1":
|
||||
compression_level = "basic"
|
||||
break
|
||||
elif choice == "2":
|
||||
compression_level = "medium"
|
||||
break
|
||||
elif choice == "3":
|
||||
compression_level = "aggressive"
|
||||
break
|
||||
else:
|
||||
print("无效选择,请输入 1、2 或 3")
|
||||
|
||||
# 开始批量压缩
|
||||
print()
|
||||
generated_files = compress_fonts_batch(font_directory, compression_level=compression_level)
|
||||
|
||||
# 生成CSS文件
|
||||
if args.css and generated_files:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("生成CSS文件...")
|
||||
print("=" * 60)
|
||||
|
||||
# 确定CSS输出路径
|
||||
if os.path.isabs(args.css):
|
||||
css_path = args.css
|
||||
else:
|
||||
css_path = os.path.join(script_dir, args.css)
|
||||
css_path = os.path.abspath(css_path)
|
||||
|
||||
# 确保输出目录存在
|
||||
css_dir = os.path.dirname(css_path)
|
||||
if css_dir and not os.path.exists(css_dir):
|
||||
os.makedirs(css_dir)
|
||||
|
||||
# 生成CSS
|
||||
generate_css(generated_files, css_path, script_dir)
|
||||
elif args.css and not generated_files:
|
||||
print("\n警告: 没有成功生成WOFF2文件,跳过CSS生成")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("全部完成!")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,8 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'scrollbar.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
202
frontend/src/assets/styles/monocraft_fonts.css
Normal file
@@ -0,0 +1,202 @@
|
||||
/* 自动生成的字体文件 */
|
||||
/* 由 font_compressor.py 生成 */
|
||||
|
||||
/* Monocraft 字体家族 */
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft ExtraLight */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light-Italic.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Light */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Regular Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Italic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold-Italic.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold-Italic.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Bold */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black Italic */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black-Italic.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/otf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Monocraft Black */
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url('../fonts/Monocraft/ttf/Monocraft-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const FONT_OPTIONS = [
|
||||
label: 'Open Sans',
|
||||
value: '"Open Sans"'
|
||||
},
|
||||
{
|
||||
label: 'Monocraft',
|
||||
value: 'Monocraft'
|
||||
},
|
||||
// Common system fonts
|
||||
{
|
||||
label: 'Arial',
|
||||
@@ -46,7 +50,7 @@ export const FONT_OPTIONS = [
|
||||
label: 'System UI',
|
||||
value: 'system-ui'
|
||||
},
|
||||
|
||||
|
||||
// Chinese fonts
|
||||
{
|
||||
label: 'Microsoft YaHei',
|
||||
@@ -56,7 +60,7 @@ export const FONT_OPTIONS = [
|
||||
label: 'PingFang SC',
|
||||
value: '"PingFang SC"'
|
||||
},
|
||||
|
||||
|
||||
// Popular programming fonts
|
||||
{
|
||||
label: 'JetBrains Mono',
|
||||
|
||||
56
frontend/src/views/editor/extensions/codeblock/annotation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Annotation, Transaction } from "@codemirror/state";
|
||||
|
||||
/**
|
||||
* 统一的 CodeBlock 注解,用于标记内部触发的事务。
|
||||
*/
|
||||
export const codeBlockEvent = Annotation.define<string>();
|
||||
|
||||
export const LANGUAGE_CHANGE = "codeblock-language-change";
|
||||
export const ADD_NEW_BLOCK = "codeblock-add-new-block";
|
||||
export const MOVE_BLOCK = "codeblock-move-block";
|
||||
export const DELETE_BLOCK = "codeblock-delete-block";
|
||||
export const CURRENCIES_LOADED = "codeblock-currencies-loaded";
|
||||
export const CONTENT_EDIT = "codeblock-content-edit";
|
||||
|
||||
/**
|
||||
* 统一管理的 userEvent 常量。
|
||||
*/
|
||||
export const USER_EVENTS = {
|
||||
INPUT: "input",
|
||||
DELETE: "delete",
|
||||
MOVE: "move",
|
||||
SELECT: "select",
|
||||
DELETE_LINE: "delete.line",
|
||||
DELETE_CUT: "delete.cut",
|
||||
INPUT_PASTE: "input.paste",
|
||||
MOVE_LINE: "move.line",
|
||||
MOVE_CHARACTER: "move.character",
|
||||
SELECT_BLOCK_BOUNDARY: "select.block-boundary",
|
||||
INPUT_REPLACE: "input.replace",
|
||||
INPUT_REPLACE_ALL: "input.replace.all",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 判断事务列表中是否包含指定注解。
|
||||
*/
|
||||
export function transactionsHasAnnotation(
|
||||
transactions: readonly Transaction[],
|
||||
annotation: string
|
||||
) {
|
||||
return transactions.some(
|
||||
tr => tr.annotation(codeBlockEvent) === annotation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断事务列表中是否包含任一注解。
|
||||
*/
|
||||
export function transactionsHasAnnotationsAny(
|
||||
transactions: readonly Transaction[],
|
||||
annotations: readonly string[]
|
||||
) {
|
||||
return transactions.some(tr => {
|
||||
const value = tr.annotation(codeBlockEvent);
|
||||
return value ? annotations.includes(value) : false;
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
* Block 命令
|
||||
*/
|
||||
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
|
||||
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
|
||||
import { formatBlockContent } from "./formatCode";
|
||||
import { codeBlockEvent, LANGUAGE_CHANGE, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK, CURRENCIES_LOADED, USER_EVENTS } from "./annotation";
|
||||
|
||||
/**
|
||||
* 获取块分隔符
|
||||
@@ -32,7 +33,7 @@ export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ st
|
||||
|
||||
dispatch(state.replaceSelection(delimText), {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -49,15 +50,16 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({
|
||||
|
||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||
|
||||
dispatch(state.update({
|
||||
dispatch(state.update({
|
||||
changes: {
|
||||
from: block.delimiter.from,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -74,15 +76,16 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s
|
||||
|
||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||
|
||||
dispatch(state.update({
|
||||
dispatch(state.update({
|
||||
changes: {
|
||||
from: block.content.to,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length)
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -99,15 +102,16 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st
|
||||
|
||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||
|
||||
dispatch(state.update({
|
||||
dispatch(state.update({
|
||||
changes: {
|
||||
from: block.delimiter.from,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -124,15 +128,16 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
||||
|
||||
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
|
||||
|
||||
dispatch(state.update({
|
||||
dispatch(state.update({
|
||||
changes: {
|
||||
from: block.content.to,
|
||||
insert: delimText,
|
||||
},
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length)
|
||||
selection: EditorSelection.cursor(block.content.to + delimText.length),
|
||||
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -143,26 +148,19 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
|
||||
*/
|
||||
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
|
||||
if (state.readOnly) return false;
|
||||
|
||||
const currentDelimiter = state.doc.sliceString(block.delimiter.from, block.delimiter.to);
|
||||
|
||||
// 重置正则表达式的 lastIndex
|
||||
DELIMITER_REGEX.lastIndex = 0;
|
||||
if (currentDelimiter.match(DELIMITER_REGEX)) {
|
||||
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
||||
|
||||
dispatch({
|
||||
changes: {
|
||||
from: block.delimiter.from,
|
||||
to: block.delimiter.to,
|
||||
insert: newDelimiter,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
|
||||
|
||||
dispatch({
|
||||
changes: {
|
||||
from: block.delimiter.from,
|
||||
to: block.delimiter.to,
|
||||
insert: newDelimiter,
|
||||
},
|
||||
annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)],
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +187,7 @@ function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelecti
|
||||
}
|
||||
|
||||
function setSel(state: any, selection: EditorSelection) {
|
||||
return state.update({ selection, scrollIntoView: true, userEvent: "select" });
|
||||
return state.update({ selection, scrollIntoView: true, userEvent: USER_EVENTS.SELECT });
|
||||
}
|
||||
|
||||
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
|
||||
@@ -303,10 +301,11 @@ export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispa
|
||||
to: block.range.to,
|
||||
insert: ""
|
||||
},
|
||||
selection: EditorSelection.cursor(newCursorPos)
|
||||
selection: EditorSelection.cursor(newCursorPos),
|
||||
annotations: [codeBlockEvent.of(DELETE_BLOCK)]
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete"
|
||||
userEvent: USER_EVENTS.DELETE
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -366,10 +365,11 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
||||
|
||||
dispatch(state.update({
|
||||
changes,
|
||||
selection: EditorSelection.cursor(newCursorPos)
|
||||
selection: EditorSelection.cursor(newCursorPos),
|
||||
annotations: [codeBlockEvent.of(MOVE_BLOCK)]
|
||||
}, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "move"
|
||||
userEvent: USER_EVENTS.MOVE
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -380,4 +380,21 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
|
||||
*/
|
||||
export const formatCurrentBlock: Command = (view) => {
|
||||
return formatBlockContent(view);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 触发一次货币数据刷新,让数学块重新计算
|
||||
*/
|
||||
export function triggerCurrenciesLoaded({ state, dispatch }: { state: any; dispatch: any }) {
|
||||
if (!dispatch || state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
dispatch(state.update({
|
||||
changes: { from: 0, to: 0, insert: "" },
|
||||
annotations: [
|
||||
codeBlockEvent.of(CURRENCIES_LOADED),
|
||||
Transaction.addToHistory.of(false)
|
||||
],
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorState, EditorSelection } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { LANGUAGES } from "./lang-parser/languages";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 构建块分隔符正则表达式
|
||||
@@ -89,7 +90,8 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
view.dispatch({
|
||||
changes: ranges,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.cut"
|
||||
userEvent: USER_EVENTS.DELETE_CUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,8 @@ const copyCut = (view: EditorView, cut: boolean): boolean => {
|
||||
view.dispatch({
|
||||
changes: ranges,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.cut"
|
||||
userEvent: USER_EVENTS.DELETE_CUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,8 +145,9 @@ function doPaste(view: EditorView, input: string) {
|
||||
}
|
||||
|
||||
view.dispatch(changes, {
|
||||
userEvent: "input.paste",
|
||||
scrollIntoView: true
|
||||
userEvent: USER_EVENTS.INPUT_PASTE,
|
||||
scrollIntoView: true,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,4 +190,4 @@ export function getCopyPasteExtensions() {
|
||||
return [
|
||||
codeBlockCopyCut,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorView } from '@codemirror/view';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { blockState } from './state';
|
||||
import { Block } from './types';
|
||||
import { USER_EVENTS } from './annotation';
|
||||
|
||||
/**
|
||||
* 二分查找:找到包含指定位置的块
|
||||
@@ -136,7 +137,7 @@ export function createCursorProtection() {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(adjustedPos),
|
||||
scrollIntoView: true,
|
||||
userEvent: 'select'
|
||||
userEvent: USER_EVENTS.SELECT
|
||||
});
|
||||
|
||||
// 阻止默认行为
|
||||
@@ -148,4 +149,3 @@ export function createCursorProtection() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
|
||||
import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view";
|
||||
import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state";
|
||||
import { StateField, RangeSetBuilder, EditorState, Transaction } from "@codemirror/state";
|
||||
import { blockState } from "./state";
|
||||
import { codeBlockEvent, USER_EVENTS } from "./annotation";
|
||||
|
||||
/**
|
||||
* 块开始装饰组件
|
||||
@@ -180,10 +181,11 @@ const blockLayer = layer({
|
||||
*/
|
||||
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
|
||||
const protect: number[] = [];
|
||||
const internalEvent = tr.annotation(codeBlockEvent);
|
||||
|
||||
// 获取块状态并获取第一个块的分隔符大小
|
||||
const blocks = tr.startState.field(blockState);
|
||||
if (blocks && blocks.length > 0) {
|
||||
if (!internalEvent && blocks && blocks.length > 0) {
|
||||
const firstBlock = blocks[0];
|
||||
const firstBlockDelimiterSize = firstBlock.delimiter.to;
|
||||
|
||||
@@ -194,23 +196,27 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any)
|
||||
}
|
||||
|
||||
// 如果是搜索替换操作,保护所有块分隔符
|
||||
if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) {
|
||||
blocks.forEach((block: any) => {
|
||||
const userEvent = tr.annotation(Transaction.userEvent);
|
||||
if (userEvent && (userEvent === USER_EVENTS.INPUT_REPLACE || userEvent === USER_EVENTS.INPUT_REPLACE_ALL)) {
|
||||
blocks?.forEach((block: any) => {
|
||||
if (block.delimiter) {
|
||||
protect.push(block.delimiter.from, block.delimiter.to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 返回保护范围数组,如果没有需要保护的范围则返回 false
|
||||
return protect.length > 0 ? protect : false;
|
||||
});
|
||||
// 返回保护范围数组;若无需保护则返回 true 放行事务
|
||||
return protect.length > 0 ? protect : true;
|
||||
})
|
||||
|
||||
/**
|
||||
* 防止选择在第一个块之前
|
||||
* 使用 transactionFilter 来确保选择不会在第一个块之前
|
||||
*/
|
||||
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
|
||||
if (tr.annotation(codeBlockEvent)) {
|
||||
return tr;
|
||||
}
|
||||
// 获取块状态并获取第一个块的分隔符大小
|
||||
const blocks = tr.startState.field(blockState);
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -261,4 +267,4 @@ export function getBlockDecorationExtensions(options: {
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
import { USER_EVENTS } from "./annotation";
|
||||
|
||||
interface LineBlock {
|
||||
from: number;
|
||||
@@ -87,7 +89,8 @@ export const deleteLine = (view: EditorView): boolean => {
|
||||
changes,
|
||||
selection,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.line"
|
||||
userEvent: USER_EVENTS.DELETE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -127,8 +130,9 @@ export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: a
|
||||
changes,
|
||||
selection,
|
||||
scrollIntoView: true,
|
||||
userEvent: "delete.line"
|
||||
userEvent: USER_EVENTS.DELETE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as prettier from "prettier/standalone";
|
||||
import { getActiveNoteBlock } from "./state";
|
||||
import { getLanguage } from "./lang-parser/languages";
|
||||
import { SupportedLanguage } from "./types";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
export const formatBlockContent = (view) => {
|
||||
if (!view || view.state.readOnly)
|
||||
@@ -87,7 +88,8 @@ export const formatBlockContent = (view) => {
|
||||
},
|
||||
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input"
|
||||
userEvent: USER_EVENTS.INPUT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -100,4 +102,4 @@ export const formatBlockContent = (view) => {
|
||||
// 执行异步格式化
|
||||
performFormat();
|
||||
return true; // 立即返回 true,表示命令已开始执行
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { transactionsHasAnnotation, CURRENCIES_LOADED } from "./annotation";
|
||||
|
||||
type MathParserEntry = {
|
||||
parser: any;
|
||||
prev?: any;
|
||||
};
|
||||
// 声明全局math对象
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -62,8 +68,7 @@ class MathResult extends WidgetType {
|
||||
/**
|
||||
* 数学装饰函数
|
||||
*/
|
||||
function mathDeco(view: any): any {
|
||||
const mathParsers = new WeakMap();
|
||||
function mathDeco(view: any, parserCache: WeakMap<any, MathParserEntry>): any {
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
@@ -72,12 +77,17 @@ function mathDeco(view: any): any {
|
||||
const block = getNoteBlockFromPos(view.state, pos);
|
||||
|
||||
if (block && block.language.name === "math") {
|
||||
// get math.js parser and cache it for this block
|
||||
let { parser, prev } = mathParsers.get(block) || {};
|
||||
let entry = parserCache.get(block);
|
||||
let parser = entry?.parser;
|
||||
if (!parser) {
|
||||
if (line.from > block.content.from) {
|
||||
pos = block.content.from;
|
||||
continue;
|
||||
}
|
||||
if (typeof window.math !== 'undefined') {
|
||||
parser = window.math.parser();
|
||||
mathParsers.set(block, { parser, prev });
|
||||
entry = { parser, prev: undefined };
|
||||
parserCache.set(block, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +95,15 @@ function mathDeco(view: any): any {
|
||||
let result: any;
|
||||
try {
|
||||
if (parser) {
|
||||
parser.set("prev", prev);
|
||||
if (entry && line.from === block.content.from && typeof parser.clear === "function") {
|
||||
parser.clear();
|
||||
entry.prev = undefined;
|
||||
}
|
||||
const prevValue = entry?.prev;
|
||||
parser.set("prev", prevValue);
|
||||
result = parser.evaluate(line.text);
|
||||
if (result !== undefined) {
|
||||
mathParsers.set(block, { parser, prev: result });
|
||||
if (entry && result !== undefined) {
|
||||
entry.prev = result;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -97,7 +112,7 @@ function mathDeco(view: any): any {
|
||||
|
||||
// if we got a result from math.js, add the result decoration
|
||||
if (result !== undefined) {
|
||||
const format = parser?.get("format");
|
||||
const format = parser?.get?.("format");
|
||||
|
||||
let resultWidget: MathResult | undefined;
|
||||
if (typeof(result) === "string") {
|
||||
@@ -142,15 +157,25 @@ function mathDeco(view: any): any {
|
||||
*/
|
||||
export const mathBlock = ViewPlugin.fromClass(class {
|
||||
decorations: any;
|
||||
mathParsers: WeakMap<any, MathParserEntry>;
|
||||
|
||||
constructor(view: any) {
|
||||
this.decorations = mathDeco(view);
|
||||
this.mathParsers = new WeakMap();
|
||||
this.decorations = mathDeco(view, this.mathParsers);
|
||||
}
|
||||
|
||||
update(update: any) {
|
||||
// If the document changed, the viewport changed, update the decorations
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = mathDeco(update.view);
|
||||
const hasCurrencyUpdate = transactionsHasAnnotation(update.transactions, CURRENCIES_LOADED);
|
||||
if (update.docChanged || hasCurrencyUpdate) {
|
||||
// 文档结构或汇率变化时重置解析缓存
|
||||
this.mathParsers = new WeakMap();
|
||||
}
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
hasCurrencyUpdate
|
||||
) {
|
||||
this.decorations = mathDeco(update.view, this.mathParsers);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { EditorSelection, SelectionRange } from "@codemirror/state";
|
||||
import { blockState } from "./state";
|
||||
import { LANGUAGES } from "./lang-parser/languages";
|
||||
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
import { USER_EVENTS } from "./annotation";
|
||||
|
||||
interface LineBlock {
|
||||
from: number;
|
||||
@@ -131,7 +133,8 @@ function moveLine(state: any, dispatch: any, forward: boolean): boolean {
|
||||
changes,
|
||||
scrollIntoView: true,
|
||||
selection: EditorSelection.create(ranges, state.selection.mainIndex),
|
||||
userEvent: "move.line"
|
||||
userEvent: USER_EVENTS.MOVE_LINE,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -157,4 +160,4 @@ export const moveLineUp = ({ state, dispatch }: { state: any; dispatch: any }):
|
||||
*/
|
||||
export const moveLineDown = ({ state, dispatch }: { state: any; dispatch: any }): boolean => {
|
||||
return moveLine(state, dispatch, true);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
|
||||
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language';
|
||||
import type { Tree } from '@lezer/common';
|
||||
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
|
||||
import {
|
||||
SupportedLanguage,
|
||||
@@ -15,51 +16,47 @@ import {
|
||||
} from './types';
|
||||
import { LANGUAGES } from './lang-parser/languages';
|
||||
|
||||
const DEFAULT_LANGUAGE = (LANGUAGES[0]?.token || 'text') as string;
|
||||
|
||||
/**
|
||||
* 从语法树解析代码块
|
||||
*/
|
||||
export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
||||
if (!syntaxTreeAvailable(state)) {
|
||||
const tree = syntaxTree(state);
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
return collectBlocksFromTree(tree, state);
|
||||
}
|
||||
|
||||
const tree = syntaxTree(state);
|
||||
function collectBlocksFromTree(tree: Tree, state: EditorState): Block[] | null {
|
||||
const blocks: Block[] = [];
|
||||
const doc = state.doc;
|
||||
|
||||
// 遍历语法树中的所有块
|
||||
tree.iterate({
|
||||
enter(node) {
|
||||
if (node.type.id === BlockNode) {
|
||||
// 查找块的分隔符和内容
|
||||
let delimiter: { from: number; to: number } | null = null;
|
||||
let content: { from: number; to: number } | null = null;
|
||||
let language = 'text';
|
||||
let language: string = DEFAULT_LANGUAGE;
|
||||
let auto = false;
|
||||
|
||||
// 遍历块的子节点
|
||||
const blockNode = node.node;
|
||||
blockNode.firstChild?.cursor().iterate(child => {
|
||||
if (child.type.id === BlockDelimiter) {
|
||||
delimiter = { from: child.from, to: child.to };
|
||||
|
||||
// 解析整个分隔符文本来获取语言和自动检测标记
|
||||
const delimiterText = doc.sliceString(child.from, child.to);
|
||||
|
||||
// 使用正则表达式解析分隔符
|
||||
const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/);
|
||||
if (match) {
|
||||
language = match[1] || 'text';
|
||||
language = match[1] || DEFAULT_LANGUAGE;
|
||||
auto = match[2] === '-a';
|
||||
} else {
|
||||
// 回退到逐个解析子节点
|
||||
child.node.firstChild?.cursor().iterate(langChild => {
|
||||
if (langChild.type.id === BlockLanguage) {
|
||||
const langText = doc.sliceString(langChild.from, langChild.to);
|
||||
language = langText || 'text';
|
||||
language = langText || DEFAULT_LANGUAGE;
|
||||
}
|
||||
// 检查是否有自动检测标记
|
||||
if (doc.sliceString(langChild.from, langChild.to) === '-a') {
|
||||
if (doc.sliceString(langChild.from, langChild.to) === AUTO_DETECT_SUFFIX) {
|
||||
auto = true;
|
||||
}
|
||||
});
|
||||
@@ -88,7 +85,6 @@ export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
|
||||
});
|
||||
|
||||
if (blocks.length > 0) {
|
||||
// 设置第一个块分隔符的大小
|
||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||
return blocks;
|
||||
}
|
||||
@@ -104,203 +100,78 @@ export let firstBlockDelimiterSize: number | undefined;
|
||||
*/
|
||||
export function getBlocksFromString(state: EditorState): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
const doc = state.doc;
|
||||
const doc = state.doc;
|
||||
|
||||
if (doc.length === 0) {
|
||||
// 如果文档为空,创建一个默认的文本块
|
||||
return [{
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
}];
|
||||
}
|
||||
return [createPlainTextBlock(0, 0)];
|
||||
}
|
||||
|
||||
const content = doc.sliceString(0, doc.length);
|
||||
const delim = "\n∞∞∞";
|
||||
let pos = 0;
|
||||
const delimiter = DELIMITER_PREFIX;
|
||||
const suffixLength = DELIMITER_SUFFIX.length;
|
||||
|
||||
// 检查文档是否以分隔符开始(不带前导换行符)
|
||||
if (content.startsWith("∞∞∞")) {
|
||||
// 文档直接以分隔符开始,调整为标准格式
|
||||
pos = 0;
|
||||
} else if (content.startsWith("\n∞∞∞")) {
|
||||
// 文档以换行符+分隔符开始,这是标准格式,从位置0开始解析
|
||||
pos = 0;
|
||||
} else {
|
||||
// 如果文档不以分隔符开始,查找第一个分隔符
|
||||
const firstDelimPos = content.indexOf(delim);
|
||||
|
||||
if (firstDelimPos === -1) {
|
||||
// 如果没有找到分隔符,整个文档作为一个文本块
|
||||
firstBlockDelimiterSize = 0;
|
||||
return [{
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
}];
|
||||
}
|
||||
let pos = content.indexOf(delimiter);
|
||||
|
||||
if (pos === -1) {
|
||||
firstBlockDelimiterSize = 0;
|
||||
return [createPlainTextBlock(0, doc.length)];
|
||||
}
|
||||
|
||||
if (pos > 0) {
|
||||
blocks.push(createPlainTextBlock(0, pos));
|
||||
}
|
||||
|
||||
while (pos !== -1 && pos < doc.length) {
|
||||
const blockStart = pos;
|
||||
const langStart = blockStart + delimiter.length;
|
||||
const delimiterEnd = content.indexOf(DELIMITER_SUFFIX, langStart);
|
||||
if (delimiterEnd === -1) break;
|
||||
|
||||
const delimiterText = content.slice(blockStart, delimiterEnd + suffixLength);
|
||||
const delimiterInfo = parseDelimiter(delimiterText);
|
||||
if (!delimiterInfo) break;
|
||||
|
||||
const contentStart = delimiterEnd + suffixLength;
|
||||
const nextDelimiter = content.indexOf(delimiter, contentStart);
|
||||
const contentEnd = nextDelimiter === -1 ? doc.length : nextDelimiter;
|
||||
|
||||
// 创建第一个块(分隔符之前的内容)
|
||||
blocks.push({
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: firstDelimPos,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: firstDelimPos,
|
||||
},
|
||||
language: { name: delimiterInfo.language, auto: delimiterInfo.auto },
|
||||
content: { from: contentStart, to: contentEnd },
|
||||
delimiter: { from: blockStart, to: delimiterEnd + suffixLength },
|
||||
range: { from: blockStart, to: contentEnd },
|
||||
});
|
||||
|
||||
pos = firstDelimPos;
|
||||
firstBlockDelimiterSize = 0;
|
||||
|
||||
pos = nextDelimiter;
|
||||
}
|
||||
|
||||
while (pos < doc.length) {
|
||||
let blockStart: number;
|
||||
|
||||
if (pos === 0 && content.startsWith("∞∞∞")) {
|
||||
// 处理文档开头直接是分隔符的情况(不带前导换行符)
|
||||
blockStart = 0;
|
||||
} else if (pos === 0 && content.startsWith("\n∞∞∞")) {
|
||||
// 处理文档开头是换行符+分隔符的情况(标准格式)
|
||||
blockStart = 0;
|
||||
} else {
|
||||
blockStart = content.indexOf(delim, pos);
|
||||
if (blockStart !== pos) {
|
||||
// 如果在当前位置没有找到分隔符,可能是文档结尾
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 确定语言开始位置
|
||||
let langStart: number;
|
||||
if (pos === 0 && content.startsWith("∞∞∞")) {
|
||||
// 文档直接以分隔符开始,跳过 ∞∞∞
|
||||
langStart = blockStart + 3;
|
||||
} else {
|
||||
// 标准情况,跳过 \n∞∞∞
|
||||
langStart = blockStart + delim.length;
|
||||
}
|
||||
|
||||
const delimiterEnd = content.indexOf("\n", langStart);
|
||||
if (delimiterEnd < 0) {
|
||||
console.error("Error parsing blocks. Delimiter didn't end with newline");
|
||||
break;
|
||||
}
|
||||
|
||||
const langFull = content.substring(langStart, delimiterEnd);
|
||||
let auto = false;
|
||||
let lang = langFull;
|
||||
|
||||
if (langFull.endsWith("-a")) {
|
||||
auto = true;
|
||||
lang = langFull.substring(0, langFull.length - 2);
|
||||
}
|
||||
|
||||
const contentFrom = delimiterEnd + 1;
|
||||
let blockEnd = content.indexOf(delim, contentFrom);
|
||||
if (blockEnd < 0) {
|
||||
blockEnd = doc.length;
|
||||
}
|
||||
|
||||
const block: Block = {
|
||||
language: {
|
||||
name: lang || 'text',
|
||||
auto: auto,
|
||||
},
|
||||
content: {
|
||||
from: contentFrom,
|
||||
to: blockEnd,
|
||||
},
|
||||
delimiter: {
|
||||
from: blockStart,
|
||||
to: delimiterEnd + 1,
|
||||
},
|
||||
range: {
|
||||
from: blockStart,
|
||||
to: blockEnd,
|
||||
},
|
||||
};
|
||||
|
||||
blocks.push(block);
|
||||
pos = blockEnd;
|
||||
}
|
||||
|
||||
// 如果没有找到任何块,创建一个默认块
|
||||
|
||||
if (blocks.length === 0) {
|
||||
blocks.push({
|
||||
language: {
|
||||
name: 'text',
|
||||
auto: false,
|
||||
},
|
||||
content: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
delimiter: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
range: {
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
},
|
||||
});
|
||||
blocks.push(createPlainTextBlock(0, doc.length));
|
||||
firstBlockDelimiterSize = 0;
|
||||
} else {
|
||||
// 设置第一个块分隔符的大小
|
||||
firstBlockDelimiterSize = blocks[0].delimiter.to;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档中的所有块
|
||||
*/
|
||||
export function getBlocks(state: EditorState): Block[] {
|
||||
// 优先使用语法树解析
|
||||
const syntaxTreeBlocks = getBlocksFromSyntaxTree(state);
|
||||
if (syntaxTreeBlocks) {
|
||||
return syntaxTreeBlocks;
|
||||
let blocks = getBlocksFromSyntaxTree(state);
|
||||
if (blocks) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const ensuredTree = ensureSyntaxTree(state, state.doc.length, 200);
|
||||
if (ensuredTree) {
|
||||
blocks = collectBlocksFromTree(ensuredTree, state);
|
||||
if (blocks) {
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果语法树不可用,回退到字符串解析
|
||||
return getBlocksFromString(state);
|
||||
}
|
||||
|
||||
@@ -396,10 +267,21 @@ export function parseDelimiter(delimiterText: string): { language: SupportedLang
|
||||
|
||||
const validLanguage = LANGUAGES.some(lang => lang.token === languageName)
|
||||
? languageName as SupportedLanguage
|
||||
: 'text';
|
||||
: DEFAULT_LANGUAGE as SupportedLanguage;
|
||||
|
||||
return {
|
||||
language: validLanguage,
|
||||
auto: isAuto
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createPlainTextBlock(from: number, to: number): Block {
|
||||
return {
|
||||
language: { name: DEFAULT_LANGUAGE, auto: false },
|
||||
content: { from, to },
|
||||
delimiter: { from: 0, to: 0 },
|
||||
range: { from, to },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StateField, StateEffect, RangeSetBuilder, EditorSelection, EditorState,
|
||||
import { selectAll as defaultSelectAll } from "@codemirror/commands";
|
||||
import { Command } from "@codemirror/view";
|
||||
import { getActiveNoteBlock, blockState } from "./state";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
|
||||
@@ -115,7 +116,8 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
||||
// 选择当前块的所有内容
|
||||
dispatch(state.update({
|
||||
selection: { anchor: block.content.from, head: block.content.to },
|
||||
userEvent: "select"
|
||||
userEvent: USER_EVENTS.SELECT,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
@@ -127,7 +129,7 @@ export const selectAll: Command = ({ state, dispatch }) => {
|
||||
*/
|
||||
export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => {
|
||||
// 只处理选择变化的事务,并且忽略我们自己生成的事务
|
||||
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === "select.block-boundary") {
|
||||
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === USER_EVENTS.SELECT_BLOCK_BOUNDARY) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) =>
|
||||
return {
|
||||
...tr,
|
||||
selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex),
|
||||
annotations: tr.annotations.concat(Transaction.userEvent.of("select.block-boundary"))
|
||||
annotations: tr.annotations.concat(Transaction.userEvent.of(USER_EVENTS.SELECT_BLOCK_BOUNDARY))
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -219,4 +221,4 @@ export function getBlockSelectExtensions() {
|
||||
return [
|
||||
emptyBlockSelected,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { EditorSelection, findClusterBreak } from "@codemirror/state";
|
||||
import { getNoteBlockFromPos } from "./state";
|
||||
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
|
||||
|
||||
/**
|
||||
* 交换光标前后的字符
|
||||
@@ -46,8 +47,9 @@ export const transposeChars = ({ state, dispatch }: { state: any; dispatch: any
|
||||
|
||||
dispatch(state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: "move.character"
|
||||
userEvent: USER_EVENTS.MOVE_CHARACTER,
|
||||
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -319,7 +319,7 @@ const handlePickerClose = () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 1000px;
|
||||
//max-width: 1000px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
|
||||
@@ -192,7 +192,7 @@ const selectSshKeyFile = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 800px;
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
// 统一的输入控件样式
|
||||
|
||||
@@ -222,7 +222,7 @@ const handleAutoSaveDelayChange = async (event: Event) => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 800px;
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
.number-control {
|
||||
|
||||
@@ -265,7 +265,7 @@ const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOp
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 1000px;
|
||||
//max-width: 1000px;
|
||||
}
|
||||
|
||||
.extension-item {
|
||||
|
||||
@@ -440,7 +440,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 800px;
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
.hotkey-selector {
|
||||
|
||||
@@ -232,7 +232,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 800px;
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
.key-bindings-container {
|
||||
|
||||
@@ -176,7 +176,7 @@ const clearAll = async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
padding: 20px 0 20px 0;
|
||||
//padding: 20px 0 20px 0;
|
||||
}
|
||||
|
||||
.dev-description {
|
||||
|
||||
@@ -151,8 +151,7 @@ const currentVersion = computed(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
max-width: 800px;
|
||||
width: 100%; // 确保在小屏幕上也能占满可用空间
|
||||
//max-width: 800px;
|
||||
}
|
||||
|
||||
.check-button {
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION=1.5.3
|
||||
VERSION=1.5.4
|
||||
|
||||