Perf/frontend chunk loading优化前端构建加载,完善 Markdown 预设、警告框、章节折叠与外部文件刷新 #32

Merged
Kronecker merged 5 commits from perf/frontend-chunk-loading into main 2026-09-06 13:42:58 +08:00
65 changed files with 1892 additions and 64 deletions
+1
View File
@@ -116,6 +116,7 @@ class NoteUpdateRequest(Contract):
title: str | None = None
markdown: str | None = None
tags: list[str] | None = None
expected_content_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
class NoteMoveRequest(Contract):
+3 -2
View File
@@ -225,7 +225,7 @@ async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
async def get_workspace_tree() -> list[WorkspaceEntry]:
return workspace_service.get_workspace_tree()
return await workspace_service.refresh_workspace_tree()
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
@@ -286,7 +286,8 @@ async def get_note(note_id: str) -> Note:
@router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
return await note_service.update_note(
note_id, title=request.title, markdown=request.markdown, tags=request.tags, defer_vectors=True
note_id, title=request.title, markdown=request.markdown, tags=request.tags,
expected_content_hash=request.expected_content_hash, defer_vectors=True
)
@@ -105,6 +105,14 @@ def get_workspace_tree() -> list[WorkspaceEntry]:
return _tree(get_settings().vault_path.resolve(), locations)
async def refresh_workspace_tree() -> list[WorkspaceEntry]:
"""Observe external creates/deletes without waiting for vector inference."""
if get_workspace_info().requires_refresh:
await _register_workspace_files()
index_service.schedule_workspace_rebuild()
return get_workspace_tree()
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。"""
@@ -16,3 +16,5 @@ tags: RAG, 产品
粗排后使用 Reranker 对候选块重新打分,提升相关性。
<br />
@@ -2,20 +2,20 @@
title: 功能演示导航
tags: 演示, 入门
---
# 功能演示导航
这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。
## 建议阅读顺序
| 笔记 | 可以查看的功能 |
| --- | --- |
| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 |
| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 |
| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 |
| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 |
| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 |
| 笔记 | 可以查看的功能 |
| ----------------------------- | ---------------------- |
| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 |
| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 |
| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 |
| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 |
| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 |
| [06 警告框与提示框](06%20警告框与提示框.md) | 类型与别名、标题、折叠、嵌套和主题配色 |
## 工作区操作
@@ -35,3 +35,4 @@ tags: 演示, 入门
- [ ] 在已配置模型后进行一次带知识库检索的问答。
> 上述清单供体验时自行勾选,不是自动验收结果。模型调用可能产生费用,图表与代码示例本身不会执行代码。
@@ -2,7 +2,6 @@
title: 星灯资料站项目简报
tags: 演示, 星灯项目, 检索
---
# 星灯资料站
星灯资料站是本组演示中的虚构项目,目标是为一个读书小组建立离线可用的学习资料目录。项目代号为 ST-27。
@@ -38,3 +37,4 @@ tags: 演示, 星灯项目, 检索
最后一个问题在本笔记中没有答案。检查回答是否说明资料不足,而不是编造负责人。其他问题可以对照正文并点击引用定位核实。
> 新建笔记需要完成索引后才能参与检索。没有模型配置时,也可以先在搜索页使用项目名、代号或独特检索词查找原文。
@@ -0,0 +1,150 @@
---
title: 警告框与提示框演示
tags: 演示, Markdown, 警告框, 主题
---
# 警告框与提示框
本页展示 GitHub 警告框和 Obsidian 提示框的类型、标题、折叠、嵌套及正文格式。打开工作区写作模式查看效果;切换源码模式查看原始语法。
## 五种常用警告框
> [!NOTE]
> 记录补充信息:这份笔记中的内容都是功能演示,不会执行代码或调用模型。
> [!TIP] 小技巧:快速插入
> 点击编辑器顶部的“提示框”选择器,选择类型后替换模板内容。
> [!IMPORTANT] 保存与显示状态
> 点击标题展开或收起,只改变本次显示状态。要修改默认状态,请在源码中的类型标记后添加 `+` 或 `-`。
> [!WARNING] 修改前保留原文
> 在演示笔记中练习时,可以先复制一段内容;需要恢复时使用撤销。
> [!CAUTION] 需要重点关注的说明
> `CAUTION` 与 `WARNING` 使用同一警告配色。提示框是笔记内容,不是应用报错弹窗。
## 更多类型
> [!ABSTRACT] 本页摘要
> 类型区分语义,标题说明重点,正文保留详细信息。
> [!INFO] 环境信息
> 警告框的边框、标题和背景随主题变化。
> [!TODO] 待办
> - [ ] 展开下方折叠示例。
> - [ ] 切换深色主题。
> - [ ] 保存后重新打开本页。
> [!SUCCESS] 已完成
> 本段展示成功状态,不代表自动测试或实际任务已经完成。
> [!QUESTION] 可以嵌套吗?
> 可以。增加一级引用符号即可在提示框中嵌入另一个提示框。
> [!FAILURE] 未达到预期
> 示例:资料中缺少日期,需要补充后再归档。
> [!DANGER] 风险提示
> 示例:不要把唯一一份原始资料直接覆盖为整理结果。
> [!BUG] 问题记录
> 示例:发现显示异常时,记录主题、操作步骤和对应 Markdown 源码。
> [!EXAMPLE] 示例
> 将提示内容写成一句明确的说明,比只写“注意”更容易理解。
> [!QUOTE] 摘录
> 一条笔记既要保留结论,也要保留形成结论的依据。
## 默认展开与默认折叠
> [!TIP]+ 默认展开:点击标题试试
> 类型后的 `+` 表示默认展开。点击标题可收起,再次点击可展开。
> [!WARNING]- 默认折叠:点击查看内容
> 你已经展开了这段说明。类型后的 `-` 表示重新渲染时默认收起。
>
> 正文可以包含 **加粗**、*斜体*、~~删除线~~ 和 `行内代码`。
## 嵌套与混合格式
> [!INFO]+ 一次资料整理
> 先整理来源,再检查缺漏。
>
> 1. 收集原始资料。
> 2. 按主题分组。
> 3. 为尚未确认的内容添加说明。
>
> > [!SUCCESS] 已收集
> > 原始笔记、会议纪要和参考链接已放入同一文件夹。
>
> > [!WARNING]- 尚待确认
> > 一条资料缺少发布日期,需要补充来源。
>
> | 项目 | 状态 |
> | --- | --- |
> | 原始资料 | 已归档 |
> | 日期核对 | 待补充 |
>
> ```python
> notes = ["原始资料", "整理结果"]
> print(len(notes))
> ```
>
> 行内公式:$a^2 + b^2 = c^2$。
## 类型别名
别名不区分大小写。下面的表格列出兼容关系。
| 类型 | 别名 |
| --- | --- |
| abstract | summary、tldr |
| tip | hint |
| success | check、done |
| question | help、faq |
| warning | caution、attention |
| failure | fail、missing |
| danger | error |
| quote | cite |
> [!summary] 摘要别名
> 这段使用 `summary`,外观与 `abstract` 一致。
> [!check] 成功别名
> 这段使用 `check`,外观与 `success` 一致。
> [!custom-demo] 未知类型的回退
> 自定义类型暂时使用 note 外观,源文件中的类型名仍然保留。
## 语法对照
以下围栏中的内容应当保持为代码,不渲染成警告框。
```markdown
> [!NOTE] 自定义标题
> 正文内容。
> [!WARNING]- 默认折叠
> 点击标题查看正文。
> [!TIP]+ 默认展开
> 默认可见的正文。
```
普通行内代码也保持原样:`[!WARNING]`
> 这是一段普通引用,没有提示类型标记,因此不应显示为警告框。
## 主题与保存体验清单
- [ ] 在浅色、深色、护眼主题下区分信息、成功、警告与危险颜色。
- [ ] 使用纸间时光,查看纸张虚线边框和嵌套层次。
- [ ] 使用 Ocean Blue 与 Midnight Purple,检查标题和正文是否清晰。
- [ ] 点击折叠标题,并使用 Tab、Enter 或空格体验键盘操作。
- [ ] 在源码模式修改一个类型或标题,再切回写作模式。
- [ ] 保存并重新打开,确认类型、标题、正文与默认折叠状态保持一致。
这是一份手动体验清单,未勾选不表示功能失败。桌面容器的原生格式快捷键与元数据转换仍属于第三阶段规划。
+31
View File
@@ -114,3 +114,34 @@ def test_workspace_openapi_paths_are_published() -> None:
"/api/workspace/folders/delete",
"/api/notes/{note_id}/rename",
} <= paths.keys()
def test_external_files_are_registered_and_removed_without_vector_wait(monkeypatch) -> None:
from app.services import index_service
scheduled = []
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: scheduled.append(True))
vault = get_settings().vault_path
vault.mkdir(parents=True, exist_ok=True)
external = vault / 'external.md'
external.write_text('# External\n', encoding='utf-8')
tree = asyncio.run(get_workspace_tree())
assert tree[0].note_id is not None
external.rename(vault / 'renamed.md')
tree = asyncio.run(get_workspace_tree())
assert [item.name for item in tree] == ['renamed.md']
(vault / 'renamed.md').unlink()
assert asyncio.run(get_workspace_tree()) == []
assert len(scheduled) == 3
def test_save_rejects_external_content_change() -> None:
import hashlib
from app.contracts import NoteUpdateRequest
from app.routes import update_note
original = '# Original\n'
note = asyncio.run(create_note(NoteCreateRequest(title='Conflict', markdown=original)))
disk = get_settings().vault_path / note.file_path
disk.write_text('# External\n', encoding='utf-8')
with pytest.raises(ApiError) as error:
asyncio.run(update_note(note.note_id, NoteUpdateRequest(markdown='# Editor\n', expected_content_hash=hashlib.sha256(original.encode()).hexdigest())))
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert disk.read_text(encoding='utf-8') == '# External\n'
+6
View File
@@ -34,11 +34,15 @@
## development:开发说明
- [前端构建分块优化开发说明](development/前端构建分块优化开发说明.md)
- [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md)
- [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md)
- [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md)
- [模型上下文管理](development/模型上下文管理.md)
- [Markdown 渲染检查](development/Markdown渲染检查.md)
- [警告框与桌面编辑命令开发说明](development/警告框与桌面编辑命令开发说明.md)
- [标题折叠与样式开发说明](development/标题折叠与样式开发说明.md)
- [主题组件覆盖检查](development/主题组件覆盖检查.md)
- [第二阶段补充验收工具](development/第二阶段补充验收工具.md)
@@ -89,3 +93,5 @@
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
- [Markdown 语法预设与外部文件刷新](development/Markdown语法预设与外部文件刷新.md)
@@ -21,7 +21,9 @@
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
建议稳定的前端命令标识为 `editor.import-note-properties`仅为设计标识,尚未注册为 Tauri IPC。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
稳定的前端命令标识为 `editor.import-note-properties`已进入前端 v1 命令目录,但属性转换处理器与 Tauri IPC 尚未实现。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
### 2.2 输入与转换规则
+3 -1
View File
@@ -30,7 +30,9 @@
| 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 |
| 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 |
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
2026-09-06 补充:GitHub alerts 与 Obsidian callout 已在工作区和静态预览接入,包含常用类型/别名、自定义标题、嵌套与折叠。语法、命令接口与验证方法见 [警告框与桌面编辑命令开发说明](警告框与桌面编辑命令开发说明.md)。
自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。
@@ -0,0 +1,48 @@
# Markdown 语法预设与外部文件刷新
日期:2026-09-06。
## 1. 设置入口与范围
“设置 → 编辑器 → Markdown 语法预设”管理语法与编辑行为,独立于“标题样式”的字号、字体和字重设置。
支持 ATX/Setext 标题、无序列表标记、有序列表递增、代码围栏、裸链接识别、数学公式、警告框、Mermaid、代码行号、自动换行、缩进和工具栏新建代码块的默认语言。Setext 只作用于 H1/H2。提供扩展、GitHub 和基础三组内置配置,支持最多 20 个命名自定义预设,同名保存替换旧配置。
配置保存在本机 localStorage 的 `markdown-preferences`,读取时校验。静态预览即时应用;写作编辑器在下次打开时应用,避免切换设置重建正在编辑的文档。写作模式保存会统一整篇正文的语法标记,源码模式保留手写语法。关闭扩展后对应内容按普通 Markdown/代码展示。
本次不是完整复制 Typora:未提供上下标、高亮、智能标点、physics 包及导出公式选项。基础配置仍支持现有 GFM 表格等功能。
## 2. 主题与折叠
六个主题共用语义颜色和表单控件。内置浅色、深色、护眼更新为 1.3.0;纸间时光 1.8.0Ocean Blue 1.5.0Midnight Purple 2.3.0。社区预览增加语法控件和标题折叠样本。
标题箭头使用统一 CSS 形状,默认隐藏,悬停标题或键盘聚焦按钮时显示;无悬停能力的触摸设备保持可见。用户自定义标题外观独立于主题配色。
## 3. 外部文件刷新与保存保护
Web 当前采用串行后台轮询:前一次完成后间隔两秒,在窗口聚焦时也检查。隐藏页面暂停读取,卸载移除监听。此机制不是原生文件事件监听;第三阶段可由 Tauri 文件事件替代。
`GET /workspace/tree` 检查磁盘新增、删除和重命名,为新文件登记元数据及全文索引,向量任务继续后台执行。前端保留文件夹展开状态,并丢弃过期请求响应。读取失败保留旧树并显示重试入口。
当前打开且未修改的文件检测到外部正文变化后更新编辑器;存在本地编辑时保留缓冲区,停止自动保存并提示冲突。重新加载磁盘版本需要用户确认。原文件删除或移动后,隐藏不可用的重新加载入口,提供下载当前 Markdown 副本和确认关闭笔记;取消关闭或确认期间正文改变时保留缓冲区。关闭后可重新选择其他文件,不再阻塞导航。`PATCH /notes/{note_id}` 新增可选 `expected_content_hash`(原始正文 UTF-8 SHA-256,64 位小写十六进制),保存前校验,不匹配返回 `NOTE_CONTENT_CONFLICT`,防止覆盖外部修改。
范围限制:现有文件的外部正文修改会刷新当前编辑器,但本轮目录检查不会据此重建其搜索索引;可通过重建索引同步检索内容。
## 4. 验证方法
```powershell
cd frontend
npm run test
npm run build
cd ..
backend/.venv/Scripts/python.exe -m pytest backend/tests/test_workspace.py backend/tests/test_workspace_background.py -q -p no:cacheprovider
```
手工验证:
1. 保存并重新应用命名预设,刷新后确认保留;重新打开笔记,检查 Setext、列表和代码围栏的源码。
2. 切换六个主题,在社区预览和工作区检查控件、警告框、标题箭头;移出标题后箭头隐藏,Tab 聚焦仍可操作。
3. 在系统文件管理器新增、重命名、删除 Markdown 文件,保持前端可见,确认树自动更新且目录展开状态保留。
4. 分别在正文未修改、有未保存编辑时从外部修改同一文件,验证自动加载与冲突保护;拒绝重新加载应保留编辑内容。
本次自动验证覆盖预设持久化、解析选项隔离、写作语法输出、标题折叠、树刷新竞态、外部文件登记及保存冲突。
@@ -0,0 +1,43 @@
# 前端构建分块优化开发说明
> 更新日期:2026-09-06。基于 main 的 64af068 进行构建对比;以下结果为本地生产构建,不是网络耗时或性能评分。
## 当前方案
- Mermaid 由静态导入改成第一次渲染或校验图表时动态导入。复用加载 Promise,加载失败清除缓存以允许重试,主题初始化与渲染继续串行执行。
- CodeMirror 基础模块、ProseMirror 和 Milkdown 分组缓存。仅显式选中的模块进入手动分组,不吸收所有传递依赖。
- CodeMirror 语言解析器、全部 Shiki 语法和 Mermaid 图型继续按需加载。不能将所有语言打入 editor vendor,否则会反而增加编辑器的首次加载量。
- KaTeX 按实际安装版本分组,避免合并项目版本和依赖内版本。不强制升级第三方依赖的数学解析器。
- 启用 Vite manifest,并提供 build:report 脚本用于持续比较入口静态依赖。
## 构建观察
数值按十进制 kB 计算。静态闭包包括入口 JS 与递归 imports,去重后求和;不包含 CSS、字体、运行时动态导入或浏览器缓存。gzip 为每个 JS 文件压缩后求和,不代表服务器必然启用该压缩。
| 检查项 | 优化前 | 优化后 |
| --- | --- | --- |
| 聊天页静态 JS 闭包 | 约 1563.5 kB | 约 894 kB |
| 聊天页闭包 gzip | 约 451.6 kB | 约 292 kB |
| VisualMarkdownEditor 单包 | 约 1145.8 kB | 约 20 kB,核心依赖转入独立块 |
| 首屏静态 JS 闭包 | 约 409.7 kB | 约 409 kB,基本不变 |
组件单包缩小不等于整个编辑器只需要 20 kB。编辑器仍需要加载基础框架、核心依赖和实际用到的语法。此次主要减少普通 Markdown 页对 Mermaid 的提前加载,并改善模块缓存边界;未测量真实启动耗时,不能声称首屏提速比例。
## 验证方法
在 frontend 目录执行:
```powershell
pnpm build
pnpm build:report
pnpm test
pnpm exec vite preview --host 127.0.0.1 --port 4173
```
build:report 读取 dist/.vite/manifest.json,输出入口闭包大小与最大的 15 个 JS 块。对比时保存相同构建环境的两份输出;更新依赖后需重新测量。
本轮前端 345 项测试通过,类型检查及生产构建通过。已在生产预览打开工作区和真实 Mermaid 示例笔记,确认编辑器与 SVG 加载,未发现控制台 error。Shiki 全语言覆盖仍由既有语言测试检查。
## 保留的大块与边界
C++、Emacs Lisp 等语法、Oniguruma WASM、部分 Mermaid 图型及 Mermaid 核心仍可能超过 500 kB。这些资源保留按需加载,不裁减语言支持,也不提高警告阈值来隐藏问题。首次打开复杂图表或对应语言仍有加载成本;后续可基于真实请求和设备数据评估 Worker、资源预热及库版本升级。
@@ -0,0 +1,52 @@
# 标题折叠与样式开发说明
日期:2026-09-06。范围为工作区写作模式的章节折叠,以及正文标题外观偏好。
## 1. 章节边界与交互
H1–H6 标题旁在悬停时显示统一折叠箭头;键盘聚焦也显示,触摸设备保持可见。章节从标题之后开始,结束于同一容器内下一个同级或更高级标题;末尾没有后续内容的标题不显示按钮。引用等容器中的标题只影响所在容器,不折叠外部正文。
- 工具栏使用单个按钮:有可见章节展开时显示“全部折叠”,否则显示“全部展开”。父章节隐藏的子章节不影响按钮判断,其自身折叠状态仍保留。无可折叠章节时按钮禁用。
- 折叠父章节不会清空子章节的折叠状态。
- 折叠时若选区在将隐藏的正文中,光标先移到标题。
- 从大纲、查找或键盘跳到隐藏内容时,展开包含目标的章节,避免隐藏光标。
- 折叠只影响当前编辑器视图,不修改 Markdown,不触发文档脏状态,也不占用撤销历史。重开文件恢复展开;源码模式与静态预览不进行章节折叠。
实现位于 `headingFolding.ts`:插件状态保存标题位置,通过事务映射跟随文档编辑;删除或改成正文的标题会从状态中清理。Decoration 隐藏完整块,widget 提供可聚焦的折叠按钮。章节范围按标题栈计算,并按不可变文档缓存;隐藏范围合并后遍历节点,避免每个节点重复扫描全部标题。
## 2. 标题样式设置
入口为“设置 → 编辑器 → 标题样式”,主题页“编辑器外观”中也提供同一组件。
- 默认跟随当前主题,不覆盖主题字号、字体与粗细。
- 启用自定义后,分别调整 H1–H6 字号(12–72 px)和字重(400–800 的五个档位)。
- 标题字体可跟随正文,或使用系统衬线、无衬线、等宽字体。
- 面板即时预览,工作区正文和静态 Markdown 预览使用同一偏好。
- 不影响笔记属性栏的标题、侧栏大纲字号或页面标题,不改写源文件中的标题级别。
- “恢复跟随主题”清除自定义覆盖;主题页“恢复默认”也重置标题设置。
偏好由 `headingAppearance` store 保存到本机 `editor-heading-appearance`。读取时校验字体枚举、字重与字号范围;应用 CSS 前再次规范化,避免空输入、无效存储或异常大数影响布局。刷新后恢复设置,切换主题保留自定义偏好。用户显式启用的覆盖只作用于 Markdown 标题,优先于主题规则;颜色继续跟随主题。
## 3. 桌面命令预留
既有 `editorCommandService` v1 增加三个可执行 ID
| 命令 | 行为 |
| --- | --- |
| editor.heading.toggle-fold | 切换选区所在章节的折叠状态 |
| editor.heading.fold-all | 折叠所有有内容的章节 |
| editor.heading.unfold-all | 展开所有章节 |
均不需要参数,沿用活动文档、模式与冲突检查。原生快捷键仍由第三阶段容器绑定,此处不注册系统级快捷键。
## 4. 验证
```sh
cd frontend
npm run test -- src/features/editor/headingFolding.spec.ts src/features/editor/VisualMarkdownEditor.spec.ts src/features/editor/HeadingStyleSettings.spec.ts src/stores/headingAppearance.spec.ts
npm run build
```
自动检查覆盖章节边界、嵌套容器、隐藏选区迁移、父子折叠状态、大纲目标展开、序列化保持、设置保存恢复、输入校验和面板恢复默认。
手动检查:打开“功能演示 / 01 Markdown 与大纲”,依次折叠 H2 与 H1,再从大纲跳转;确认隐藏内容重新显示。打开设置调整 H1 字号与 H2 粗细,返回笔记检查外观,刷新后检查偏好仍在;关闭自定义后依次切换六个主题核对主题原有样式。
@@ -0,0 +1,90 @@
# 警告框与桌面编辑命令开发说明
日期:2026-09-06。范围为前端渲染与命令边界;不包含 Tauri IPC、原生菜单或系统级快捷键注册。
## 1. 格式与渲染
使用引用块语法。GitHub 的 NOTE、TIP、IMPORTANT、WARNING、CAUTION 均可渲染;同时支持 Obsidian 的常用类型、别名、标题、嵌套与折叠。
```markdown
> [!WARNING]- 自定义标题
> 提示正文,支持 **粗体**、`行内代码`、列表等 Markdown。
>
> > [!TIP]+ 嵌套提示
> > 展开内容
```
`+` / `-` 时不可折叠;`+` 默认展开,`-` 默认折叠。点击折叠只改变本次显示状态,不自动改写源文件中的默认状态。标题作为文本显示,不执行 HTML。未知类型使用 note 外观并保留类型名。
| 规范类型 | 兼容别名 |
| --- | --- |
| note | — |
| abstract | summary、tldr |
| info、todo | — |
| tip | hint |
| important | — |
| success | check、done |
| question | help、faq |
| warning | caution、attention |
| failure | fail、missing |
| danger | error |
| bug、example | — |
| quote | cite |
工作区顶部“提示框”选择器可插入模板。编辑器保留原生 blockquote 文档节点,以 NodeView 展示标题与折叠按钮,装饰隐藏标记;选区进入标记时显示原文以便修改。序列化仅取消引用首行提示标记的转义,避免保存后退回普通引用。代码中的标记不转换。写作模式会规范化 Markdown 转义;需要永久展示字面标记时使用行内代码或围栏代码。
静态预览使用 marked 的 blockquote renderer,折叠使用原生 details / summary,内容仍经过 DOMPurify。两个入口共享 `callouts.ts``callouts.css`;颜色继承主题的 info、warning、success、error、surface 和 text 变量,不另存固定浅色配色。因此已有主题和符合主题规范的导入主题均可继承。
## 2. 桌面命令边界 v1
入口:`frontend/src/services/editorCommandService.ts`
- `editorCommandVersion`:当前版本 1。
- `getEditorCommandCapabilities()`:返回每个稳定命令 ID 的 supported 与 enabled。预留 ID 不等于已经实现。
- `executeEditorCommand(id, params?)`:返回 `{ ok: true }``{ ok: false, reason }`。reason 为 unsupported、unavailable、invalid-params、failed。
- `registerEditorCommands(target)`:由活动编辑器注册处理器,返回注销函数。旧组件注销不清除替代组件的注册。
成功表示处理器接受并执行了命令;具体格式操作仍遵循编辑器的选区规则。命令不直接写磁盘,变更进入既有脏状态、撤销与自动保存链路。无活动文件、源码模式、只读、冲突、编辑器加载中或文件已切换时禁用当前处理器。
| 已接入 ID(统一加 editor. 前缀) | params |
| --- | --- |
| bold、italic、ordered-list、bullet-list、inline-code、code-block、inline-math、math-block、paragraph | 无 |
| heading | 整数 16 |
| font-size | 有限数值 896,单位 px;作用于选区 |
| insert-markdown | 非空 Markdown 字符串,最多 100000 字符 |
| callout | `{ type, title?, body?, fold? }`;fold 为空串、+ 或 -;标题不可换行 |
目录还预留删除线、任务列表、引用、Mermaid、链接、图片、表格、分隔线、硬换行、引用式链接、HTML、撤销/重做,以及 `editor.import-note-properties``editor.metadata.edit``editor.metadata.title``editor.metadata.tags`。这些单独的处理器尚未接入,返回 unsupported;现阶段复杂格式可通过 insert-markdown 插入。元数据不得通过普通正文插入接口冒充属性导入。
第三阶段由 Host 将原生菜单/快捷键映射到上述 ID。快捷键表需支持平台差异与用户改键,过滤表单、输入法组合与弹窗焦点;不要重复绑定浏览器和 Milkdown 已有按键。宿主只能调用允许的命令,不执行任意脚本。元数据处理器须按已有桌面需求完成无损 YAML 合并、版本检查、冲突提示及属性/正文一并撤销后才可标记 supported。
## 3. 验证方法
在 frontend 运行:
```sh
npm run test -- src/utils/callouts.spec.ts src/services/editorCommandService.spec.ts src/features/editor/VisualMarkdownEditor.spec.ts
npm run build
```
- callouts:逐个类型与别名、大小写、嵌套、默认折叠、空正文、未知类型、标题注入、普通引用和代码排除。
- 编辑器:初始解析、直接输入标记、按钮折叠、序列化往返、命令参数校验和冲突禁用。
- 命令服务:能力查询、无活动目标、未知命令、未实现命令和旧目标注销隔离。
- 启动开发服务器,访问 `/tests/visual/callouts.html?theme=paper-moments`;依次替换 light、dark、sepia、ocean-blue、midnight-purple。左右分别是工作区和静态预览,核对边框、标题、正文、嵌套与折叠;缩窄窗口检查换行。
此项不宣称支持所有 Markdown 方言;脚注、定义列表、Wiki 双链及 `:::` 等其他警告框语法仍需独立扩展。原生快捷键和元数据转换属于第三阶段验收。
## 4. 六主题适配补充
主题新增 `--color-callout-info/success/warning/danger/important/quote` 六个语义变量。每种类型继续使用共享组件结构,标题、边框与淡色背景取同一语义配色。未知导入主题未定义这些变量时,默认继承其已有状态色。折叠标题补齐悬停、键盘焦点与不可折叠标题状态。
| 主题 | 本次版本 | 外观 |
| --- | --- | --- |
| light、dark、sepia | 1.2.0 | 分别使用浅色、深色、暖色配色 |
| paper-moments | 1.7.0 | 纸张边框、虚线内描边和轻投影;嵌套取消重复投影 |
| ocean-blue | 1.4.0 | 海蓝与低亮度状态色 |
| midnight-purple | 2.2.0 | 深色表面与高亮度状态色 |
修正工作区基础选择器覆盖类型颜色的问题,默认色使用低优先级规则。社区预览 iframe 同步载入共享 callouts CSS,并展示 14 种规范类型、默认展开/折叠及嵌套样例。主题安装与预览使用同一 CSS 来源,纸间时光下载包的清单和样式同步更新;已安装旧版可通过主题页既有更新入口升级。
验证增加六主题 × 六配色在实际 8% 混色背景上的标题对比度检查(至少 4.5:1),工作区选择器覆盖回归,以及六主题预览结构检查。此检查针对不透明 sRGB 配色,不代替每个平台的字体与截图验收。
+10
View File
@@ -41,6 +41,10 @@ pnpm dev
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown;详见 [标题折叠与样式开发说明](../docs/development/标题折叠与样式开发说明.md)。
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
## 数据边界
@@ -86,3 +90,9 @@ pnpm build
Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。
开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。
## 构建体积检查
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。
Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "vue-tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
"type-check": "vue-tsc --noEmit",
"build:report": "node scripts/build-size.mjs"
},
"dependencies": {
"@codemirror/commands": "6.11.0",
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
const root = fileURLToPath(new URL('../dist/', import.meta.url))
const manifest = JSON.parse(readFileSync(resolve(root, '.vite/manifest.json'), 'utf8'))
const files = new Map(Object.values(manifest).filter(x => x.file.endsWith('.js')).map(x => [x.file, x]))
const sizes = file => { const bytes = readFileSync(resolve(root, file)); return { bytes: bytes.length, gzip: gzipSync(bytes).length } }
const closure = key => {
const seen = new Set()
function visit(k) { if (seen.has(k)) return; seen.add(k); for (const i of manifest[k]?.imports ?? []) visit(i) }
visit(key)
return [...new Set([...seen].map(k => manifest[k]?.file).filter(f => f?.endsWith('.js')))]
}
const entries = Object.entries(manifest).filter(([key, value]) => value.isEntry || /(?:WorkspaceView|VisualMarkdownEditor|VaultEntry|ChatView)\.vue$/.test(key)).map(([key]) => {
const files = closure(key)
return { entry: key, files, bytes: files.reduce((n, f) => n + sizes(f).bytes, 0), gzip: files.reduce((n, f) => n + sizes(f).gzip, 0) }
})
console.log(JSON.stringify({ entries, largest: [...files.keys()].map(file => ({ file, ...sizes(file) })).sort((a,b) => b.bytes-a.bytes).slice(0,15), chunks: files.size }, null, 2))
+19 -1
View File
@@ -1,6 +1,6 @@
theme_id: paper-moments
name: 纸间时光 · Paper Moments
version: 1.6.2
version: 1.8.0
author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0
@@ -9,6 +9,12 @@ css_entry: theme.css
license: MIT
---
[data-theme="paper-moments"] {
--color-callout-info: #406b7b;
--color-callout-success: #536f43;
--color-callout-warning: #875f25;
--color-callout-danger: #a34e42;
--color-callout-important: #805c7e;
--color-callout-quote: #6e6053;
color-scheme: light;
--color-background-primary: #faf7ee;
--color-background-secondary: #f3eee3;
@@ -322,3 +328,15 @@ license: MIT
/* Nested choices retain a quiet paper border without repeating tape/shadows. */
[data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; }
[data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
/* Callout paper: no tape over titles, no repeated shadows in nested blocks. */
[data-theme="paper-moments"] .markdown-callout,
[data-theme="paper-moments"] .milkdown .ProseMirror blockquote.markdown-callout {
border-radius: 10px 3px 10px 3px;
outline: 1px dashed color-mix(in srgb, var(--callout-color) 30%, transparent);
outline-offset: -6px;
box-shadow: 3px 3px 0 color-mix(in srgb, var(--callout-color) 12%, transparent);
padding: 14px 18px;
}
[data-theme="paper-moments"] .markdown-callout .markdown-callout { box-shadow: none; }
[data-theme="paper-moments"] .markdown-callout > .callout-title { font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; }
@@ -12,6 +12,8 @@ import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService'
import { navigateToCitation } from '@/composables/useCitationNavigation'
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
useWorkspaceRefresh()
defineProps<{
showSecondarySidebar?: boolean
@@ -3,6 +3,10 @@ import DiagramInteractions from './DiagramInteractions.vue'
import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
const headingAppearance = useHeadingAppearanceStore()
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
const markdownPreferences = useMarkdownPreferencesStore()
const props = defineProps<{ source: string }>()
const themeStore = useThemeStore()
@@ -12,15 +16,15 @@ let renderVersion = 0
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async ([source, theme]) => {
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized)], async ([source, theme]) => {
const version = ++renderVersion
const result = await renderMarkdown(source, { theme })
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized })
if (version === renderVersion) html.value = result
}, { immediate: true, flush: 'post' })
</script>
<template>
<DiagramInteractions><div class="markdown-content" v-html="html" /></DiagramInteractions>
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" v-html="html" /></DiagramInteractions>
</template>
<style>
@@ -0,0 +1,40 @@
import { onMounted, onUnmounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
/** Web fallback until the desktop host supplies filesystem events. No overlapping polls. */
export function useWorkspaceRefresh() {
const workspace = useWorkspaceStore()
const editor = useEditorStore()
let stopped = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
if (running || stopped) return
clearTimeout(timer)
running = true
try {
if (document.visibilityState !== 'hidden' && workspace.hasVault) {
await workspace.refreshFileTree()
if (!stopped) {
if (editor.currentFilePath && editor.currentFilePath === workspace.activeFilePath && !workspace.activeFile) editor.setExternalChanged()
else await editor.checkExternalFile()
}
}
} catch { /* Keep the existing tree; the store exposes the error and retries. */ }
finally {
running = false
if (!stopped) timer = setTimeout(refresh, 2000)
}
}
onMounted(() => {
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refresh)
void refresh()
})
onUnmounted(() => {
stopped = true; clearTimeout(timer)
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refresh)
})
}
@@ -0,0 +1,41 @@
// @vitest-environment happy-dom
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, expect, it, vi } from 'vitest'
import EditorHeader from './EditorHeader.vue'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import * as service from '@/services/workspaceService'
afterEach(() => vi.restoreAllMocks())
it('offers recovery for a missing file, supports cancel, and releases navigation after confirmation', async () => {
const pinia = createPinia(); setActivePinia(pinia)
vi.spyOn(service, 'getNoteId').mockResolvedValue('id')
const read = vi.spyOn(service, 'readFileContent').mockResolvedValue('original')
const editor = useEditorStore(), workspace = useWorkspaceStore()
await editor.loadFile('/removed.md'); workspace.openFile('/removed.md')
editor.updateContent('unsaved'); editor.setExternalChanged()
const wrapper = mount(EditorHeader, { global: { plugins: [pinia], stubs: { ActionDialog: { name: 'ActionDialog', template: '<div />', props: ['message'], emits: ['resolve'] } } } })
const close = () => wrapper.findAll('button').find(button => button.text() === '关闭当前笔记')!
try {
expect(wrapper.text()).toContain('原文件已删除或移动')
expect(wrapper.text()).not.toContain('重新加载外部版本')
expect(wrapper.text()).toContain('下载 Markdown 副本')
await close().trigger('click')
wrapper.findComponent({ name: 'ActionDialog' }).vm.$emit('resolve', null); await flushPromises()
expect(editor.content).toBe('unsaved')
await close().trigger('click')
wrapper.findComponent({ name: 'ActionDialog' }).vm.$emit('resolve', ''); await flushPromises()
expect(editor.currentFilePath).toBeNull(); expect(workspace.activeFilePath).toBeNull()
read.mockResolvedValue('another file')
await editor.loadFile('/other.md')
expect(editor.content).toBe('another file')
} finally { wrapper.unmount() }
})
it('does not discard text changed after confirmation was opened', async () => {
setActivePinia(createPinia())
const editor = useEditorStore()
editor.currentFilePath = '/removed.md'; editor.updateContent('before'); editor.setExternalChanged()
editor.updateContent('after')
expect(await editor.discardExternalChanges('/removed.md', 'before')).toBe(false)
expect(editor.content).toBe('after')
})
+37 -2
View File
@@ -1,11 +1,39 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
import { t } from '@/i18n'
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
const reloadError = ref('')
const needsRecovery = computed(() => ['conflict', 'external_changed'].includes(editorStore.saveStatus))
const missingFile = computed(() => needsRecovery.value && editorStore.currentFilePath === workspaceStore.activeFilePath && !workspaceStore.activeFile && !workspaceStore.treeRefreshError)
function downloadCopy() {
const url = URL.createObjectURL(new Blob([editorStore.content], { type: 'text/markdown;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${(editorStore.currentFilePath?.split('/').pop() ?? 'note.md').replace(/\.md$/i, '')}-recovered.md`
document.body.append(link); link.click(); link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function discard() {
const path = editorStore.currentFilePath, snapshot = editorStore.content
if (!path || !(await askConfirm(t('关闭将丢弃当前编辑内容。需要保留时,请先下载 Markdown 副本。确认关闭?', 'Closing discards the current editor content. Download a Markdown copy first if needed. Close?')))) return
if (!(await editorStore.discardExternalChanges(path, snapshot))) return
workspaceStore.closeFile(path)
workspaceStore.setActiveFile(null)
reloadError.value = ''
}
async function reload() {
const path = editorStore.currentFilePath, snapshot = editorStore.content
if (!(await askConfirm(t('重新加载会丢弃当前未保存内容。请先复制需要保留的文字。继续吗?', 'Reload discards unsaved edits. Copy any text you need to keep first. Continue?')))) return
if (path !== editorStore.currentFilePath || snapshot !== editorStore.content) return
try { await editorStore.reloadExternalFile(); reloadError.value = '' } catch (error) { reloadError.value = error instanceof Error ? error.message : '重新加载失败' }
}
const statusText = computed<Record<string, string>>(() => ({
idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'),
@@ -15,14 +43,20 @@ const statusText = computed<Record<string, string>>(() => ({
<template>
<header class="editor-header">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<button v-if="needsRecovery && !missingFile" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
<span v-if="missingFile" class="save-status conflict">{{ t('原文件已删除或移动', 'Original file deleted or moved') }}</span>
<button v-if="needsRecovery" class="button-secondary" @click="downloadCopy">{{ t('下载 Markdown 副本', 'Download Markdown copy') }}</button>
<button v-if="needsRecovery" class="button-secondary" @click="discard">{{ t('关闭当前笔记', 'Close current note') }}</button>
<span v-if="reloadError" class="save-status conflict" role="alert">{{ reloadError }}</span>
<div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
</div>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
<button type="button" class="save-button" :disabled="['saving','conflict','external_changed'].includes(editorStore.saveStatus)" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
</div>
</header>
</template>
@@ -40,6 +74,7 @@ const statusText = computed<Record<string, string>>(() => ({
.file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-identity small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.editor-actions { flex-wrap: wrap; justify-content: flex-end; }
.editor-actions, .mode-switch { display: flex; align-items: center; gap: var(--space-sm); }
.save-status { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
+1 -1
View File
@@ -24,7 +24,7 @@ function updateContent(event: Event) {
</script>
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" />
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import HeadingStyleSettings from './HeadingStyleSettings.vue'
it('previews individual heading settings and restores theme inheritance', async () => {
localStorage.clear()
const wrapper = mount(HeadingStyleSettings, { global: { plugins: [createPinia()] } })
try {
expect(wrapper.get('input[aria-label="H1 字号"]').attributes('disabled')).toBeDefined()
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.get('input[aria-label="H1 字号"]').setValue(42)
await wrapper.get('select[aria-label="H1 粗细"]').setValue('400')
expect(wrapper.get('.heading-style-preview').attributes('style')).toContain('--heading-1-size: 42px')
expect(wrapper.get('.heading-style-preview').attributes('style')).toContain('--heading-1-weight: 400')
await wrapper.get('button').trigger('click')
expect(wrapper.get('.heading-style-preview').attributes('data-heading-style')).toBeUndefined()
expect(wrapper.get('.heading-style-preview').attributes('style') ?? '').not.toContain('--heading-1-size')
} finally { wrapper.unmount() }
})
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { t } from '@/i18n'
const appearance = useHeadingAppearanceStore()
</script>
<template>
<details class="ui-disclosure heading-style-settings">
<summary>{{ t('标题样式', 'Heading styles') }}</summary>
<div class="heading-settings-body">
<label><input v-model="appearance.preferences.custom" type="checkbox" /> {{ t('自定义标题样式', 'Customize heading styles') }}</label>
<p class="subtle">{{ t('关闭时跟随主题。设置作用于正文 H1–H6,不改写 Markdown,也不改变笔记属性标题。', 'Disable to follow the theme. Applies to document H1H6 without rewriting Markdown or the metadata title.') }}</p>
<label>{{ t('标题字体', 'Heading font') }}
<select v-model="appearance.preferences.family" class="select" :disabled="!appearance.preferences.custom">
<option value="inherit">{{ t('跟随正文', 'Follow body') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="sans-serif">{{ t('无衬线字体', 'Sans serif') }}</option><option value="monospace">{{ t('等宽字体', 'Monospace') }}</option>
</select>
</label>
<div v-for="(level, index) in appearance.preferences.levels" :key="index" class="heading-setting-row">
<strong>H{{ index + 1 }}</strong>
<label>{{ t('字号 px', 'Size px') }}<input v-model.number="level.size" class="input" type="number" min="12" max="72" :disabled="!appearance.preferences.custom" :aria-label="`H${index + 1} ${t('字号', 'size')}`" /></label>
<label>{{ t('粗细', 'Weight') }}<select v-model.number="level.weight" class="select" :disabled="!appearance.preferences.custom" :aria-label="`H${index + 1} ${t('粗细', 'weight')}`"><option :value="400">{{ t('常规', 'Regular') }}</option><option :value="500">Medium</option><option :value="600">Semibold</option><option :value="700">{{ t('加粗', 'Bold') }}</option><option :value="800">Extra bold</option></select></label>
</div>
<button class="button-secondary" type="button" @click="appearance.reset">{{ t('恢复跟随主题', 'Restore theme defaults') }}</button>
<div class="heading-style-preview" :data-heading-style="appearance.preferences.custom ? 'custom' : undefined" :style="appearance.cssVariables">
<div class="markdown-content"><component :is="`h${index + 1}`" v-for="(_, index) in appearance.preferences.levels" :key="index">H{{ index + 1 }} {{ t('标题预览', 'Heading preview') }}</component></div>
</div>
</div>
</details>
</template>
<style scoped>
.heading-settings-body { display: grid; gap: 14px; padding: 16px; }
.heading-setting-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: end; }
.heading-setting-row label { display: grid; gap: 6px; min-width: 0; }
.heading-setting-row strong { align-self: center; }
.heading-style-preview { border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 16px; overflow-wrap: anywhere; background: var(--color-surface-primary); color: var(--color-text-primary); }
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMarkdownPreferencesStore, markdownPresets } from '@/stores/markdownPreferences'
import { t } from '@/i18n'
const store = useMarkdownPreferencesStore()
const name = ref('')
const error = ref('')
function save() { error.value = store.savePreset(name.value) ? '' : t('请输入名称,最多保存 20 个预设。', 'Enter a name; up to 20 presets.'); if (!error.value) name.value = '' }
</script>
<template>
<section class="markdown-preferences surface-nested">
<h3>{{ t('Markdown 语法与编辑预设', 'Markdown syntax and editing presets') }}</h3>
<p class="subtle">{{ t('语法和代码设置在下次打开写作编辑器时应用;不批量改写已有笔记。静态预览即时更新。', 'Syntax and code settings apply when the visual editor next opens; existing notes are not rewritten in bulk. Static previews update immediately.') }}</p>
<div class="inline-actions"><button class="button-secondary" @click="store.apply(markdownPresets.extended)">{{ t('扩展 Markdown', 'Extended Markdown') }}</button><button class="button-secondary" @click="store.apply(markdownPresets.github)">GitHub</button><button class="button-secondary" @click="store.apply(markdownPresets.plain)">{{ t('基础 Markdown', 'Basic Markdown') }}</button></div>
<div class="form-grid">
<label>{{ t('标题语法', 'Heading syntax') }}<select v-model="store.preferences.heading" class="select"><option value="atx">ATX (#)</option><option value="setext">Setext (=== / ---)</option></select></label>
<label>{{ t('无序列表', 'Bullet list') }}<select v-model="store.preferences.bullet" class="select"><option>-</option><option>*</option><option>+</option></select></label>
<label>{{ t('有序列表', 'Ordered list') }}<select v-model="store.preferences.incrementList" class="select"><option :value="true">1. 2. 3.</option><option :value="false">1. 1. 1.</option></select></label>
<label>{{ t('代码围栏', 'Code fence') }}<select v-model="store.preferences.fence" class="select"><option value="`">```</option><option value="~">~~~</option></select></label>
</div>
<p class="subtle">{{ t('Setext 适用于 H1/H2H3–H6 仍使用 #。写作模式保存时会规范化整篇正文的标记;源码模式保留手写语法。', 'Setext applies to H1/H2; H3H6 use #. Visual-mode saves normalize document markers; source mode preserves handwritten syntax.') }}</p>
<div class="markdown-switches">
<label><input v-model="store.preferences.autoLinks" type="checkbox" />{{ t('自动识别裸链接', 'Recognize bare URLs') }}</label>
<label><input v-model="store.preferences.math" type="checkbox" />{{ t('数学公式', 'Math') }}</label>
<label><input v-model="store.preferences.callouts" type="checkbox" />{{ t('警告框与提示框', 'Alerts and callouts') }}</label>
<label><input v-model="store.preferences.diagrams" type="checkbox" />Mermaid</label>
<label><input v-model="store.preferences.lineNumbers" type="checkbox" />{{ t('代码行号', 'Code line numbers') }}</label>
<label><input v-model="store.preferences.wrapCode" type="checkbox" />{{ t('代码自动换行', 'Wrap code') }}</label>
</div>
<div class="form-grid">
<label>{{ t('代码缩进', 'Code indent') }}<select v-model.number="store.preferences.indent" class="select"><option :value="2">2</option><option :value="4">4</option><option :value="8">8</option></select></label>
<label>{{ t('新建代码块默认语言', 'Default language for new code blocks') }}<input v-model="store.preferences.defaultLanguage" class="input" maxlength="40" placeholder="python" /></label>
</div>
<form class="inline-actions" @submit.prevent="save"><input v-model="name" class="input" maxlength="40" :aria-label="t('预设名称', 'Preset name')" :placeholder="t('我的预设名称', 'My preset name')" /><button class="button-primary">{{ t('保存为预设', 'Save preset') }}</button></form>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<div v-for="(preset, index) in store.customPresets" :key="preset.name" class="inline-actions"><strong>{{ preset.name }}</strong><button class="button-secondary" @click="store.apply(preset.preferences)">{{ t('应用', 'Apply') }}</button><button class="button-danger" @click="store.customPresets.splice(index, 1)">{{ t('删除', 'Delete') }}</button></div>
</section>
</template>
<style scoped>
.markdown-preferences { display: grid; gap: 16px; padding: 16px; margin-block: 16px; }
.markdown-preferences .form-grid > label { display: grid; gap: 6px; min-width: 0; }
.markdown-switches { display: grid; grid-template-columns: repeat(auto-fit,minmax(190px,1fr)); gap: 12px; }
.markdown-switches label { display: flex; align-items: center; gap: 8px; }
.inline-actions { flex-wrap: wrap; }
</style>
@@ -13,6 +13,10 @@ import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService'
import { headingFoldKey } from './headingFolding'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -51,6 +55,111 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('applies syntax and renderer preferences when opening the visual editor', async () => {
const preferences = useMarkdownPreferencesStore()
preferences.preferences.heading = 'setext'
preferences.preferences.bullet = '+'
preferences.preferences.fence = '~'
preferences.preferences.callouts = false
preferences.preferences.math = false
preferences.preferences.autoLinks = false
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# Heading\n\n- first\n- second\n\n> [!NOTE]\n> text\n\nhttps://example.com\n\n```text\ncode\n```' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const result = editor.action(getMarkdown())
expect(result).toContain('Heading\n===')
expect(result).toContain('+ first')
expect(result).toContain('~~~text')
expect(wrapper.find('.markdown-callout').exists()).toBe(false)
expect(wrapper.find('.ProseMirror a').exists()).toBe(false)
})
it('folds heading sections, retains nested state and opens hidden outline targets', async () => {
const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nvisible'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').trigger('click')
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
expect(wrapper.findAll('.heading-fold-hidden').length).toBeGreaterThan(1)
await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click')
expect(wrapper.get('.heading-fold-toggle[aria-label="展开 H2 B"]').attributes('aria-expanded')).toBe('false')
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let position = 0
view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'child') position = pos })
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position)))
expect(headingFoldKey.getState(view.state)?.size).toBe(0)
})
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
expect(editor.action(getMarkdown()).trim()).toBe(source)
await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click')
expect(wrapper.findAll('.section-actions button')).toHaveLength(1)
expect(wrapper.get('.section-actions button').text()).toBe('全部展开')
await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 C"]').trigger('click')
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click')
await wrapper.get('button[aria-label="展开所有章节"]').trigger('click')
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
})
it('offers expand all when individually collapsed parents hide expanded children', async () => {
const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nbody'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 C"]').trigger('click')
expect(wrapper.get('.section-actions button').text()).toBe('全部展开')
expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true')
await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click')
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true')
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
await wrapper.get('.section-actions button').trigger('click')
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
expect(editor.action(getMarkdown()).trim()).toBe(source)
})
it('renders and folds callouts without losing portable Markdown on serialization', async () => {
const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(wrapper.findAll('.markdown-callout')).toHaveLength(2)
expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('true')
await wrapper.get('.callout-title').trigger('click')
expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('false')
const markdown = editor.action(getMarkdown())
expect(markdown.trim()).toBe(source)
})
it('dispatches native-ready commands through editor transactions and rejects invalid parameters', async () => {
useEditorStore().currentFilePath = 'test.md'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'text' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(await executeEditorCommand('editor.heading', 8)).toEqual({ ok: false, reason: 'invalid-params' })
expect(await executeEditorCommand('editor.heading', 2)).toEqual({ ok: true })
expect(editor.action(getMarkdown())).toContain('## text')
expect(await executeEditorCommand('editor.callout', { type: 'tip', body: '**test**' })).toEqual({ ok: true })
expect(wrapper.find('.markdown-callout').exists()).toBe(true)
useEditorStore().saveStatus = 'conflict'
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
})
it('keeps code examples as ordinary quotes and renders newly typed markers', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
expect(wrapper.find('.markdown-callout').exists()).toBe(false)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let position = 0
view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'text') position = pos })
view.dispatch(view.state.tr.insertText('[!TIP]', position, position + 4))
})
expect(wrapper.find('.markdown-callout').exists()).toBe(true)
expect(editor.action(getMarkdown())).toContain('[!TIP]')
})
it('renders the supported format matrix and preserves inline code', async () => {
const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
'正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二',
@@ -4,11 +4,13 @@ import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Link, Fold, Expand } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap } from '@codemirror/view'
import { keymap, EditorView as CodeEditorView } from '@codemirror/view'
import { indentUnit } from '@codemirror/language'
import { EditorState as CodeEditorState } from '@codemirror/state'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
@@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown } from '@milkdown/kit/utils'
import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -28,8 +30,10 @@ import {
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark'
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
import { Slice } from '@milkdown/kit/prose/model'
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService'
import { TextSelection, Plugin } from '@milkdown/kit/prose/state'
import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor'
@@ -37,11 +41,18 @@ import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { inlineCodeInputPlugin } from './inlineCodeInput'
import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin'
import { calloutTypes } from '@/utils/callouts'
import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSections } from './headingFolding'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const headingAppearance = useHeadingAppearanceStore()
const markdownPreferences = { ...useMarkdownPreferencesStore().normalized }
const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('')
function setTags(tags: string[]) {
@@ -63,10 +74,82 @@ const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
let disposeCommands: (() => void) | undefined
let disposed = false
function insertMarkdown(source: string) {
crepe?.editor.action(ctx => {
const doc = ctx.get(parserCtx)(source)
if (!doc) throw new Error('Invalid Markdown')
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView())
view.focus()
})
}
function insertCallout(event: Event) {
const select = event.target as HTMLSelectElement
if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`)
select.value = ''
}
function installCommands() {
const targetPath = editorStore.currentFilePath
const handlers: Partial<Record<EditorCommandId, CommandHandler>> = {}
for (const [id, action] of [['editor.heading.toggle-fold', 'toggle'], ['editor.heading.fold-all', 'all'], ['editor.heading.unfold-all', 'none']] as const) {
handlers[id] = () => { foldHeadings(action); return { ok: true } }
}
const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block']
for (const command of toolbar) {
if (!markdownPreferences.math && command.includes('math')) continue
handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } }
}
handlers['editor.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } }
handlers['editor.heading'] = params => {
if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-params' }
crepe!.editor.action(callCommand(wrapInHeadingCommand.key, Number(params)))
return { ok: true }
}
handlers['editor.font-size'] = params => {
if (typeof params !== 'number' || !Number.isFinite(params) || params < 8 || params > 96) return { ok: false, reason: 'invalid-params' }
fontSizeInput.value = params
applyFontSizeValue()
return { ok: true }
}
handlers['editor.insert-markdown'] = params => {
if (typeof params !== 'string' || !params.trim() || params.length > 100000) return { ok: false, reason: 'invalid-params' }
insertMarkdown(params)
return { ok: true }
}
handlers['editor.callout'] = params => {
if (!markdownPreferences.callouts) return { ok: false, reason: 'unsupported' }
if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' }
const { type, title = '', body = '', fold = '' } = params as Record<string, unknown>
if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title)
|| typeof body !== 'string' || !['', '+', '-'].includes(String(fold)) || title.length + body.length > 100000) return { ok: false, reason: 'invalid-params' }
insertMarkdown(`> [!${type}]${fold} ${title}\n${body.split(/\r?\n/).map(line => `> ${line}`).join('\n')}`)
return { ok: true }
}
disposeCommands = registerEditorCommands({
available: () => !loading.value && !!crepe && editorStore.mode === 'wysiwyg' && editorStore.saveStatus !== 'conflict'
&& !!targetPath && targetPath === editorStore.currentFilePath && crepe.editor.action(ctx => ctx.get(editorViewCtx).editable),
handlers,
})
}
function foldHeadings(action: 'toggle' | 'all' | 'none') {
crepe?.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const tr = headingFoldTransaction(view.state, action)
if (tr) view.dispatch(tr)
})
}
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
for (const [id, entry] of diagramPreviews) {
@@ -114,7 +197,7 @@ function runCommand(command: ToolbarCommand) {
'ordered-list': callCommand(wrapInOrderedListCommand.key),
'bullet-list': callCommand(wrapInBulletListCommand.key),
'inline-code': callCommand(toggleInlineCodeCommand.key),
'code-block': callCommand(createCodeBlockCommand.key, ''),
'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage),
'inline-math': callCommand('ToggleLatex'),
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
}
@@ -181,7 +264,7 @@ onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false },
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
@@ -245,12 +328,54 @@ onMounted(async () => {
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? renderDiagram(content, applyPreview)
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
: config.renderPreview(language, content, applyPreview),
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme),
indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent),
...(markdownPreferences.wrapCode ? [CodeEditorView.lineWrapping] : [])],
})))
crepe.editor.config(ctx => ctx.update(remarkStringifyOptionsCtx, options => ({
...options, setext: markdownPreferences.heading === 'setext', bullet: markdownPreferences.bullet,
incrementListMarker: markdownPreferences.incrementList, fence: markdownPreferences.fence,
})))
if (!markdownPreferences.autoLinks) crepe.editor.use($remark('disable-bare-autolinks', () => () => (tree, file) => {
type Ast = { type: string; value?: string; url?: string; children?: Ast[]; position?: { start: { offset?: number }; end: { offset?: number } } }
const source = String(file.value)
const walk = (node: Ast) => {
if (node.type === 'link' && node.position) {
const raw = source.slice(node.position.start.offset, node.position.end.offset)
if (/^(?:https?:\/\/|www\.)\S+$/.test(raw)) {
node.type = 'text'; node.value = raw; delete node.children; delete node.url
}
}
node.children?.forEach(walk)
}
walk(tree as Ast)
}))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin)
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.use(headingFoldingPlugin)
crepe.editor.use($prose(() => new Plugin({
view(view) {
const sync = (current: typeof view) => {
const sections = headingSections(current.state.doc)
const folded = headingFoldKey.getState(current.state)
hasFoldableHeadings.value = sections.length > 0
// Hidden descendants retain their own state but are not visible expanded sections.
let hiddenUntil = -1
allHeadingsFolded.value = sections.length > 0 && sections.every(section => {
if (section.from < hiddenUntil) return true
if (!folded?.has(section.from)) return false
hiddenUntil = section.end
return true
})
}
sync(view)
return { update: sync }
},
})))
if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
@@ -265,6 +390,7 @@ onMounted(async () => {
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences()
loading.value = false
if (!disposed) installCommands()
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
@@ -282,15 +408,24 @@ watch(() => editorStore.headingRequest, request => {
})
})
onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
<template>
<DiagramInteractions class="visual-editor">
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<div class="section-actions">
<button type="button" :disabled="loading || !hasFoldableHeadings"
:title="allHeadingsFolded ? t('展开所有章节正文', 'Expand all section content') : t('折叠所有章节,保留标题', 'Collapse all sections, keeping headings visible')"
:aria-label="allHeadingsFolded ? t('展开所有章节', 'Unfold all sections') : t('折叠所有章节', 'Fold all sections')"
@click="foldHeadings(allHeadingsFolded ? 'none' : 'all')">
<AppIcon :icon="allHeadingsFolded ? Expand : Fold" :size="16" />
<span>{{ allHeadingsFolded ? t('全部展开', 'Expand all') : t('全部折叠', 'Collapse all') }}</span>
</button>
</div>
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
@@ -321,9 +456,15 @@ defineExpose({ getEditor: () => crepe?.editor })
<span class="toolbar-divider" />
<button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" :title="t('代码块', 'Code block')" :aria-label="t('代码块', 'Code block')" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button v-if="markdownPreferences.math" type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
<label class="toolbar-select">
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
<option value="">{{ t('提示框', 'Callout') }}</option>
<option v-for="(_, type) in calloutTypes" :key="type" :value="type">{{ type }}</option>
</select>
</label>
</div>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div class="milkdown-host" :class="{ loading }">
@@ -343,10 +484,16 @@ defineExpose({ getEditor: () => crepe?.editor })
<style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.section-actions { display: inline-flex; align-items: center; flex-shrink: 0; margin-inline-end: 8px; padding: 2px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-toolbar .section-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 28px; padding: 4px 8px; font: inherit; font-size: var(--font-size-xs); line-height: 1.25; white-space: nowrap; color: var(--color-text-secondary); }
.section-actions :deep(.app-icon) { transform: rotate(90deg); }
.markdown-toolbar .section-actions button:hover:not(:disabled) { background: var(--color-background-hover); color: var(--color-accent-primary); }
.markdown-toolbar .section-actions button:disabled { opacity: .45; cursor: default; }
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
.heading-glyph { font-weight: 800; }
.font-size-glyph { font-size: 18px; }
@@ -0,0 +1,89 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { parseCallout } from '@/utils/callouts'
import '@/styles/callouts.css'
import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core'
export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ctx => {
ctx.update(remarkStringifyOptionsCtx, options => ({
...options,
handlers: { ...options.handlers, blockquote(node, _parent, state, info) {
const exit = state.enter('blockquote')
const tracker = state.createTracker(info)
tracker.move('> ')
tracker.shift(2)
const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`)
exit()
// Only remove escaping from a leading callout marker, never body literals.
return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]')
} },
}))
}
// Keep native blockquotes in the document: typing, undo and Markdown serialization
// remain Milkdown transactions; the view never rewrites a user's callout source.
export const calloutPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = []
state.doc.descendants((node, position) => {
if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return
const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n'))
if (!callout) return
const from = position + 2
const to = from + callout.markerLength
const editing = state.selection.from <= to && state.selection.to >= from
decorations.push(Decoration.inline(from, to, { class: editing ? 'callout-marker-editing' : 'callout-marker' }))
})
return DecorationSet.create(state.doc, decorations)
},
nodeViews: {
blockquote(initialNode) {
const dom = document.createElement('blockquote')
const header = document.createElement('button')
header.type = 'button'
header.className = 'callout-title'
header.contentEditable = 'false'
const contentDOM = document.createElement('div')
contentDOM.className = 'callout-body'
dom.append(header, contentDOM)
let signature = ''
let foldable = false
const update = (node: typeof initialNode) => {
if (node.type.name !== 'blockquote') return false
const callout = node.firstChild?.type.name === 'paragraph' && !node.firstChild.firstChild?.marks.length
? parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n')) : null
dom.className = callout ? 'markdown-callout' : ''
header.hidden = !callout
foldable = !!callout?.fold
header.disabled = !foldable
if (callout) {
dom.dataset.callout = callout.type
const next = `${callout.name}:${callout.fold}`
if (signature !== next) dom.dataset.collapsed = String(callout.fold === '-')
signature = next
header.textContent = `${foldable ? (dom.dataset.collapsed === 'true' ? '▸ ' : '▾ ') : ''}${callout.title}`
if (foldable) header.setAttribute('aria-expanded', String(dom.dataset.collapsed !== 'true'))
else header.removeAttribute('aria-expanded')
} else {
delete dom.dataset.callout
delete dom.dataset.collapsed
signature = ''
}
return true
}
let current = initialNode
header.onclick = () => {
if (!foldable) return
dom.dataset.collapsed = String(dom.dataset.collapsed !== 'true')
update(current)
}
update(initialNode)
return { dom, contentDOM, update(node) { current = node; return update(node) },
stopEvent: event => header.contains(event.target as Node),
ignoreMutation: mutation => mutation.type !== 'selection' && !contentDOM.contains(mutation.target) }
},
},
},
}))
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { Schema } from '@milkdown/kit/prose/model'
import { EditorState, TextSelection } from '@milkdown/kit/prose/state'
import { headingSections, headingFoldTransaction } from './headingFolding'
const schema = new Schema({ nodes: {
doc: { content: 'block+' }, text: { group: 'inline' },
heading: { group: 'block', content: 'inline*', attrs: { level: { default: 1 } } },
paragraph: { group: 'block', content: 'inline*' },
blockquote: { group: 'block', content: 'block+' },
} })
const h = (level: number, text: string) => schema.nodes.heading!.create({ level }, schema.text(text))
const p = (text: string) => schema.nodes.paragraph!.create(null, schema.text(text))
it('ends sections at same-or-higher headings and confines nested quotes to their parent', () => {
const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('a'), h(2, 'B'), p('b'), h(1, 'C'), p('c'), schema.nodes.blockquote!.create(null, [h(2, 'D'), p('d')])])
const sections = headingSections(doc)
expect(sections.map(section => doc.nodeAt(section.from)?.textContent)).toEqual(['A', 'B', 'C', 'D'])
expect(sections[0]!.end).toBe(sections[2]!.from)
expect(sections[1]!.end).toBe(sections[2]!.from)
expect(sections[3]!.end).toBe(doc.content.size - 1)
})
it('moves the caret out of collapsed content without changing document content or history', () => {
const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('body'), h(1, 'B')])
const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5) })
const tr = headingFoldTransaction(state, 'all')!
expect(tr.doc.eq(doc)).toBe(true)
expect(tr.docChanged).toBe(false)
expect(tr.selection.from).toBe(1)
expect(tr.getMeta('addToHistory')).toBe(false)
expect(headingSections(doc)).toHaveLength(1)
})
@@ -0,0 +1,118 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin, PluginKey, TextSelection, type EditorState } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import type { Node } from '@milkdown/kit/prose/model'
import { t } from '@/i18n'
export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
type Section = { from: number; body: number; end: number; level: number }
const sectionCache = new WeakMap<Node, Section[]>()
/** A section ends at the next sibling heading of the same or a higher rank. */
export function headingSections(doc: Node): Section[] {
const cached = sectionCache.get(doc)
if (cached) return cached
const sections: Section[] = []
function visit(parent: Node, start: number) {
const children: { node: Node; pos: number }[] = []
parent.forEach((node, offset) => children.push({ node, pos: start + offset }))
const following: { pos: number; level: number }[] = []
for (let i = children.length - 1; i >= 0; i--) {
const { node, pos } = children[i]!
if (node.type.name === 'heading') {
while (following.length && following[following.length - 1]!.level > node.attrs.level) following.pop()
const next = following[following.length - 1]
const body = pos + node.nodeSize
const end = next?.pos ?? start + parent.content.size
if (end > body) sections.push({ from: pos, body, end, level: Number(node.attrs.level) })
following.push({ pos, level: Number(node.attrs.level) })
}
if (!node.isTextblock && node.childCount) visit(node, pos + 1)
}
}
visit(doc, 0)
sections.sort((a, b) => a.from - b.from)
sectionCache.set(doc, sections)
return sections
}
export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'all' | 'none', position?: number) {
const sections = headingSections(state.doc)
const folded = new Set(headingFoldKey.getState(state) ?? [])
if (action === 'none') folded.clear()
else if (action === 'all') sections.forEach(section => folded.add(section.from))
else {
const section = position === undefined
? sections.filter(item => item.from <= state.selection.from && item.end >= state.selection.from).pop()
: sections.find(item => item.from === position)
if (!section) return null
if (folded.has(section.from)) folded.delete(section.from)
else folded.add(section.from)
}
const tr = state.tr
const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
}
export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
key: headingFoldKey,
state: {
init: () => new Set(),
apply(tr, previous) {
const explicit = tr.getMeta(headingFoldKey) as Set<number> | undefined
if (explicit) return explicit
const sections = headingSections(tr.doc)
const mapped = new Set<number>()
for (const old of previous) {
const result = tr.mapping.mapResult(old, 1)
if (!result.deleted && sections.some(section => section.from === result.pos)) mapped.add(result.pos)
}
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
if (tr.selectionSet || tr.docChanged) {
for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
}
return mapped
},
},
props: {
decorations(state) {
const folded = headingFoldKey.getState(state) ?? new Set<number>()
const sections = headingSections(state.doc)
const decorations: Decoration[] = []
for (const section of sections) {
const collapsed = folded.has(section.from)
decorations.push(Decoration.widget(section.from + 1, view => {
const button = document.createElement('button')
button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false'
button.setAttribute('aria-expanded', String(!collapsed))
button.setAttribute('aria-label', `${collapsed ? t('展开', 'Expand') : t('折叠', 'Collapse')} H${section.level} ${state.doc.nodeAt(section.from)?.textContent ?? ''}`)
button.onmousedown = event => event.preventDefault()
button.onclick = event => {
event.preventDefault()
const tr = headingFoldTransaction(view.state, 'toggle', section.from)
if (tr) view.dispatch(tr)
}
return button
}, { key: `${section.from}:${collapsed}:${state.doc.nodeAt(section.from)?.textContent}`, side: -1, stopEvent: () => true }))
}
const hidden: { body: number; end: number }[] = []
for (const section of sections) {
if (!folded.has(section.from)) continue
const previous = hidden[hidden.length - 1]
if (previous && section.body <= previous.end) previous.end = Math.max(previous.end, section.end)
else hidden.push({ body: section.body, end: section.end })
}
let rangeIndex = 0
state.doc.descendants((node, pos) => {
if (!node.isBlock) return
while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
const range = hidden[rangeIndex]
if (range && pos >= range.body && pos + node.nodeSize <= range.end) {
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'heading-fold-hidden' }))
return false
}
})
return DecorationSet.create(state.doc, decorations)
},
},
}))
@@ -5,6 +5,8 @@ const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
import MarkdownPreferenceSettings from '@/features/editor/MarkdownPreferenceSettings.vue'
import ChatPersonaDialog from '@/features/chat/ChatPersonaDialog.vue'
import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
@@ -85,7 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label><MarkdownPreferenceSettings /><HeadingStyleSettings /></div>
<div v-else-if="activeSection === 'providers'" class="settings-section">
<section class="panel provider-settings-card">
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'
// Vitest disables CSS by default, including CSS raw imports. Load the real files here.
vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') }))
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
import CommunityThemePreview from './CommunityThemePreview.vue'
import { mockCommunityThemes } from '@/services/themePackageService'
const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))]
@@ -15,6 +16,10 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
expect(doc.querySelector('script')).toBeNull()
expect(doc.querySelectorAll('.specimen-callouts > aside.markdown-callout')).toHaveLength(14)
expect(doc.querySelector('.specimen-callouts > details[open]')).not.toBeNull()
expect(doc.querySelector('.specimen-callouts > details:not([open])')).not.toBeNull()
expect(doc.querySelector('style')!.textContent).toContain('.callout-title:focus-visible')
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content')).toContain("default-src 'none'")
for (const selector of ['input.input','input:disabled','textarea.textarea','select.select','.ui-disclosure[open]','.ui-disclosure:not([open])','.button-primary:disabled','.badge.success','.error-banner','.specimen-markdown code','.specimen-markdown table','.specimen-chart','.specimen-long']) expect(doc.querySelector(selector), selector).not.toBeNull()
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
@@ -5,6 +5,9 @@ import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw'
import featuresCss from '@/styles/features.css?raw'
import headingsCss from '@/styles/headings.css?raw'
import calloutsCss from '@/styles/callouts.css?raw'
import { calloutTypes } from '@/utils/callouts'
import specimenHtml from './themeSpecimen.html?raw'
const props = defineProps<{ themeId: string; name?: string; css?: string }>()
@@ -20,7 +23,7 @@ const previewDocument = computed(() => {
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
doc.head.append(policy)
const style = doc.createElement('style')
style.textContent = `${tokensCss}\n${featuresCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${headingsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
doc.head.append(style)
const article = doc.createElement('article')
@@ -33,6 +36,22 @@ const previewDocument = computed(() => {
const button = doc.createElement('button'); button.className = 'button-primary'; button.textContent = t('示例按钮', 'Example button')
journal.append(text)
const specimen = doc.createElement('template'); specimen.innerHTML = specimenHtml
const callouts = doc.createElement('section'); callouts.className = 'specimen-callouts'
const caption = doc.createElement('h2'); caption.textContent = t('警告框与提示框', 'Alerts and callouts'); callouts.append(caption)
for (const type of Object.keys(calloutTypes)) {
const block = doc.createElement('aside'); block.className = 'markdown-callout'; block.dataset.callout = type
const title = doc.createElement('div'); title.className = 'callout-title'; title.textContent = type
const body = doc.createElement('div'); body.className = 'callout-body'; body.textContent = t('提示正文:检查文字、边框与主题配色。', 'Callout body: check text, borders and theme colors.')
block.append(title, body); callouts.append(block)
}
for (const open of [false, true]) {
const details = doc.createElement('details'); details.className = 'markdown-callout'; details.dataset.callout = 'warning'; details.open = open
const summary = doc.createElement('summary'); summary.className = 'callout-title'; summary.textContent = t('可折叠警告框', 'Collapsible callout')
const body = doc.createElement('div'); body.className = 'callout-body'
const nested = doc.createElement('aside'); nested.className = 'markdown-callout'; nested.dataset.callout = 'tip'; nested.textContent = t('嵌套提示内容', 'Nested callout content')
body.append(nested); details.append(summary, body); callouts.append(details)
}
specimen.content.append(callouts)
article.append(header, journal, button, specimen.content); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML
})
@@ -96,7 +96,7 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS
it('offers and applies the paper theme update without discarding the active theme', async () => {
const store = useThemeStore()
const old = await inspectThemePackage(paperPackage.replace('version: 1.6.2', 'version: 1.6.1'))
const old = await inspectThemePackage(paperPackage.replace('version: 1.8.0', 'version: 1.6.1'))
await store.installThemeFromInspection(old.manifest, old.css)
store.applyTheme('paper-moments')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
await flushPromises()
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.6.2')
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.0')
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
@@ -222,6 +223,7 @@ onMounted(() => {
<div class="field"><label>{{ t('字体', 'Font') }}</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">{{ t('系统字体', 'System font') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="var(--font-ui-mono)">{{ t('等宽字体', 'Monospace') }}</option></select></div>
<div class="field"><label>{{ t('代码块样式', 'Code block style') }}</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">{{ t('跟随主题', 'Follow theme') }}</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>{{ t('Markdown 渲染使用对应的 Shiki GitHub 主题', 'Markdown rendering uses the matching Shiki GitHub theme') }}</small></div>
</div>
<HeadingStyleSettings />
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
<div class="preview-heading"><h3>{{ t('主题预览', 'Theme Preview') }}</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
<p>{{ t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') }}</p>
@@ -17,3 +17,6 @@ console.log(note);</code></pre><table><thead><tr><th>名称</th><th>状态</th><
<figure class="specimen-chart"><figcaption>数据配色示例</figcaption><div role="img" aria-label="本地用量 30,提供商用量 70"><span style="height:30%;background:var(--color-accent-primary)"></span><span style="height:70%;background:var(--color-accent-secondary)"></span></div></figure>
<p class="subtle specimen-long">超长模型标识:provider/model-with-a-very-long-identifier-for-layout-validation-012345678901234567890123456789</p>
</div>
<section class="surface-nested markdown-preferences"><h2>Markdown 语法预设</h2><label>标题样式 <select class="select"><option>ATX (#)</option><option>Setext</option></select></label><label><input type="checkbox" checked> 警告框与提示框</label><button class="button-secondary">保存为预设</button></section>
<section class="milkdown"><div class="ProseMirror"><h2><button class="heading-fold-toggle" aria-expanded="true" aria-label="折叠示例标题"></button>悬停查看折叠箭头</h2><p>折叠按钮跟随主题,键盘聚焦时也可见。</p></div></section>
@@ -216,6 +216,7 @@ function containingFolder(path: string): string {
<template>
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
<p v-if="workspaceStore.treeRefreshError" class="subtle" role="status">{{ t('文件树暂未同步将自动重试', 'File tree sync delayed; retrying automatically.') }}</p>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from 'vitest'
import { executeEditorCommand, registerEditorCommands, getEditorCommandCapabilities } from './editorCommandService'
let dispose: (() => void) | undefined
afterEach(() => dispose?.())
it('reports unsupported, disabled and invalid commands without side effects', async () => {
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
const handler = vi.fn(() => ({ ok: true as const }))
let available = false
dispose = registerEditorCommands({ available: () => available, handlers: { 'editor.bold': handler } })
expect(getEditorCommandCapabilities().find(item => item.id === 'editor.bold')).toMatchObject({ supported: true, enabled: false })
await executeEditorCommand('editor.bold')
expect(handler).not.toHaveBeenCalled()
available = true
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: true })
expect(await executeEditorCommand('editor.metadata.edit')).toEqual({ ok: false, reason: 'unsupported' })
expect(await executeEditorCommand('arbitrary-command')).toEqual({ ok: false, reason: 'unsupported' })
})
it('old editor disposal cannot unregister the replacement editor', async () => {
const old = registerEditorCommands({ available: () => true, handlers: {} })
dispose = registerEditorCommands({ available: () => true, handlers: { 'editor.bold': () => ({ ok: true }) } })
old()
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: true })
dispose()
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
})
@@ -0,0 +1,34 @@
/** Versioned frontend boundary for future native menus/shortcuts; no Tauri IPC yet. */
export const editorCommandVersion = 1
export const editorCommandIds = [
'editor.bold', 'editor.italic', 'editor.strikethrough', 'editor.inline-code',
'editor.paragraph', 'editor.heading', 'editor.bullet-list', 'editor.ordered-list',
'editor.task-list', 'editor.blockquote', 'editor.callout', 'editor.code-block',
'editor.inline-math', 'editor.math-block', 'editor.mermaid', 'editor.link',
'editor.image', 'editor.table', 'editor.horizontal-rule', 'editor.hard-break',
'editor.font-size', 'editor.insert-markdown', 'editor.import-note-properties',
'editor.metadata.edit', 'editor.metadata.title', 'editor.metadata.tags',
'editor.reference-link', 'editor.html', 'editor.undo', 'editor.redo',
'editor.heading.toggle-fold', 'editor.heading.fold-all', 'editor.heading.unfold-all',
] as const
export type EditorCommandId = typeof editorCommandIds[number]
export type CommandResult = { ok: true } | { ok: false; reason: 'unsupported' | 'unavailable' | 'invalid-params' | 'failed' }
export type CommandHandler = (params: unknown) => CommandResult | Promise<CommandResult>
type Target = { available: () => boolean; handlers: Partial<Record<EditorCommandId, CommandHandler>> }
let active: Target | undefined
export function registerEditorCommands(target: Target) {
active = target
return () => { if (active === target) active = undefined }
}
export function getEditorCommandCapabilities() {
return editorCommandIds.map(id => ({ id, supported: !!active?.handlers[id], enabled: !!active?.handlers[id] && active.available() }))
}
export async function executeEditorCommand(id: string, params?: unknown): Promise<CommandResult> {
if (!(editorCommandIds as readonly string[]).includes(id)) return { ok: false, reason: 'unsupported' }
const target = active
if (!target || !target.available()) return { ok: false, reason: 'unavailable' }
const handler = target.handlers[id as EditorCommandId]
if (!handler) return { ok: false, reason: 'unsupported' }
try { return await handler(params) } catch { return { ok: false, reason: 'failed' } }
}
+12 -4
View File
@@ -1,4 +1,10 @@
import mermaid from 'mermaid'
let mermaidPromise: Promise<typeof import('mermaid')['default']> | undefined
function loadMermaid() {
return mermaidPromise ??= import('mermaid').then(module => module.default).catch(error => {
mermaidPromise = undefined
throw error
})
}
import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
@@ -23,7 +29,8 @@ export function mermaidThemeVariables(dark: boolean) {
}
}
function ensureInitialized(theme: 'light' | 'dark') {
async function ensureInitialized(theme: 'light' | 'dark') {
const mermaid = await loadMermaid()
mermaid.initialize({
startOnLoad: false,
theme: 'base',
@@ -34,6 +41,7 @@ function ensureInitialized(theme: 'light' | 'dark') {
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
})
return mermaid
}
let queue: Promise<unknown> = Promise.resolve()
function serialized<T>(work: () => Promise<T>): Promise<T> {
@@ -66,9 +74,9 @@ async function renderMermaidNow(
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light'
ensureInitialized(theme)
const id = `mermaid-${Date.now()}-${++renderCounter}`
try {
const mermaid = await ensureInitialized(theme)
const result = await mermaid.render(id, source)
const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml')
@@ -131,7 +139,7 @@ export function useMermaidTheme() {
export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> {
try {
await serialized(async () => { ensureInitialized('light'); await mermaid.parse(source) })
await serialized(async () => { const mermaid = await ensureInitialized('light'); await mermaid.parse(source) })
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误'
+1 -1
View File
@@ -25,7 +25,7 @@ export async function createNote(data: {
export async function updateNote(
noteId: string,
data: { title?: string; markdown?: string; tags?: string[] }
data: { title?: string; markdown?: string; tags?: string[]; expected_content_hash?: string }
): Promise<ApiNote> {
return apiClient.patch(`/api/notes/${noteId}`, data)
}
+8 -2
View File
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{
theme_id: 'ocean-blue',
name: 'Ocean Blue',
version: '1.3.1',
version: '1.5.0',
author: 'community',
description: '宁静的海洋蓝色主题,适合长时间阅读',
min_app_version: '0.2.0',
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{
theme_id: 'midnight-purple',
name: 'Midnight Purple',
version: '2.1.1',
version: '2.3.0',
author: 'night-owl',
description: '深紫色暗夜主题,适合编码',
min_app_version: '0.2.0',
@@ -464,6 +464,12 @@ export function getCommunityThemePreviewCss(themeId: string): string {
return buildCommunityThemeCss(themeId, t.is_dark) + `
[data-theme="${themeId}"] {
color-scheme: ${t.is_dark ? 'dark' : 'light'};
--color-callout-info: ${t.is_dark ? '#9dbbff' : '#126589'};
--color-callout-success: ${t.is_dark ? '#a7d58c' : '#267049'};
--color-callout-warning: ${t.is_dark ? '#efc886' : '#885c13'};
--color-callout-danger: ${t.is_dark ? '#ff9caf' : '#b13d4d'};
--color-callout-important: ${t.is_dark ? '#d4afff' : '#7050a3'};
--color-callout-quote: ${t.is_dark ? '#b0b9dd' : '#53697d'};
--color-text-inverse: ${t.is_dark ? '#1a1b26' : '#ffffff'};
--color-text-disabled: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-surface-primary));
--color-background-overlay: ${t.is_dark ? '#000000a6' : '#00000073'};
+8 -2
View File
@@ -19,6 +19,7 @@ export interface VaultInfo {
}
let cachedTree: FileNode[] | null = null
let treeRequestVersion = 0
const noteIdByPath = new Map<string, string>()
const typeByPath = new Map<string, FileNode['type']>()
@@ -88,6 +89,7 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
}
export async function openVault(path: string): Promise<VaultInfo> {
treeRequestVersion++
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items)
return {
@@ -104,7 +106,9 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
}
export async function refreshTree(): Promise<FileNode[]> {
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree')
const version = ++treeRequestVersion
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree', { timeoutMs: 10000 })
if (version !== treeRequestVersion) return cachedTree ?? []
return cacheEntries(entries)
}
@@ -122,10 +126,12 @@ export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath)
}
export async function saveFileContent(filePath: string, content: string): Promise<void> {
export async function saveFileContent(filePath: string, content: string, expectedContent?: string): Promise<void> {
const metadata = splitNoteMetadata(content)
const expectedHash = expectedContent === undefined ? undefined : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedContent)))).map(byte => byte.toString(16).padStart(2, '0')).join('')
await noteService.updateNote(await requireNoteId(filePath), {
markdown: content,
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
// Explicit [] clears the index; absent tags retain API-managed tags.
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
})
+50 -7
View File
@@ -3,10 +3,13 @@ import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
const content = ref('')
const contentRevision = ref(0)
let diskContent: string | undefined
const saveStatus = ref<SaveStatus>('idle')
const lastSavedAt = ref<string | null>(null)
const currentNoteId = ref<string | null>(null)
@@ -35,13 +38,14 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) {
content.value = newContent
saveStatus.value = 'dirty'
if (saveStatus.value !== 'conflict' && saveStatus.value !== 'external_changed') saveStatus.value = 'dirty'
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) {
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = null
@@ -51,20 +55,23 @@ export const useEditorStore = defineStore('editor', () => {
async function save() {
if (!currentFilePath.value) return
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value
const snapshot = content.value
const baseline = diskContent
saveStatus.value = 'saving'
pendingSave = (async () => {
try {
await workspaceService.saveFileContent(targetPath, snapshot)
await workspaceService.saveFileContent(targetPath, snapshot, baseline)
if (currentFilePath.value === targetPath) {
diskContent = snapshot
saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
lastSavedAt.value = new Date().toISOString()
}
} catch {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
} catch (error) {
if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
} finally {
pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
@@ -86,7 +93,7 @@ export const useEditorStore = defineStore('editor', () => {
}
if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
if (['dirty', 'save_failed', 'conflict', 'external_changed'].includes(saveStatus.value)) {
throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.'))
}
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
@@ -102,6 +109,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = filePath
currentNoteId.value = loadedNoteId
content.value = loadedContent
diskContent = loadedContent
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch (error) {
@@ -122,14 +130,44 @@ export const useEditorStore = defineStore('editor', () => {
}
function setExternalChanged() {
if (saveStatus.value === 'dirty') {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed' || saveStatus.value === 'conflict') {
saveStatus.value = 'conflict'
} else {
saveStatus.value = 'external_changed'
}
}
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
async function checkExternalFile() {
if (!currentFilePath.value || pendingSave || diskContent === undefined || saveStatus.value === 'conflict') return
const path = currentFilePath.value, baseline = diskContent
try {
const latest = await workspaceService.readFileContent(path)
if (path !== currentFilePath.value || pendingSave || diskContent !== baseline || latest === baseline) return
if (content.value === baseline && saveStatus.value === 'saved') {
content.value = latest; diskContent = latest; contentRevision.value++
} else {
setExternalChanged(); saveStatus.value = 'conflict'
}
} catch { /* Tree polling reports missing files; transient network errors retain edits. */ }
}
async function reloadExternalFile() {
const path = currentFilePath.value, snapshot = content.value
if (!path || pendingSave) return
const latest = await workspaceService.readFileContent(path)
if (path !== currentFilePath.value || content.value !== snapshot || pendingSave) return
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
content.value = latest; diskContent = latest; saveStatus.value = 'saved'; contentRevision.value++
}
async function discardExternalChanges(path: string, snapshot: string): Promise<boolean> {
if (pendingSave) await pendingSave
if (currentFilePath.value !== path || content.value !== snapshot) return false
closeFile()
return true
}
function closeFile() {
loadVersion++
@@ -137,6 +175,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = null
currentNoteId.value = null
content.value = ''
diskContent = undefined
saveStatus.value = 'idle'
lastSavedAt.value = null
highlightBlockId.value = null
@@ -153,6 +192,10 @@ export const useEditorStore = defineStore('editor', () => {
jumpToHeading,
mode,
content,
contentRevision,
checkExternalFile,
reloadExternalFile,
discardExternalChanges,
saveStatus,
lastSavedAt,
currentNoteId,
+25 -1
View File
@@ -6,6 +6,30 @@ import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
it('reloads clean external changes but preserves unsaved edits and blocks overwrite', async () => {
setActivePinia(createPinia())
vi.spyOn(workspace, 'getNoteId').mockResolvedValue('id')
const read = vi.spyOn(workspace, 'readFileContent').mockResolvedValue('original')
const write = vi.spyOn(workspace, 'saveFileContent').mockResolvedValue()
const store = useEditorStore()
await store.loadFile('/draft.md')
read.mockResolvedValue('external')
await store.checkExternalFile()
expect(store.content).toBe('external')
expect(store.contentRevision).toBe(1)
store.updateContent('my unsaved changes')
read.mockResolvedValue('new external')
await store.checkExternalFile()
expect(store.saveStatus).toBe('conflict')
expect(store.content).toBe('my unsaved changes')
store.updateContent('keep editing')
await store.save()
expect(write).not.toHaveBeenCalled()
await store.reloadExternalFile()
expect(store.content).toBe('new external')
expect(store.saveStatus).toBe('saved')
})
it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers()
setActivePinia(createPinia())
@@ -20,6 +44,6 @@ it('saves text typed while the previous save is still pending', async () => {
await saving
expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest')
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest', 'first')
expect(store.saveStatus).toBe('saved')
})
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { normalizeHeadingAppearance, useHeadingAppearanceStore } from './headingAppearance'
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
it('persists heading preferences and restores theme defaults without residual overrides', async () => {
const store = useHeadingAppearanceStore()
expect(store.cssVariables).toEqual({})
store.preferences.custom = true
store.preferences.levels[1]!.size = 35
store.preferences.levels[1]!.weight = 400
await nextTick()
setActivePinia(createPinia())
const restored = useHeadingAppearanceStore()
expect(restored.cssVariables['--heading-2-size']).toBe('35px')
expect(restored.cssVariables['--heading-2-weight']).toBe('400')
restored.reset()
expect(restored.cssVariables).toEqual({})
})
it('rejects invalid storage and limits values before applying CSS', () => {
localStorage.setItem('editor-heading-appearance', 'invalid')
expect(useHeadingAppearanceStore().preferences.custom).toBe(false)
const result = normalizeHeadingAppearance({ custom: true, family: 'url(unsafe)', levels: [{ size: 9999, weight: 2 }, { size: NaN }] })
expect(result.family).toBe('inherit')
expect(result.levels[0]).toEqual({ size: 72, weight: 700 })
expect(result.levels[1]!.size).toBe(28)
})
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import '@/styles/headings.css'
const key = 'editor-heading-appearance'
export const defaultHeadingSizes = [32, 28, 24, 21, 18, 16]
export function normalizeHeadingAppearance(value: unknown) {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const levels = Array.isArray(raw.levels) ? raw.levels : []
return {
custom: raw.custom === true,
family: ['inherit', 'serif', 'sans-serif', 'monospace'].includes(String(raw.family)) ? String(raw.family) : 'inherit',
levels: defaultHeadingSizes.map((size, index) => {
const item = levels[index] ?? {}
return {
size: typeof item.size === 'number' && Number.isFinite(item.size) ? Math.min(72, Math.max(12, item.size)) : size,
weight: [400, 500, 600, 700, 800].includes(item.weight) ? Number(item.weight) : 700,
}
}),
}
}
export const useHeadingAppearanceStore = defineStore('heading-appearance', () => {
let saved: unknown
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') } catch { saved = {} }
const preferences = ref(normalizeHeadingAppearance(saved))
watch(preferences, value => localStorage.setItem(key, JSON.stringify(normalizeHeadingAppearance(value))), { deep: true })
const cssVariables = computed(() => {
const normalized = normalizeHeadingAppearance(preferences.value)
if (!normalized.custom) return {}
const result: Record<string, string> = { '--heading-family': normalized.family }
normalized.levels.forEach((item, index) => {
result[`--heading-${index + 1}-size`] = `${item.size}px`
result[`--heading-${index + 1}-weight`] = String(item.weight)
})
return result
})
function reset() { preferences.value = normalizeHeadingAppearance({}) }
return { preferences, cssVariables, reset }
})
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useMarkdownPreferencesStore, markdownPresets } from './markdownPreferences'
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
it('saves, replaces and restores named syntax presets', async () => {
const store = useMarkdownPreferencesStore()
store.preferences.heading = 'setext'
store.preferences.bullet = '+'
expect(store.savePreset('我的格式')).toBe(true)
store.apply(markdownPresets.plain)
expect(store.normalized.callouts).toBe(false)
store.apply(store.customPresets[0]!.preferences)
expect(store.normalized.heading).toBe('setext')
await nextTick()
setActivePinia(createPinia())
expect(useMarkdownPreferencesStore().normalized.bullet).toBe('+')
expect(useMarkdownPreferencesStore().customPresets[0]!.name).toBe('我的格式')
})
@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export interface MarkdownPreferences {
heading: 'atx' | 'setext'; bullet: '-' | '*' | '+'; incrementList: boolean
fence: '`' | '~'; math: boolean; callouts: boolean; diagrams: boolean; autoLinks: boolean
lineNumbers: boolean; wrapCode: boolean; indent: number; defaultLanguage: string
}
export const defaultMarkdownPreferences: MarkdownPreferences = {
heading: 'atx', bullet: '-', incrementList: true, fence: '`', math: true, callouts: true,
diagrams: true, autoLinks: true, lineNumbers: true, wrapCode: false, indent: 4, defaultLanguage: '',
}
export function normalizeMarkdownPreferences(value: unknown): MarkdownPreferences {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const result = { ...defaultMarkdownPreferences }
for (const key of ['incrementList','math','callouts','diagrams','autoLinks','lineNumbers','wrapCode'] as const) if (typeof raw[key] === 'boolean') result[key] = raw[key]
result.heading = raw.heading === 'setext' ? 'setext' : 'atx'
result.bullet = raw.bullet === '*' || raw.bullet === '+' ? raw.bullet : '-'
result.fence = raw.fence === '~' ? '~' : '`'
result.indent = [2,4,8].includes(Number(raw.indent)) ? Number(raw.indent) : 4
result.defaultLanguage = typeof raw.defaultLanguage === 'string' && /^[\w+-]{0,40}$/.test(raw.defaultLanguage) ? raw.defaultLanguage : ''
return result
}
export const markdownPresets = {
extended: defaultMarkdownPreferences,
github: { ...defaultMarkdownPreferences, math: false },
plain: { ...defaultMarkdownPreferences, math: false, callouts: false, diagrams: false, autoLinks: false },
}
const key = 'markdown-preferences'
export const useMarkdownPreferencesStore = defineStore('markdown-preferences', () => {
let saved: Record<string, unknown> = {}
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* defaults */ }
const preferences = ref(normalizeMarkdownPreferences(saved.preferences))
const customPresets = ref<{ name: string; preferences: MarkdownPreferences }[]>(Array.isArray(saved.presets)
? saved.presets.filter(item => item && typeof item.name === 'string').slice(0, 20).map(item => ({ name: item.name.slice(0, 40), preferences: normalizeMarkdownPreferences(item.preferences) })) : [])
const normalized = computed(() => normalizeMarkdownPreferences(preferences.value))
watch([preferences, customPresets], () => localStorage.setItem(key, JSON.stringify({ preferences: normalized.value, presets: customPresets.value })), { deep: true })
function apply(value: unknown) { preferences.value = normalizeMarkdownPreferences(value) }
function savePreset(name: string) {
name = name.trim().slice(0, 40)
if (!name) return false
const existing = customPresets.value.find(item => item.name === name)
if (existing) existing.preferences = { ...normalized.value }
else if (customPresets.value.length < 20) customPresets.value.push({ name, preferences: { ...normalized.value } })
else return false
return true
}
return { preferences, normalized, customPresets, apply, savePreset }
})
+5 -3
View File
@@ -1,13 +1,14 @@
import { defineStore } from 'pinia'
import { useHeadingAppearanceStore } from './headingAppearance'
import { ref, computed, watch } from 'vue'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService'
import { t } from '@/i18n'
const builtinThemes = (): ThemeConfig[] => [
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.1.1', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.1.1', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.1.1', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.3.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.3.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.3.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
]
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
@@ -158,6 +159,7 @@ export const useThemeStore = defineStore('theme', () => {
}
function resetToDefault() {
useHeadingAppearanceStore().reset()
applyTheme('light')
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
+22 -1
View File
@@ -13,6 +13,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const isLoading = ref(false)
const hasVault = ref(false)
const recentVaults = ref<workspaceService.VaultInfo[]>([])
const treeRefreshError = ref<string | null>(null)
let refreshSequence = 0
const activeFile = computed(() => {
if (!activeFilePath.value) return null
@@ -65,10 +67,27 @@ export const useWorkspaceStore = defineStore('workspace', () => {
/** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */
async function refreshFileTree() {
if (!hasVault.value) return
fileTree.value = await workspaceService.getFileTree()
const sequence = ++refreshSequence
const path = vaultPath.value
try {
const fresh = await workspaceService.refreshTree()
if (sequence !== refreshSequence || path !== vaultPath.value || !hasVault.value) return
const open = new Map<string, boolean>()
const collect = (nodes: FileNode[]) => nodes.forEach(node => { if (node.type === 'folder') open.set(node.path, !!node.is_open); if (node.children) collect(node.children) })
collect(fileTree.value)
const restore = (nodes: FileNode[]) => nodes.forEach(node => { if (node.type === 'folder') node.is_open = open.get(node.path) ?? false; if (node.children) restore(node.children) })
restore(fresh)
// Avoid redrawing an unchanged tree on every background check.
if (JSON.stringify(fresh) !== JSON.stringify(fileTree.value)) fileTree.value = fresh
treeRefreshError.value = null
} catch (error) {
if (sequence === refreshSequence) treeRefreshError.value = error instanceof Error ? error.message : '文件树刷新失败'
throw error
}
}
async function openVault(path: string) {
refreshSequence++
isLoading.value = true
try {
const info = await workspaceService.openVault(path)
@@ -84,6 +103,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
}
async function createVault(path: string, name: string) {
refreshSequence++
isLoading.value = true
try {
const info = await workspaceService.createVault(path, name)
@@ -162,6 +182,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading,
hasVault,
recentVaults,
treeRefreshError,
toggleFolder,
openFile,
closeFile,
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useWorkspaceStore } from './workspace'
import * as service from '@/services/workspaceService'
beforeEach(() => { setActivePinia(createPinia()); vi.restoreAllMocks() })
it('fetches external entries while keeping folder state and ignoring stale responses', async () => {
const store = useWorkspaceStore()
store.hasVault = true; store.vaultPath = '/vault'
store.fileTree = [{ id: 'folder', path: '/folder', name: 'folder', type: 'folder', is_open: true, children: [] }]
let release!: (value: typeof store.fileTree) => void
vi.spyOn(service, 'refreshTree').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
.mockResolvedValueOnce([{ id: 'folder', path: '/folder', name: 'folder', type: 'folder', children: [{ id: 'new', path: '/folder/new.md', name: 'new.md', type: 'file' }] }])
const old = store.refreshFileTree()
await store.refreshFileTree()
expect(store.fileTree[0]!.is_open).toBe(true)
expect(store.fileTree[0]!.children).toHaveLength(1)
release([]); await old
expect(store.fileTree).toHaveLength(1)
})
+28
View File
@@ -0,0 +1,28 @@
/* Semantic tokens inherit every installed theme, including imported themes. */
:where(.markdown-callout) { --callout-color: var(--color-callout-info, var(--color-info)); }
.markdown-callout, .milkdown .ProseMirror blockquote.markdown-callout {
border: 1px solid color-mix(in srgb, var(--callout-color) 35%, transparent);
border-inline-start: 4px solid var(--callout-color);
border-radius: var(--radius-md, 8px);
background: color-mix(in srgb, var(--callout-color) 8%, var(--color-surface-primary, var(--paper, #fffdf7)));
color: var(--color-text-primary, var(--ink, inherit));
margin: 12px 0; padding: 12px 16px; min-width: 0;
}
.markdown-callout[data-callout='warning'], .markdown-callout[data-callout='question'] { --callout-color: var(--color-callout-warning, var(--color-warning)); }
.markdown-callout[data-callout='danger'], .markdown-callout[data-callout='failure'], .markdown-callout[data-callout='bug'] { --callout-color: var(--color-callout-danger, var(--color-error)); }
.markdown-callout[data-callout='success'], .markdown-callout[data-callout='tip'] { --callout-color: var(--color-callout-success, var(--color-success)); }
.markdown-callout[data-callout='abstract'], .markdown-callout[data-callout='important'], .markdown-callout[data-callout='example'] { --callout-color: var(--color-callout-important, var(--color-accent-secondary)); }
.markdown-callout[data-callout='quote'] { --callout-color: var(--color-callout-quote, var(--color-text-secondary)); }
.markdown-callout[data-callout='todo'] { --callout-color: var(--color-callout-info, var(--color-accent-primary)); }
.markdown-callout > .callout-title { color: var(--callout-color); font-weight: 700; line-height: 1.5; overflow-wrap: anywhere; }
.markdown-callout > button.callout-title { background: transparent; border: 0; padding: 0; width: 100%; text-align: start; cursor: pointer; }
.markdown-callout > button.callout-title:disabled { opacity: 1; cursor: default; color: var(--callout-color); }
.markdown-callout > summary.callout-title { cursor: pointer; }
.markdown-callout > :is(button, summary).callout-title:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 4px; border-radius: var(--radius-sm); }
.markdown-callout > :is(button:not(:disabled), summary).callout-title:hover { text-decoration: underline; text-underline-offset: 3px; }
.markdown-callout .callout-body { min-width: 0; overflow-wrap: anywhere; }
.markdown-callout .callout-body > :last-child { margin-bottom: 0; }
.markdown-callout[data-collapsed='true'] > .callout-body { display: none; }
.callout-marker { display: none; }
.callout-title[hidden] { display: none !important; }
.callout-marker-editing { opacity: .65; font-family: monospace; }
+19
View File
@@ -0,0 +1,19 @@
.heading-fold-hidden { display: none !important; }
.milkdown .ProseMirror .heading-fold-toggle { opacity: 0; pointer-events: none; transition: opacity 120ms ease; }
.milkdown .ProseMirror :is(h1,h2,h3,h4,h5,h6):hover > .heading-fold-toggle,
.milkdown .ProseMirror .heading-fold-toggle:focus-visible { opacity: 1; pointer-events: auto; }
@media (hover: none) { .milkdown .ProseMirror .heading-fold-toggle { opacity: 1; pointer-events: auto; } }
@media (prefers-reduced-motion: reduce) { .milkdown .ProseMirror .heading-fold-toggle { transition: none; } }
.milkdown .ProseMirror .heading-fold-toggle { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; width: 24px; height: 28px; padding: 0; margin-inline-end: 5px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); font: 16px/1 system-ui; cursor: pointer; user-select: none; }
.milkdown .ProseMirror .heading-fold-toggle:hover { background: var(--color-background-hover); color: var(--color-accent-primary); }
.milkdown .ProseMirror .heading-fold-toggle:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) :is(h1,h2,h3,h4,h5,h6) { font-family: var(--heading-family) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h1 { font-size: var(--heading-1-size) !important; font-weight: var(--heading-1-weight) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h2 { font-size: var(--heading-2-size) !important; font-weight: var(--heading-2-weight) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h3 { font-size: var(--heading-3-size) !important; font-weight: var(--heading-3-weight) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h4 { font-size: var(--heading-4-size) !important; font-weight: var(--heading-4-weight) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h5 { font-size: var(--heading-5-size) !important; font-weight: var(--heading-5-weight) !important; }
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h6 { font-size: var(--heading-6-size) !important; font-weight: var(--heading-6-weight) !important; }
.milkdown .ProseMirror .heading-fold-toggle::before { content: ''; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); }
.milkdown .ProseMirror .heading-fold-toggle[aria-expanded='false']::before { transform: rotate(-45deg); }
+44
View File
@@ -22,3 +22,47 @@ it.each(mockCommunityThemes)('provides interaction and Markdown colors in $theme
}
expect(css).toContain(`color-scheme: ${theme.is_dark ? 'dark' : 'light'}`)
})
const calloutThemes = ['light', 'dark', 'sepia', ...mockCommunityThemes.map(theme => theme.theme_id)]
const calloutTones = ['info', 'success', 'warning', 'danger', 'important', 'quote']
it.each(calloutThemes)('keeps callout headings readable against tinted surfaces in %s', themeId => {
const doc = document.implementation.createHTMLDocument('theme contrast')
const style = doc.createElement('style')
style.textContent = files['styles/tokens.css'] ?? files['styles\\tokens.css']!
style.textContent += getCommunityThemePreviewCss(themeId)
doc.head.append(style)
const values = new Map<string, string>()
for (const rule of Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]) {
if (![':root', `[data-theme='${themeId}']`, `[data-theme="${themeId}"]`].includes(rule.selectorText)) continue
for (const name of ['--color-surface-primary', ...calloutTones.map(tone => `--color-callout-${tone}`)]) {
const value = rule.style.getPropertyValue(name).trim()
if (value) values.set(name, value)
}
}
const rgb = (hex: string) => [1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255)
const luminance = (color: number[]) => color.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0)
const surface = rgb(values.get('--color-surface-primary')!)
for (const tone of calloutTones) {
const hex = values.get(`--color-callout-${tone}`)!
expect(hex).toMatch(/^#[\da-f]{6}$/i)
const ink = rgb(hex)
const background = surface.map((value, index) => value * .92 + ink[index]! * .08)
const first = luminance(ink), second = luminance(background)
expect((Math.max(first, second) + .05) / (Math.min(first, second) + .05), tone).toBeGreaterThanOrEqual(4.5)
}
})
it('preserves warning and success colors inside the editor selector cascade', () => {
const style = document.createElement('style')
style.textContent = readFileSync(join(root, 'styles/callouts.css'), 'utf8')
document.head.append(style)
const host = document.createElement('div')
host.className = 'milkdown'
host.innerHTML = '<div class="ProseMirror"><blockquote class="markdown-callout" data-callout="warning"></blockquote><blockquote class="markdown-callout" data-callout="success"></blockquote></div>'
document.body.append(host)
try {
const blocks = host.querySelectorAll('blockquote')
expect(getComputedStyle(blocks[0]!).getPropertyValue('--callout-color')).toContain('--color-callout-warning')
expect(getComputedStyle(blocks[1]!).getPropertyValue('--callout-color')).toContain('--color-callout-success')
} finally { host.remove(); style.remove() }
})
+28
View File
@@ -1,4 +1,11 @@
:root {
/* Callout heading colors also serve as borders; keep text readable on tint. */
--color-callout-info: var(--color-info);
--color-callout-success: var(--color-success);
--color-callout-warning: var(--color-warning);
--color-callout-danger: var(--color-error);
--color-callout-important: var(--color-accent-secondary);
--color-callout-quote: var(--color-text-secondary);
/* Background */
--color-background-primary: #ffffff;
--color-background-secondary: #f7f8fa;
@@ -117,7 +124,22 @@
--statusbar-height: 28px;
}
[data-theme='light'] {
--color-callout-info: #175da6;
--color-callout-success: #236b3b;
--color-callout-warning: #855700;
--color-callout-danger: #ad2935;
--color-callout-important: #7443ad;
--color-callout-quote: #59636e;
}
[data-theme='dark'] {
--color-callout-info: #8bbdff;
--color-callout-success: #80ce93;
--color-callout-warning: #efc66f;
--color-callout-danger: #ff9b9b;
--color-callout-important: #c8a5ff;
--color-callout-quote: #abb6c2;
--color-background-primary: #0d1117;
--color-background-secondary: #161b22;
--color-background-tertiary: #21262d;
@@ -168,6 +190,12 @@
}
[data-theme='sepia'] {
--color-callout-info: #396578;
--color-callout-success: #496b3b;
--color-callout-warning: #805918;
--color-callout-danger: #a04438;
--color-callout-important: #785476;
--color-callout-quote: #746653;
--color-background-primary: #fbf3df;
--color-background-secondary: #f4e8ca;
--color-background-tertiary: #eadbb8;
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { calloutTypes, parseCallout } from './callouts'
import { renderMarkdown } from './markdown'
import { defaultMarkdownPreferences } from '@/stores/markdownPreferences'
describe('callouts', () => {
it('keeps disabled syntax literal without leaking settings between render requests', async () => {
const source = '> [!WARNING]\n> text\n\n$x$\n\nhttps://example.com'
const [plain, extended] = await Promise.all([
renderMarkdown(source, { preferences: { ...defaultMarkdownPreferences, callouts: false, math: false, autoLinks: false } }),
renderMarkdown(source),
])
expect(plain).not.toContain('markdown-callout')
expect(plain).not.toContain('katex')
expect(plain).not.toContain('<a ')
expect(extended).toContain('markdown-callout')
expect(extended).toContain('katex')
expect(extended).toContain('<a ')
})
for (const [type, aliases] of Object.entries(calloutTypes)) {
for (const alias of aliases) it(`renders ${alias}`, async () => {
const root = document.createElement('div')
root.innerHTML = await renderMarkdown(`> [!${alias.toUpperCase()}] 标题\n> **正文** 与 \`code\`\n>\n> - 条目`)
expect(root.querySelector('.markdown-callout')?.getAttribute('data-callout')).toBe(type)
expect(root.querySelector('.callout-title')?.textContent).toBe('标题')
expect(root.querySelector('strong')?.textContent).toBe('正文')
expect(root.querySelector('li')?.textContent).toBe('条目')
})
}
it('supports folding, nesting, unknown types, empty bodies and safe titles', async () => {
const root = document.createElement('div')
root.innerHTML = await renderMarkdown('> [!WARNING]- 外层\n>\n> > [!tip]+ 内层\n> > 内容\n\n> [!custom] <img src=x onerror=alert(1)>\n\n> [!NOTE]')
expect(root.querySelector('details')?.hasAttribute('open')).toBe(false)
expect(root.querySelector('details details')?.hasAttribute('open')).toBe(true)
expect(root.querySelectorAll('.markdown-callout')).toHaveLength(4)
expect(root.querySelector('img')).toBeNull()
expect(root.textContent).toContain('<img src=x onerror=alert(1)>')
})
it('renders the editor serialization of nested folded callouts', async () => {
const root = document.createElement('div')
root.innerHTML = await renderMarkdown('> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容\n')
expect(root.querySelectorAll('.markdown-callout')).toHaveLength(2)
expect(root.querySelector('strong')?.textContent).toBe('正文')
})
it('does not convert ordinary quotes, inline code, escaped markers or fenced examples', async () => {
const root = document.createElement('div')
root.innerHTML = await renderMarkdown('> ordinary\n\n> \\[!NOTE]\n\n`[!TIP]`\n\n```text\n> [!WARNING]\n```')
expect(root.querySelector('.markdown-callout')).toBeNull()
expect(root.querySelectorAll('blockquote')).toHaveLength(2)
expect(parseCallout('prefix [!NOTE]')).toBeNull()
})
})
+21
View File
@@ -0,0 +1,21 @@
/** GitHub alerts and Obsidian callouts share the same portable Markdown syntax. */
export const calloutTypes = {
note: ['note'], abstract: ['abstract', 'summary', 'tldr'], info: ['info'],
todo: ['todo'], tip: ['tip', 'hint'], important: ['important'], success: ['success', 'check', 'done'],
question: ['question', 'help', 'faq'], warning: ['warning', 'caution', 'attention'],
failure: ['failure', 'fail', 'missing'], danger: ['danger', 'error'], bug: ['bug'],
example: ['example'], quote: ['quote', 'cite'],
} as const
export function parseCallout(text: string) {
const match = /^\[!([\w-]+)\]([+-]?)[ \t]*([^\n]*)(?:\n|$)/.exec(text)
if (!match) return null
const name = match[1]!.toLowerCase()
const type = Object.entries(calloutTypes).find(([, aliases]) => (aliases as readonly string[]).includes(name))?.[0] ?? 'note'
return { name, type, title: match[3]!.trim() || name.charAt(0).toUpperCase() + name.slice(1),
fold: match[2] || null, markerLength: match[0].replace(/\n$/, '').length, body: text.slice(match[0].length) }
}
export function escapeCalloutTitle(text: string) {
return text.replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
}
+27 -5
View File
@@ -1,5 +1,6 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { Marked } from 'marked'
import { defaultMarkdownPreferences, type MarkdownPreferences } from '@/stores/markdownPreferences'
import { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs'
@@ -9,6 +10,8 @@ import { renderMermaid } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { parseCallout, escapeCalloutTitle } from './callouts'
import '@/styles/callouts.css'
function mathHtml(source: string, displayMode: boolean) {
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
@@ -16,7 +19,21 @@ function mathHtml(source: string, displayMode: boolean) {
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
}
marked.use({extensions:[
function createMarkdownParser(preferences: MarkdownPreferences) {
const marked = new Marked()
marked.use({ renderer: { blockquote(token) {
if (!preferences.callouts) return false
const callout = parseCallout(token.text)
if (!callout) return false
const title = escapeCalloutTitle(callout.title)
const body = marked.parse(callout.body, { async: false }) as string
const attributes = `class="markdown-callout" data-callout="${callout.type}"`
return callout.fold
? `<details ${attributes}${callout.fold === '+' ? ' open' : ''}><summary class="callout-title">${title}</summary><div class="callout-body">${body}</div></details>`
: `<aside ${attributes}><div class="callout-title">${title}</div><div class="callout-body">${body}</div></aside>`
} } })
if (preferences.math) marked.use({extensions:[
{name:'blockMath',level:'block',tokenizer(source) {
const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(source)
if (match) return {type:'blockMath',raw:match[0],text:match[1]!.trim()}
@@ -30,6 +47,9 @@ marked.use({extensions:[
]})
marked.setOptions({ gfm: true, breaks: true })
if (!preferences.autoLinks) marked.use({ tokenizer: { url() { return undefined } } })
return marked
}
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
let highlighter: ReturnType<typeof createHighlighterCore> | undefined
@@ -82,7 +102,9 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
}
}
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark' }): Promise<string> {
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark'; preferences?: MarkdownPreferences }): Promise<string> {
const preferences = options?.preferences ?? defaultMarkdownPreferences
const marked = createMarkdownParser(preferences)
const html = marked.parse(source, { async: false }) as string
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
@@ -90,11 +112,11 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
if (requestedLanguage === 'mermaid') {
if (requestedLanguage === 'mermaid' && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
continue
}
if (requestedLanguage.toLowerCase() === 'latex') {
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true)))
continue
}
+1
View File
@@ -6,5 +6,6 @@
- `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。
- `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。
- `/tests/visual/mermaid-matrix.html`:真实 Mermaid(无渲染 mock)的 6 图型 × 6 主题矩阵。顶部报告检查结果;加 `?theme=paper-moments` 可查看单主题外观。
- `/tests/visual/callouts.html?theme=paper-moments`:工作区与静态预览的警告框类型、嵌套和折叠对照;可切换上述六个主题,不读写用户笔记。
运行自动回归:`pnpm test`。AppDialog 测试覆盖滚动锁引用计数、恢复焦点、禁止隐式关闭;主题预览矩阵覆盖六主题的实际共享 CSS、控件状态和 CSP/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。
+21
View File
@@ -0,0 +1,21 @@
<!doctype html><html><head><meta charset="utf-8"><title>警告框渲染验收</title></head>
<body><div id="app"></div><script type="module">
import { createApp, h } from 'vue';
import { createPinia } from 'pinia';
import Editor from '/src/features/editor/VisualMarkdownEditor.vue';
import MarkdownContent from '/src/components/common/MarkdownContent.vue';
import { calloutTypes } from '/src/utils/callouts.ts';
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts';
import '/src/styles/tokens.css';
import '/src/styles/features.css';
const theme = new URLSearchParams(location.search).get('theme') || 'light';
document.documentElement.dataset.theme = theme;
const style = document.createElement('style');
style.textContent = getCommunityThemePreviewCss(theme) || '';
document.head.append(style);
const source = Object.keys(calloutTypes).map(type => `> [!${type.toUpperCase()}] ${type} 提示\n> 正文 **粗体** 与 \`code\`\n>\n> - 列表项`).join('\n\n') + '\n\n> [!WARNING]- 折叠提示\n>\n> > [!TIP]+ 嵌套提示\n> > 点击展开后可见';
createApp({ render: () => h('main', {style:'max-width:1200px;margin:auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:24px'}, [
h('section', [h('h1', '工作区'), h(Editor, {initialContent:source})]),
h('section', [h('h1', '静态预览'), h(MarkdownContent, {source})]),
]) }).use(createPinia()).mount('#app');
</script></body></html>
+29
View File
@@ -1,6 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { readFileSync } from 'node:fs'
const mathChunks = new Map<string, string>()
function mathChunk(module: string) {
const marker = '/node_modules/katex/'
const root = module.slice(0, module.lastIndexOf(marker) + marker.length)
if (!mathChunks.has(root)) {
const { version } = JSON.parse(readFileSync(`${root}package.json`, 'utf8'))
mathChunks.set(root, `math-katex-${version.replace(/[^0-9a-z]/gi, '-')}`)
}
return mathChunks.get(root)!
}
export default defineConfig({
plugins: [vue()],
@@ -9,6 +21,23 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src'),
},
},
build: {
manifest: true,
rollupOptions: {
output: {
// Keep lazy languages/diagrams independent; do not collect every vendor into one bundle.
onlyExplicitManualChunks: true,
manualChunks(id) {
const module = id.replace(/\\/g, '/')
if (!module.includes('/node_modules/')) return
if (/\/@codemirror\/(?:state|view|language|commands|search|autocomplete|lint)\//.test(module) || /\/@lezer\/(?:common|highlight|lr)\//.test(module) || module.includes('/node_modules/codemirror/')) return 'editor-codemirror'
if (/\/node_modules\/prosemirror-[^/]+\//.test(module)) return 'editor-prosemirror'
if (module.includes('/node_modules/@milkdown/')) return 'editor-milkdown'
if (module.includes('/node_modules/katex/')) return mathChunk(module)
},
},
},
},
server: {
host: '127.0.0.1',
port: 5173,