Merge pull request 'Perf/frontend chunk loading优化前端构建加载,完善 Markdown 预设、警告框、章节折叠与外部文件刷新' (#32) from perf/frontend-chunk-loading into main

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-09-06 13:42:57 +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 title: str | None = None
markdown: str | None = None markdown: str | None = None
tags: list[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): 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"]) @router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
async def get_workspace_tree() -> list[WorkspaceEntry]: 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"]) @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"]) @router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
async def update_note(note_id: str, request: NoteUpdateRequest) -> Note: async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
return await note_service.update_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) 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: async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。""" """打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。"""
@@ -16,3 +16,5 @@ tags: RAG, 产品
粗排后使用 Reranker 对候选块重新打分,提升相关性。 粗排后使用 Reranker 对候选块重新打分,提升相关性。
<br />
@@ -2,20 +2,20 @@
title: 功能演示导航 title: 功能演示导航
tags: 演示, 入门 tags: 演示, 入门
--- ---
# 功能演示导航 # 功能演示导航
这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。 这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。
## 建议阅读顺序 ## 建议阅读顺序
| 笔记 | 可以查看的功能 | | 笔记 | 可以查看的功能 |
| --- | --- | | ----------------------------- | ---------------------- |
| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 | | 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 |
| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 | | 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 |
| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 | | 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 |
| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 | | 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 |
| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 | | 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 |
| [06 警告框与提示框](06%20警告框与提示框.md) | 类型与别名、标题、折叠、嵌套和主题配色 |
## 工作区操作 ## 工作区操作
@@ -35,3 +35,4 @@ tags: 演示, 入门
- [ ] 在已配置模型后进行一次带知识库检索的问答。 - [ ] 在已配置模型后进行一次带知识库检索的问答。
> 上述清单供体验时自行勾选,不是自动验收结果。模型调用可能产生费用,图表与代码示例本身不会执行代码。 > 上述清单供体验时自行勾选,不是自动验收结果。模型调用可能产生费用,图表与代码示例本身不会执行代码。
@@ -2,7 +2,6 @@
title: 星灯资料站项目简报 title: 星灯资料站项目简报
tags: 演示, 星灯项目, 检索 tags: 演示, 星灯项目, 检索
--- ---
# 星灯资料站 # 星灯资料站
星灯资料站是本组演示中的虚构项目,目标是为一个读书小组建立离线可用的学习资料目录。项目代号为 ST-27。 星灯资料站是本组演示中的虚构项目,目标是为一个读书小组建立离线可用的学习资料目录。项目代号为 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/workspace/folders/delete",
"/api/notes/{note_id}/rename", "/api/notes/{note_id}/rename",
} <= paths.keys() } <= 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:开发说明
- [前端构建分块优化开发说明](development/前端构建分块优化开发说明.md)
- [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md) - [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md)
- [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md) - [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md)
- [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md) - [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md)
- [模型上下文管理](development/模型上下文管理.md) - [模型上下文管理](development/模型上下文管理.md)
- [Markdown 渲染检查](development/Markdown渲染检查.md) - [Markdown 渲染检查](development/Markdown渲染检查.md)
- [警告框与桌面编辑命令开发说明](development/警告框与桌面编辑命令开发说明.md)
- [标题折叠与样式开发说明](development/标题折叠与样式开发说明.md)
- [主题组件覆盖检查](development/主题组件覆盖检查.md) - [主题组件覆盖检查](development/主题组件覆盖检查.md)
- [第二阶段补充验收工具](development/第二阶段补充验收工具.md) - [第二阶段补充验收工具](development/第二阶段补充验收工具.md)
@@ -89,3 +93,5 @@
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。 - 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。 - `.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 输入与转换规则 ### 2.2 输入与转换规则
+3 -1
View File
@@ -30,7 +30,9 @@
| 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 | | 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 |
| 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 | | 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 |
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。 源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
2026-09-06 补充:GitHub alerts 与 Obsidian callout 已在工作区和静态预览接入,包含常用类型/别名、自定义标题、嵌套与折叠。语法、命令接口与验证方法见 [警告框与桌面编辑命令开发说明](警告框与桌面编辑命令开发说明.md)。
自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。 自动检查覆盖解析、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`,授权与来源说明随目录保存。 编辑器使用 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、密码等结构化或敏感输入保持关闭。 语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
## 数据边界 ## 数据边界
@@ -86,3 +90,9 @@ pnpm build
Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。 Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。
开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。 开发和验证方法见 [后台索引与保存](../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", "build": "vue-tsc -b && vite build",
"test": "vitest run", "test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit",
"build:report": "node scripts/build-size.mjs"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "6.11.0", "@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 theme_id: paper-moments
name: 纸间时光 · Paper Moments name: 纸间时光 · Paper Moments
version: 1.6.2 version: 1.8.0
author: NotesAgent author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0 min_app_version: 0.2.0
@@ -9,6 +9,12 @@ css_entry: theme.css
license: MIT license: MIT
--- ---
[data-theme="paper-moments"] { [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-scheme: light;
--color-background-primary: #faf7ee; --color-background-primary: #faf7ee;
--color-background-secondary: #f3eee3; --color-background-secondary: #f3eee3;
@@ -322,3 +328,15 @@ license: MIT
/* Nested choices retain a quiet paper border without repeating tape/shadows. */ /* 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 { 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); } [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 CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService' import { getIndexStatus } from '@/services/indexService'
import { navigateToCitation } from '@/composables/useCitationNavigation' import { navigateToCitation } from '@/composables/useCitationNavigation'
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
useWorkspaceRefresh()
defineProps<{ defineProps<{
showSecondarySidebar?: boolean showSecondarySidebar?: boolean
@@ -3,6 +3,10 @@ import DiagramInteractions from './DiagramInteractions.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme' 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 props = defineProps<{ source: string }>()
const themeStore = useThemeStore() const themeStore = useThemeStore()
@@ -12,15 +16,15 @@ let renderVersion = 0
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light')) const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
// Mermaid SVG CSS // 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 version = ++renderVersion
const result = await renderMarkdown(source, { theme }) const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized })
if (version === renderVersion) html.value = result if (version === renderVersion) html.value = result
}, { immediate: true, flush: 'post' }) }, { immediate: true, flush: 'post' })
</script> </script>
<template> <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> </template>
<style> <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"> <script setup lang="ts">
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace' 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' import { t } from '@/i18n'
const editorStore = useEditorStore() const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore() 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>>(() => ({ const statusText = computed<Record<string, string>>(() => ({
idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'), 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> <template>
<header class="editor-header"> <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="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions"> <div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span> <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')"> <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 === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button> <button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
</div> </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> </div>
</header> </header>
</template> </template>
@@ -40,6 +74,7 @@ const statusText = computed<Record<string, string>>(() => ({
.file-identity { display: grid; min-width: 0; } .file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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); } .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); } .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 { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); } .save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
+1 -1
View File
@@ -24,7 +24,7 @@ function updateContent(event: Event) {
</script> </script>
<template> <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" /> :initial-content="editorStore.content" />
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck" <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" /> :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 { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view' import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown' 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 } type EditorComponent = { getEditor: () => Editor | undefined }
@@ -51,6 +55,111 @@ afterEach(() => {
}) })
describe('VisualMarkdownEditor formatting toolbars', () => { 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 () => { it('renders the supported format matrix and preserves inline code', async () => {
const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6', const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
'正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二', '正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二',
@@ -4,11 +4,13 @@ import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog() const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue' import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from '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 { Crepe } from '@milkdown/crepe'
import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror' 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 { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror' import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css' import './language-icons.css'
@@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels' import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview' import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown } from '@milkdown/kit/utils' import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
import { import {
createCodeBlockCommand, createCodeBlockCommand,
toggleEmphasisCommand, toggleEmphasisCommand,
@@ -28,8 +30,10 @@ import {
wrapInHeadingCommand, wrapInHeadingCommand,
wrapInOrderedListCommand, wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark' } from '@milkdown/kit/preset/commonmark'
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core' import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state' 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 { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
@@ -37,11 +41,18 @@ import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown' import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { inlineCodeInputPlugin } from './inlineCodeInput' 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 { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>() const props = defineProps<{ initialContent: string }>()
const headingAppearance = useHeadingAppearanceStore()
const markdownPreferences = { ...useMarkdownPreferencesStore().normalized }
const metadata = ref(splitNoteMetadata(props.initialContent)) const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('') const tagDraft = ref('')
function setTags(tags: string[]) { function setTags(tags: string[]) {
@@ -63,10 +74,82 @@ const settingsStore = useSettingsStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null) const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true) const loading = ref(true)
const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false)
const fontSizeInput = ref(16) const fontSizeInput = ref(16)
let crepe: Crepe | null = null let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => 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 }>() const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
function renderDiagram(source: string, apply: (value: HTMLElement) => void) { function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
for (const [id, entry] of diagramPreviews) { for (const [id, entry] of diagramPreviews) {
@@ -114,7 +197,7 @@ function runCommand(command: ToolbarCommand) {
'ordered-list': callCommand(wrapInOrderedListCommand.key), 'ordered-list': callCommand(wrapInOrderedListCommand.key),
'bullet-list': callCommand(wrapInBulletListCommand.key), 'bullet-list': callCommand(wrapInBulletListCommand.key),
'inline-code': callCommand(toggleInlineCodeCommand.key), 'inline-code': callCommand(toggleInlineCodeCommand.key),
'code-block': callCommand(createCodeBlockCommand.key, ''), 'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage),
'inline-math': callCommand('ToggleLatex'), 'inline-math': callCommand('ToggleLatex'),
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'), 'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
} }
@@ -181,7 +264,7 @@ onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: editorRoot.value, root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent, defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false }, features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: { [Crepe.Feature.CodeMirror]: {
@@ -245,12 +328,54 @@ onMounted(async () => {
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage, renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? renderDiagram(content, applyPreview) ? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
: config.renderPreview(language, content, applyPreview), : 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(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin) 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) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// / // /
@@ -265,6 +390,7 @@ onMounted(async () => {
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value) if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences() applyProofingPreferences()
loading.value = false loading.value = false
if (!disposed) installCommands()
}) })
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences) 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 }) defineExpose({ getEditor: () => crepe?.editor })
</script> </script>
<template> <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" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')"> <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')"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading"> <select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
@@ -321,9 +456,15 @@ defineExpose({ getEditor: () => crepe?.editor })
<span class="toolbar-divider" /> <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('行内代码', '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('代码块', '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 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 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('公式块', '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> <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>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div> <div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div class="milkdown-host" :class="{ loading }"> <div class="milkdown-host" :class="{ loading }">
@@ -343,10 +484,16 @@ defineExpose({ getEditor: () => crepe?.editor })
<style scoped> <style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); } .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 { 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 { 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: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; } .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; } .format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
.heading-glyph { font-weight: 800; } .heading-glyph { font-weight: 800; }
.font-size-glyph { font-size: 18px; } .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 { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts' import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue' 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 ChatPersonaDialog from '@/features/chat/ChatPersonaDialog.vue'
import ProviderLogo from './ProviderLogo.vue' import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue' import ModelRoutingSettings from './ModelRoutingSettings.vue'
@@ -85,7 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" /> <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-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"> <div v-else-if="activeSection === 'providers'" class="settings-section">
<section class="panel provider-settings-card"> <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. // 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/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/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 CommunityThemePreview from './CommunityThemePreview.vue'
import { mockCommunityThemes } from '@/services/themePackageService' 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}))] 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') const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id) expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
expect(doc.querySelector('script')).toBeNull() 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'") 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() 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') expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
@@ -5,6 +5,9 @@ import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService' import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw' import tokensCss from '@/styles/tokens.css?raw'
import featuresCss from '@/styles/features.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' import specimenHtml from './themeSpecimen.html?raw'
const props = defineProps<{ themeId: string; name?: string; css?: string }>() 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'" 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) doc.head.append(policy)
const style = doc.createElement('style') 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) doc.head.append(style)
const article = doc.createElement('article') 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') const button = doc.createElement('button'); button.className = 'button-primary'; button.textContent = t('示例按钮', 'Example button')
journal.append(text) journal.append(text)
const specimen = doc.createElement('template'); specimen.innerHTML = specimenHtml 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) article.append(header, journal, button, specimen.content); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML 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 () => { it('offers and applies the paper theme update without discarding the active theme', async () => {
const store = useThemeStore() 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) await store.installThemeFromInspection(old.manifest, old.css)
store.applyTheme('paper-moments') store.applyTheme('paper-moments')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) 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'))! 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 card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
await flushPromises() 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') expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
}) })
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
import AppDialog from '@/components/common/AppDialog.vue' import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.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('字体', '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 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> </div>
<HeadingStyleSettings />
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"> <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> <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> <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> <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> <p class="subtle specimen-long">超长模型标识:provider/model-with-a-very-long-identifier-for-layout-validation-012345678901234567890123456789</p>
</div> </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> <template>
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch"> <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" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs"> <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> <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 { computed } from 'vue'
import { useThemeStore } from '@/stores/theme' 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({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
theme: 'base', theme: 'base',
@@ -34,6 +41,7 @@ function ensureInitialized(theme: 'light' | 'dark') {
sequence: { useMaxWidth: true }, sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true }, gantt: { useMaxWidth: true },
}) })
return mermaid
} }
let queue: Promise<unknown> = Promise.resolve() let queue: Promise<unknown> = Promise.resolve()
function serialized<T>(work: () => Promise<T>): Promise<T> { function serialized<T>(work: () => Promise<T>): Promise<T> {
@@ -66,9 +74,9 @@ async function renderMermaidNow(
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {} options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
): Promise<MermaidRenderResult> { ): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light' const theme = options.theme ?? 'light'
ensureInitialized(theme)
const id = `mermaid-${Date.now()}-${++renderCounter}` const id = `mermaid-${Date.now()}-${++renderCounter}`
try { try {
const mermaid = await ensureInitialized(theme)
const result = await mermaid.render(id, source) const result = await mermaid.render(id, source)
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml') 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 }> { export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> {
try { 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 } return { valid: true }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '未知错误' const message = error instanceof Error ? error.message : '未知错误'
+1 -1
View File
@@ -25,7 +25,7 @@ export async function createNote(data: {
export async function updateNote( export async function updateNote(
noteId: string, noteId: string,
data: { title?: string; markdown?: string; tags?: string[] } data: { title?: string; markdown?: string; tags?: string[]; expected_content_hash?: string }
): Promise<ApiNote> { ): Promise<ApiNote> {
return apiClient.patch(`/api/notes/${noteId}`, data) return apiClient.patch(`/api/notes/${noteId}`, data)
} }
+8 -2
View File
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{ {
theme_id: 'ocean-blue', theme_id: 'ocean-blue',
name: 'Ocean Blue', name: 'Ocean Blue',
version: '1.3.1', version: '1.5.0',
author: 'community', author: 'community',
description: '宁静的海洋蓝色主题,适合长时间阅读', description: '宁静的海洋蓝色主题,适合长时间阅读',
min_app_version: '0.2.0', min_app_version: '0.2.0',
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{ {
theme_id: 'midnight-purple', theme_id: 'midnight-purple',
name: 'Midnight Purple', name: 'Midnight Purple',
version: '2.1.1', version: '2.3.0',
author: 'night-owl', author: 'night-owl',
description: '深紫色暗夜主题,适合编码', description: '深紫色暗夜主题,适合编码',
min_app_version: '0.2.0', min_app_version: '0.2.0',
@@ -464,6 +464,12 @@ export function getCommunityThemePreviewCss(themeId: string): string {
return buildCommunityThemeCss(themeId, t.is_dark) + ` return buildCommunityThemeCss(themeId, t.is_dark) + `
[data-theme="${themeId}"] { [data-theme="${themeId}"] {
color-scheme: ${t.is_dark ? 'dark' : 'light'}; 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-inverse: ${t.is_dark ? '#1a1b26' : '#ffffff'};
--color-text-disabled: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-surface-primary)); --color-text-disabled: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-surface-primary));
--color-background-overlay: ${t.is_dark ? '#000000a6' : '#00000073'}; --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 cachedTree: FileNode[] | null = null
let treeRequestVersion = 0
const noteIdByPath = new Map<string, string>() const noteIdByPath = new Map<string, string>()
const typeByPath = new Map<string, FileNode['type']>() 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> { export async function openVault(path: string): Promise<VaultInfo> {
treeRequestVersion++
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 }) const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items) cacheEntries(snapshot.items)
return { return {
@@ -104,7 +106,9 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
} }
export async function refreshTree(): Promise<FileNode[]> { 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) return cacheEntries(entries)
} }
@@ -122,10 +126,12 @@ export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath) 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 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), { await noteService.updateNote(await requireNoteId(filePath), {
markdown: content, markdown: content,
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
// Explicit [] clears the index; absent tags retain API-managed tags. // Explicit [] clears the index; absent tags retain API-managed tags.
...(metadata?.hasTags ? { tags: metadata.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 type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService' import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n' import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg') const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
const content = ref('') const content = ref('')
const contentRevision = ref(0)
let diskContent: string | undefined
const saveStatus = ref<SaveStatus>('idle') const saveStatus = ref<SaveStatus>('idle')
const lastSavedAt = ref<string | null>(null) const lastSavedAt = ref<string | null>(null)
const currentNoteId = ref<string | null>(null) const currentNoteId = ref<string | null>(null)
@@ -35,13 +38,14 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) { function updateContent(newContent: string) {
content.value = newContent 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 saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) { function scheduleAutoSave(delay = 1500) {
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
saveTimer = null saveTimer = null
@@ -51,20 +55,23 @@ export const useEditorStore = defineStore('editor', () => {
async function save() { async function save() {
if (!currentFilePath.value) return if (!currentFilePath.value) return
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (pendingSave) return pendingSave if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。 // 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value const targetPath = currentFilePath.value
const snapshot = content.value const snapshot = content.value
const baseline = diskContent
saveStatus.value = 'saving' saveStatus.value = 'saving'
pendingSave = (async () => { pendingSave = (async () => {
try { try {
await workspaceService.saveFileContent(targetPath, snapshot) await workspaceService.saveFileContent(targetPath, snapshot, baseline)
if (currentFilePath.value === targetPath) { if (currentFilePath.value === targetPath) {
diskContent = snapshot
saveStatus.value = content.value === snapshot ? 'saved' : 'dirty' saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
} }
} catch { } catch (error) {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed' if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
} finally { } finally {
pendingSave = null pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave() if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
@@ -86,7 +93,7 @@ export const useEditorStore = defineStore('editor', () => {
} }
if (pendingSave) await pendingSave if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save() 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.')) 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 currentFilePath.value = filePath
currentNoteId.value = loadedNoteId currentNoteId.value = loadedNoteId
content.value = loadedContent content.value = loadedContent
diskContent = loadedContent
saveStatus.value = 'saved' saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
} catch (error) { } catch (error) {
@@ -122,14 +130,44 @@ export const useEditorStore = defineStore('editor', () => {
} }
function setExternalChanged() { 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' saveStatus.value = 'conflict'
} else { } else {
saveStatus.value = 'external_changed' 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() { function closeFile() {
loadVersion++ loadVersion++
@@ -137,6 +175,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = null currentFilePath.value = null
currentNoteId.value = null currentNoteId.value = null
content.value = '' content.value = ''
diskContent = undefined
saveStatus.value = 'idle' saveStatus.value = 'idle'
lastSavedAt.value = null lastSavedAt.value = null
highlightBlockId.value = null highlightBlockId.value = null
@@ -153,6 +192,10 @@ export const useEditorStore = defineStore('editor', () => {
jumpToHeading, jumpToHeading,
mode, mode,
content, content,
contentRevision,
checkExternalFile,
reloadExternalFile,
discardExternalChanges,
saveStatus, saveStatus,
lastSavedAt, lastSavedAt,
currentNoteId, currentNoteId,
+25 -1
View File
@@ -6,6 +6,30 @@ import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() }) 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 () => { it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers() vi.useFakeTimers()
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -20,6 +44,6 @@ it('saves text typed while the previous save is still pending', async () => {
await saving await saving
expect(store.saveStatus).toBe('dirty') expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500) await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest') expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest', 'first')
expect(store.saveStatus).toBe('saved') 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 { defineStore } from 'pinia'
import { useHeadingAppearanceStore } from './headingAppearance'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts' import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService' import * as themePkg from '@/services/themePackageService'
import { t } from '@/i18n' import { t } from '@/i18n'
const builtinThemes = (): ThemeConfig[] => [ 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: '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.1.1', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' }, { 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.1.1', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, { 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' export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
@@ -158,6 +159,7 @@ export const useThemeStore = defineStore('theme', () => {
} }
function resetToDefault() { function resetToDefault() {
useHeadingAppearanceStore().reset()
applyTheme('light') applyTheme('light')
fontEditorSize.value = 15 fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui' fontEditorFamily.value = 'system-ui'
+22 -1
View File
@@ -13,6 +13,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const isLoading = ref(false) const isLoading = ref(false)
const hasVault = ref(false) const hasVault = ref(false)
const recentVaults = ref<workspaceService.VaultInfo[]>([]) const recentVaults = ref<workspaceService.VaultInfo[]>([])
const treeRefreshError = ref<string | null>(null)
let refreshSequence = 0
const activeFile = computed(() => { const activeFile = computed(() => {
if (!activeFilePath.value) return null if (!activeFilePath.value) return null
@@ -65,10 +67,27 @@ export const useWorkspaceStore = defineStore('workspace', () => {
/** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */ /** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */
async function refreshFileTree() { async function refreshFileTree() {
if (!hasVault.value) return 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) { async function openVault(path: string) {
refreshSequence++
isLoading.value = true isLoading.value = true
try { try {
const info = await workspaceService.openVault(path) const info = await workspaceService.openVault(path)
@@ -84,6 +103,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
} }
async function createVault(path: string, name: string) { async function createVault(path: string, name: string) {
refreshSequence++
isLoading.value = true isLoading.value = true
try { try {
const info = await workspaceService.createVault(path, name) const info = await workspaceService.createVault(path, name)
@@ -162,6 +182,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading, isLoading,
hasVault, hasVault,
recentVaults, recentVaults,
treeRefreshError,
toggleFolder, toggleFolder,
openFile, openFile,
closeFile, 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'}`) 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 { :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 */ /* Background */
--color-background-primary: #ffffff; --color-background-primary: #ffffff;
--color-background-secondary: #f7f8fa; --color-background-secondary: #f7f8fa;
@@ -117,7 +124,22 @@
--statusbar-height: 28px; --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'] { [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-primary: #0d1117;
--color-background-secondary: #161b22; --color-background-secondary: #161b22;
--color-background-tertiary: #21262d; --color-background-tertiary: #21262d;
@@ -168,6 +190,12 @@
} }
[data-theme='sepia'] { [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-primary: #fbf3df;
--color-background-secondary: #f4e8ca; --color-background-secondary: #f4e8ca;
--color-background-tertiary: #eadbb8; --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 DOMPurify from 'dompurify'
import { marked } from 'marked' import { Marked } from 'marked'
import { defaultMarkdownPreferences, type MarkdownPreferences } from '@/stores/markdownPreferences'
import { createHighlighterCore } from 'shiki/core' import { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs' import { bundledLanguagesInfo } from 'shiki/langs'
@@ -9,6 +10,8 @@ import { renderMermaid } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls' import { appendDiagramControls } from './diagramControls'
import katex from 'katex' import katex from 'katex'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import { parseCallout, escapeCalloutTitle } from './callouts'
import '@/styles/callouts.css'
function mathHtml(source: string, displayMode: boolean) { function mathHtml(source: string, displayMode: boolean) {
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'}) 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'}>` 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) { {name:'blockMath',level:'block',tokenizer(source) {
const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(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()} 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 }) marked.setOptions({ gfm: true, breaks: true })
if (!preferences.autoLinks) marked.use({ tokenizer: { url() { return undefined } } })
return marked
}
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。 // Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
let highlighter: ReturnType<typeof createHighlighterCore> | undefined 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 html = marked.parse(source, { async: false }) as string
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html') 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')) { for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text' 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 ?? '' }) mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
continue continue
} }
if (requestedLanguage.toLowerCase() === 'latex') { if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true))) code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true)))
continue continue
} }
+1
View File
@@ -6,5 +6,6 @@
- `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。 - `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。
- `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。 - `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。
- `/tests/visual/mermaid-matrix.html`:真实 Mermaid(无渲染 mock)的 6 图型 × 6 主题矩阵。顶部报告检查结果;加 `?theme=paper-moments` 可查看单主题外观。 - `/tests/visual/mermaid-matrix.html`:真实 Mermaid(无渲染 mock)的 6 图型 × 6 主题矩阵。顶部报告检查结果;加 `?theme=paper-moments` 可查看单主题外观。
- `/tests/visual/callouts.html?theme=paper-moments`:工作区与静态预览的警告框类型、嵌套和折叠对照;可切换上述六个主题,不读写用户笔记。
运行自动回归:`pnpm test`。AppDialog 测试覆盖滚动锁引用计数、恢复焦点、禁止隐式关闭;主题预览矩阵覆盖六主题的实际共享 CSS、控件状态和 CSP/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。 运行自动回归:`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 { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'node:path' 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({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
@@ -9,6 +21,23 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src'), '@': 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: { server: {
host: '127.0.0.1', host: '127.0.0.1',
port: 5173, port: 5173,