From fcc319d5e1a8dd033a3e2a3cafd8fc019cce33cd Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 6 Sep 2026 10:42:12 +0800 Subject: [PATCH 1/5] perf(frontend): defer mermaid and split editor dependencies --- docs/README.md | 2 + docs/development/前端构建分块优化开发说明.md | 43 ++++++++++++++++++++ frontend/README.md | 4 ++ frontend/package.json | 3 +- frontend/scripts/build-size.mjs | 19 +++++++++ frontend/src/services/mermaidService.ts | 16 ++++++-- frontend/vite.config.ts | 29 +++++++++++++ 7 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 docs/development/前端构建分块优化开发说明.md create mode 100644 frontend/scripts/build-size.mjs diff --git a/docs/README.md b/docs/README.md index 39e8853..fe8912b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,8 @@ ## development:开发说明 +- [前端构建分块优化开发说明](development/前端构建分块优化开发说明.md) + - [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md) - [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md) - [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md) diff --git a/docs/development/前端构建分块优化开发说明.md b/docs/development/前端构建分块优化开发说明.md new file mode 100644 index 0000000..139ac7e --- /dev/null +++ b/docs/development/前端构建分块优化开发说明.md @@ -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、资源预热及库版本升级。 diff --git a/frontend/README.md b/frontend/README.md index 8b1f23b..4a84848 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -86,3 +86,7 @@ pnpm build Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。 开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。 + +## 构建体积检查 + +执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。 diff --git a/frontend/package.json b/frontend/package.json index 83b878c..97a32fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "build": "vue-tsc -b && vite build", "test": "vitest run", "preview": "vite preview", - "type-check": "vue-tsc --noEmit" + "type-check": "vue-tsc --noEmit", + "build:report": "node scripts/build-size.mjs" }, "dependencies": { "@codemirror/commands": "6.11.0", diff --git a/frontend/scripts/build-size.mjs b/frontend/scripts/build-size.mjs new file mode 100644 index 0000000..c7ec4e0 --- /dev/null +++ b/frontend/scripts/build-size.mjs @@ -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)) diff --git a/frontend/src/services/mermaidService.ts b/frontend/src/services/mermaidService.ts index 57eb899..81dcc32 100644 --- a/frontend/src/services/mermaidService.ts +++ b/frontend/src/services/mermaidService.ts @@ -1,4 +1,10 @@ -import mermaid from 'mermaid' +let mermaidPromise: Promise | undefined +function loadMermaid() { + return mermaidPromise ??= import('mermaid').then(module => module.default).catch(error => { + mermaidPromise = undefined + throw error + }) +} import { computed } from 'vue' import { useThemeStore } from '@/stores/theme' @@ -23,7 +29,8 @@ export function mermaidThemeVariables(dark: boolean) { } } -function ensureInitialized(theme: 'light' | 'dark') { +async function ensureInitialized(theme: 'light' | 'dark') { + const mermaid = await loadMermaid() mermaid.initialize({ startOnLoad: false, theme: 'base', @@ -34,6 +41,7 @@ function ensureInitialized(theme: 'light' | 'dark') { sequence: { useMaxWidth: true }, gantt: { useMaxWidth: true }, }) + return mermaid } let queue: Promise = Promise.resolve() function serialized(work: () => Promise): Promise { @@ -66,9 +74,9 @@ async function renderMermaidNow( options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {} ): Promise { const theme = options.theme ?? 'light' - ensureInitialized(theme) const id = `mermaid-${Date.now()}-${++renderCounter}` try { + const mermaid = await ensureInitialized(theme) const result = await mermaid.render(id, source) const parser = new DOMParser() const doc = parser.parseFromString(result.svg, 'image/svg+xml') @@ -131,7 +139,7 @@ export function useMermaidTheme() { export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> { try { - await serialized(async () => { ensureInitialized('light'); await mermaid.parse(source) }) + await serialized(async () => { const mermaid = await ensureInitialized('light'); await mermaid.parse(source) }) return { valid: true } } catch (error) { const message = error instanceof Error ? error.message : '未知错误' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ee63575..0c13b75 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,18 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'node:path' +import { readFileSync } from 'node:fs' + +const mathChunks = new Map() +function mathChunk(module: string) { + const marker = '/node_modules/katex/' + const root = module.slice(0, module.lastIndexOf(marker) + marker.length) + if (!mathChunks.has(root)) { + const { version } = JSON.parse(readFileSync(`${root}package.json`, 'utf8')) + mathChunks.set(root, `math-katex-${version.replace(/[^0-9a-z]/gi, '-')}`) + } + return mathChunks.get(root)! +} export default defineConfig({ plugins: [vue()], @@ -9,6 +21,23 @@ export default defineConfig({ '@': path.resolve(__dirname, 'src'), }, }, + build: { + manifest: true, + rollupOptions: { + output: { + // Keep lazy languages/diagrams independent; do not collect every vendor into one bundle. + onlyExplicitManualChunks: true, + manualChunks(id) { + const module = id.replace(/\\/g, '/') + if (!module.includes('/node_modules/')) return + if (/\/@codemirror\/(?:state|view|language|commands|search|autocomplete|lint)\//.test(module) || /\/@lezer\/(?:common|highlight|lr)\//.test(module) || module.includes('/node_modules/codemirror/')) return 'editor-codemirror' + if (/\/node_modules\/prosemirror-[^/]+\//.test(module)) return 'editor-prosemirror' + if (module.includes('/node_modules/@milkdown/')) return 'editor-milkdown' + if (module.includes('/node_modules/katex/')) return mathChunk(module) + }, + }, + }, + }, server: { host: '127.0.0.1', port: 5173, From 415efc44440b73d5bdd708f5c2d47df9ec7858ac Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 6 Sep 2026 11:33:19 +0800 Subject: [PATCH 2/5] feat(editor): add themed callouts and desktop command boundary --- backend/data/vault/功能演示/00 演示导航.md | 1 + .../data/vault/功能演示/06 警告框与提示框.md | 150 ++++++++++++++++++ docs/README.md | 1 + .../Tauri-Rust桌面客户端需求说明-第三阶段.md | 4 +- docs/development/Markdown渲染检查.md | 4 +- .../警告框与桌面编辑命令开发说明.md | 90 +++++++++++ frontend/README.md | 2 + .../src/assets/themes/paper-moments.theme | 20 ++- .../editor/VisualMarkdownEditor.spec.ts | 41 +++++ .../features/editor/VisualMarkdownEditor.vue | 72 ++++++++- frontend/src/features/editor/calloutPlugin.ts | 89 +++++++++++ .../themes/CommunityThemePreview.spec.ts | 5 + .../features/themes/CommunityThemePreview.vue | 20 ++- .../src/features/themes/ThemesView.spec.ts | 4 +- .../src/services/editorCommandService.spec.ts | 25 +++ frontend/src/services/editorCommandService.ts | 33 ++++ frontend/src/services/themePackageService.ts | 10 +- frontend/src/stores/theme.ts | 6 +- frontend/src/styles/callouts.css | 28 ++++ frontend/src/styles/themeCoverage.spec.ts | 44 +++++ frontend/src/styles/tokens.css | 28 ++++ frontend/src/utils/callouts.spec.ts | 39 +++++ frontend/src/utils/callouts.ts | 21 +++ frontend/src/utils/markdown.ts | 13 ++ frontend/tests/visual/README.md | 1 + frontend/tests/visual/callouts.html | 21 +++ 26 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 backend/data/vault/功能演示/06 警告框与提示框.md create mode 100644 docs/development/警告框与桌面编辑命令开发说明.md create mode 100644 frontend/src/features/editor/calloutPlugin.ts create mode 100644 frontend/src/services/editorCommandService.spec.ts create mode 100644 frontend/src/services/editorCommandService.ts create mode 100644 frontend/src/styles/callouts.css create mode 100644 frontend/src/utils/callouts.spec.ts create mode 100644 frontend/src/utils/callouts.ts create mode 100644 frontend/tests/visual/callouts.html diff --git a/backend/data/vault/功能演示/00 演示导航.md b/backend/data/vault/功能演示/00 演示导航.md index 2078084..23ed1c1 100644 --- a/backend/data/vault/功能演示/00 演示导航.md +++ b/backend/data/vault/功能演示/00 演示导航.md @@ -16,6 +16,7 @@ tags: 演示, 入门 | 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 | | 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 | | 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 | +| [06 警告框与提示框](06%20警告框与提示框.md) | 类型与别名、标题、折叠、嵌套和主题配色 | ## 工作区操作 diff --git a/backend/data/vault/功能演示/06 警告框与提示框.md b/backend/data/vault/功能演示/06 警告框与提示框.md new file mode 100644 index 0000000..6f1e6e7 --- /dev/null +++ b/backend/data/vault/功能演示/06 警告框与提示框.md @@ -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 或空格体验键盘操作。 +- [ ] 在源码模式修改一个类型或标题,再切回写作模式。 +- [ ] 保存并重新打开,确认类型、标题、正文与默认折叠状态保持一致。 + +这是一份手动体验清单,未勾选不表示功能失败。桌面容器的原生格式快捷键与元数据转换仍属于第三阶段规划。 diff --git a/docs/README.md b/docs/README.md index fe8912b..2cb6e38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ - [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md) - [模型上下文管理](development/模型上下文管理.md) - [Markdown 渲染检查](development/Markdown渲染检查.md) +- [警告框与桌面编辑命令开发说明](development/警告框与桌面编辑命令开发说明.md) - [主题组件覆盖检查](development/主题组件覆盖检查.md) - [第二阶段补充验收工具](development/第二阶段补充验收工具.md) diff --git a/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md b/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md index 463d40c..1757a6d 100644 --- a/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md +++ b/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.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 输入与转换规则 diff --git a/docs/development/Markdown渲染检查.md b/docs/development/Markdown渲染检查.md index f1a4929..5114f37 100644 --- a/docs/development/Markdown渲染检查.md +++ b/docs/development/Markdown渲染检查.md @@ -30,7 +30,9 @@ | 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 | | 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 | -源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。 +源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。 + +2026-09-06 补充:GitHub alerts 与 Obsidian callout 已在工作区和静态预览接入,包含常用类型/别名、自定义标题、嵌套与折叠。语法、命令接口与验证方法见 [警告框与桌面编辑命令开发说明](警告框与桌面编辑命令开发说明.md)。 自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。 diff --git a/docs/development/警告框与桌面编辑命令开发说明.md b/docs/development/警告框与桌面编辑命令开发说明.md new file mode 100644 index 0000000..2b1bdf7 --- /dev/null +++ b/docs/development/警告框与桌面编辑命令开发说明.md @@ -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 | 整数 1–6 | +| font-size | 有限数值 8–96,单位 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 配色,不代替每个平台的字体与截图验收。 diff --git a/frontend/README.md b/frontend/README.md index 4a84848..a5e5d3a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,6 +41,8 @@ pnpm dev 编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。 +工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。 + 语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。 ## 数据边界 diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index 0a24502..ea37ee9 100644 --- a/frontend/src/assets/themes/paper-moments.theme +++ b/frontend/src/assets/themes/paper-moments.theme @@ -1,6 +1,6 @@ theme_id: paper-moments name: 纸间时光 · Paper Moments -version: 1.6.2 +version: 1.7.0 author: NotesAgent description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 min_app_version: 0.2.0 @@ -9,6 +9,12 @@ css_entry: theme.css license: MIT --- [data-theme="paper-moments"] { + --color-callout-info: #406b7b; + --color-callout-success: #536f43; + --color-callout-warning: #875f25; + --color-callout-danger: #a34e42; + --color-callout-important: #805c7e; + --color-callout-quote: #6e6053; color-scheme: light; --color-background-primary: #faf7ee; --color-background-secondary: #f3eee3; @@ -322,3 +328,15 @@ license: MIT /* Nested choices retain a quiet paper border without repeating tape/shadows. */ [data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; } [data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); } + +/* Callout paper: no tape over titles, no repeated shadows in nested blocks. */ +[data-theme="paper-moments"] .markdown-callout, +[data-theme="paper-moments"] .milkdown .ProseMirror blockquote.markdown-callout { + border-radius: 10px 3px 10px 3px; + outline: 1px dashed color-mix(in srgb, var(--callout-color) 30%, transparent); + outline-offset: -6px; + box-shadow: 3px 3px 0 color-mix(in srgb, var(--callout-color) 12%, transparent); + padding: 14px 18px; +} +[data-theme="paper-moments"] .markdown-callout .markdown-callout { box-shadow: none; } +[data-theme="paper-moments"] .markdown-callout > .callout-title { font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; } diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts index ffdc997..086359b 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -13,6 +13,8 @@ import { useThemeStore } from '@/stores/theme' import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { EditorView as CodeMirror } from '@codemirror/view' import { renderMarkdown } from '@/utils/markdown' +import { useEditorStore } from '@/stores/editor' +import { executeEditorCommand } from '@/services/editorCommandService' type EditorComponent = { getEditor: () => Editor | undefined } @@ -51,6 +53,45 @@ afterEach(() => { }) describe('VisualMarkdownEditor formatting toolbars', () => { + it('renders and folds callouts without losing portable Markdown on serialization', async () => { + const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(wrapper.findAll('.markdown-callout')).toHaveLength(2) + expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('true') + await wrapper.get('.callout-title').trigger('click') + expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('false') + const markdown = editor.action(getMarkdown()) + expect(markdown.trim()).toBe(source) + }) + it('dispatches native-ready commands through editor transactions and rejects invalid parameters', async () => { + useEditorStore().currentFilePath = 'test.md' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'text' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(await executeEditorCommand('editor.heading', 8)).toEqual({ ok: false, reason: 'invalid-params' }) + expect(await executeEditorCommand('editor.heading', 2)).toEqual({ ok: true }) + expect(editor.action(getMarkdown())).toContain('## text') + expect(await executeEditorCommand('editor.callout', { type: 'tip', body: '**test**' })).toEqual({ ok: true }) + expect(wrapper.find('.markdown-callout').exists()).toBe(true) + useEditorStore().saveStatus = 'conflict' + expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' }) + }) + it('keeps code examples as ordinary quotes and renders newly typed markers', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(wrapper.find('.markdown-callout').exists()).toBe(false) + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + let position = 0 + view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'text') position = pos }) + view.dispatch(view.state.tr.insertText('[!TIP]', position, position + 4)) + }) + expect(wrapper.find('.markdown-callout').exists()).toBe(true) + expect(editor.action(getMarkdown())).toContain('[!TIP]') + }) it('renders the supported format matrix and preserves inline code', async () => { const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6', '正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二', diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index b3302c0..bbaf9be 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -28,7 +28,9 @@ import { wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark' -import { commandsCtx, editorViewCtx } from '@milkdown/kit/core' +import { commandsCtx, editorViewCtx, parserCtx } from '@milkdown/kit/core' +import { Slice } from '@milkdown/kit/prose/model' +import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService' import { TextSelection } from '@milkdown/kit/prose/state' import { callCommand } from '@milkdown/kit/utils' import AppIcon from '@/components/common/AppIcon.vue' @@ -37,6 +39,8 @@ import { useSettingsStore } from '@/stores/settings' import { useThemeStore } from '@/stores/theme' import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown' import { inlineCodeInputPlugin } from './inlineCodeInput' +import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin' +import { calloutTypes } from '@/utils/callouts' import { t } from '@/i18n' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' @@ -67,6 +71,61 @@ const fontSizeInput = ref(16) let crepe: Crepe | null = null let disposeLanguagePicker: (() => void) | undefined let disposeCodeLabels: (() => void) | undefined +let disposeCommands: (() => void) | undefined +let disposed = false + +function insertMarkdown(source: string) { + crepe?.editor.action(ctx => { + const doc = ctx.get(parserCtx)(source) + if (!doc) throw new Error('Invalid Markdown') + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView()) + view.focus() + }) +} + +function insertCallout(event: Event) { + const select = event.target as HTMLSelectElement + if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`) + select.value = '' +} + +function installCommands() { + const targetPath = editorStore.currentFilePath + const handlers: Partial> = {} + const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block'] + for (const command of toolbar) 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 (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' } + const { type, title = '', body = '', fold = '' } = params as Record + 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, + }) +} const diagramPreviews = new Map void }>() function renderDiagram(source: string, apply: (value: HTMLElement) => void) { for (const [id, entry] of diagramPreviews) { @@ -251,6 +310,8 @@ onMounted(async () => { }))) crepe.editor.use(fontSizeMarkdownPlugin) crepe.editor.use(inlineCodeInputPlugin) + crepe.editor.use(calloutPlugin) + crepe.editor.config(configureCalloutSerialization) crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 @@ -265,6 +326,7 @@ onMounted(async () => { if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value) applyProofingPreferences() loading.value = false + if (!disposed) installCommands() }) watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences) @@ -282,7 +344,7 @@ 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 }) @@ -324,6 +386,12 @@ defineExpose({ getEditor: () => crepe?.editor }) +
{{ t('正在加载编辑器…', 'Loading editor…') }}
diff --git a/frontend/src/features/editor/calloutPlugin.ts b/frontend/src/features/editor/calloutPlugin.ts new file mode 100644 index 0000000..b7b80ab --- /dev/null +++ b/frontend/src/features/editor/calloutPlugin.ts @@ -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[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) } + }, + }, + }, +})) diff --git a/frontend/src/features/themes/CommunityThemePreview.spec.ts b/frontend/src/features/themes/CommunityThemePreview.spec.ts index 85e4df5..dbb74ff 100644 --- a/frontend/src/features/themes/CommunityThemePreview.spec.ts +++ b/frontend/src/features/themes/CommunityThemePreview.spec.ts @@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils' // Vitest disables CSS by default, including CSS raw imports. Load the real files here. vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') })) vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') })) +vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') })) import CommunityThemePreview from './CommunityThemePreview.vue' import { mockCommunityThemes } from '@/services/themePackageService' const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))] @@ -15,6 +16,10 @@ it.each(themes)('previews shared component states safely for $theme_id', theme = const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html') expect(doc.documentElement.dataset.theme).toBe(theme.theme_id) expect(doc.querySelector('script')).toBeNull() + expect(doc.querySelectorAll('.specimen-callouts > aside.markdown-callout')).toHaveLength(14) + expect(doc.querySelector('.specimen-callouts > details[open]')).not.toBeNull() + expect(doc.querySelector('.specimen-callouts > details:not([open])')).not.toBeNull() + expect(doc.querySelector('style')!.textContent).toContain('.callout-title:focus-visible') expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content')).toContain("default-src 'none'") for (const selector of ['input.input','input:disabled','textarea.textarea','select.select','.ui-disclosure[open]','.ui-disclosure:not([open])','.button-primary:disabled','.badge.success','.error-banner','.specimen-markdown code','.specimen-markdown table','.specimen-chart','.specimen-long']) expect(doc.querySelector(selector), selector).not.toBeNull() expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover') diff --git a/frontend/src/features/themes/CommunityThemePreview.vue b/frontend/src/features/themes/CommunityThemePreview.vue index 4d145e0..de96438 100644 --- a/frontend/src/features/themes/CommunityThemePreview.vue +++ b/frontend/src/features/themes/CommunityThemePreview.vue @@ -5,6 +5,8 @@ import { t } from '@/i18n' import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService' import tokensCss from '@/styles/tokens.css?raw' import featuresCss from '@/styles/features.css?raw' +import calloutsCss from '@/styles/callouts.css?raw' +import { calloutTypes } from '@/utils/callouts' import specimenHtml from './themeSpecimen.html?raw' const props = defineProps<{ themeId: string; name?: string; css?: string }>() @@ -20,7 +22,7 @@ const previewDocument = computed(() => { policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'" doc.head.append(policy) const style = doc.createElement('style') - style.textContent = `${tokensCss}\n${featuresCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }` + style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }` doc.head.append(style) const article = doc.createElement('article') @@ -33,6 +35,22 @@ const previewDocument = computed(() => { const button = doc.createElement('button'); button.className = 'button-primary'; button.textContent = t('示例按钮', 'Example button') journal.append(text) const specimen = doc.createElement('template'); specimen.innerHTML = specimenHtml + const callouts = doc.createElement('section'); callouts.className = 'specimen-callouts' + const caption = doc.createElement('h2'); caption.textContent = t('警告框与提示框', 'Alerts and callouts'); callouts.append(caption) + for (const type of Object.keys(calloutTypes)) { + const block = doc.createElement('aside'); block.className = 'markdown-callout'; block.dataset.callout = type + const title = doc.createElement('div'); title.className = 'callout-title'; title.textContent = type + const body = doc.createElement('div'); body.className = 'callout-body'; body.textContent = t('提示正文:检查文字、边框与主题配色。', 'Callout body: check text, borders and theme colors.') + block.append(title, body); callouts.append(block) + } + for (const open of [false, true]) { + const details = doc.createElement('details'); details.className = 'markdown-callout'; details.dataset.callout = 'warning'; details.open = open + const summary = doc.createElement('summary'); summary.className = 'callout-title'; summary.textContent = t('可折叠警告框', 'Collapsible callout') + const body = doc.createElement('div'); body.className = 'callout-body' + const nested = doc.createElement('aside'); nested.className = 'markdown-callout'; nested.dataset.callout = 'tip'; nested.textContent = t('嵌套提示内容', 'Nested callout content') + body.append(nested); details.append(summary, body); callouts.append(details) + } + specimen.content.append(callouts) article.append(header, journal, button, specimen.content); doc.body.append(article) return '' + doc.documentElement.outerHTML }) diff --git a/frontend/src/features/themes/ThemesView.spec.ts b/frontend/src/features/themes/ThemesView.spec.ts index 084b2d8..ea528a1 100644 --- a/frontend/src/features/themes/ThemesView.spec.ts +++ b/frontend/src/features/themes/ThemesView.spec.ts @@ -96,7 +96,7 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS it('offers and applies the paper theme update without discarding the active theme', async () => { const store = useThemeStore() - const old = await inspectThemePackage(paperPackage.replace('version: 1.6.2', 'version: 1.6.1')) + const old = await inspectThemePackage(paperPackage.replace('version: 1.7.0', 'version: 1.6.1')) await store.installThemeFromInspection(old.manifest, old.css) store.applyTheme('paper-moments') wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) @@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))! await card.findAll('button').find(button => button.text() === '更新')!.trigger('click') await flushPromises() - expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.6.2') + expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.7.0') expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested') }) diff --git a/frontend/src/services/editorCommandService.spec.ts b/frontend/src/services/editorCommandService.spec.ts new file mode 100644 index 0000000..37ddef4 --- /dev/null +++ b/frontend/src/services/editorCommandService.spec.ts @@ -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' }) +}) diff --git a/frontend/src/services/editorCommandService.ts b/frontend/src/services/editorCommandService.ts new file mode 100644 index 0000000..f43c118 --- /dev/null +++ b/frontend/src/services/editorCommandService.ts @@ -0,0 +1,33 @@ +/** 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', +] 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 +type Target = { available: () => boolean; handlers: Partial> } +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 { + 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' } } +} diff --git a/frontend/src/services/themePackageService.ts b/frontend/src/services/themePackageService.ts index 54efaba..7d62703 100644 --- a/frontend/src/services/themePackageService.ts +++ b/frontend/src/services/themePackageService.ts @@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [ { theme_id: 'ocean-blue', name: 'Ocean Blue', - version: '1.3.1', + version: '1.4.0', author: 'community', description: '宁静的海洋蓝色主题,适合长时间阅读', min_app_version: '0.2.0', @@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [ { theme_id: 'midnight-purple', name: 'Midnight Purple', - version: '2.1.1', + version: '2.2.0', author: 'night-owl', description: '深紫色暗夜主题,适合编码', min_app_version: '0.2.0', @@ -464,6 +464,12 @@ export function getCommunityThemePreviewCss(themeId: string): string { return buildCommunityThemeCss(themeId, t.is_dark) + ` [data-theme="${themeId}"] { color-scheme: ${t.is_dark ? 'dark' : 'light'}; + --color-callout-info: ${t.is_dark ? '#9dbbff' : '#126589'}; + --color-callout-success: ${t.is_dark ? '#a7d58c' : '#267049'}; + --color-callout-warning: ${t.is_dark ? '#efc886' : '#885c13'}; + --color-callout-danger: ${t.is_dark ? '#ff9caf' : '#b13d4d'}; + --color-callout-important: ${t.is_dark ? '#d4afff' : '#7050a3'}; + --color-callout-quote: ${t.is_dark ? '#b0b9dd' : '#53697d'}; --color-text-inverse: ${t.is_dark ? '#1a1b26' : '#ffffff'}; --color-text-disabled: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-surface-primary)); --color-background-overlay: ${t.is_dark ? '#000000a6' : '#00000073'}; diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index 778de1f..536095e 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -5,9 +5,9 @@ import * as themePkg from '@/services/themePackageService' import { t } from '@/i18n' const builtinThemes = (): ThemeConfig[] => [ - { theme_id: 'light', name: t('浅色', 'Light'), version: '1.1.1', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, - { theme_id: 'dark', name: t('深色', 'Dark'), version: '1.1.1', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' }, - { theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.1.1', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, + { theme_id: 'light', name: t('浅色', 'Light'), version: '1.2.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, + { theme_id: 'dark', name: t('深色', 'Dark'), version: '1.2.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' }, + { theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.2.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, ] export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark' diff --git a/frontend/src/styles/callouts.css b/frontend/src/styles/callouts.css new file mode 100644 index 0000000..465c8ae --- /dev/null +++ b/frontend/src/styles/callouts.css @@ -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; } diff --git a/frontend/src/styles/themeCoverage.spec.ts b/frontend/src/styles/themeCoverage.spec.ts index 1807b52..b00132b 100644 --- a/frontend/src/styles/themeCoverage.spec.ts +++ b/frontend/src/styles/themeCoverage.spec.ts @@ -22,3 +22,47 @@ it.each(mockCommunityThemes)('provides interaction and Markdown colors in $theme } expect(css).toContain(`color-scheme: ${theme.is_dark ? 'dark' : 'light'}`) }) + +const calloutThemes = ['light', 'dark', 'sepia', ...mockCommunityThemes.map(theme => theme.theme_id)] +const calloutTones = ['info', 'success', 'warning', 'danger', 'important', 'quote'] +it.each(calloutThemes)('keeps callout headings readable against tinted surfaces in %s', themeId => { + const doc = document.implementation.createHTMLDocument('theme contrast') + const style = doc.createElement('style') + style.textContent = files['styles/tokens.css'] ?? files['styles\\tokens.css']! + style.textContent += getCommunityThemePreviewCss(themeId) + doc.head.append(style) + const values = new Map() + 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 = '
' + 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() } +}) diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index d951f27..47b4648 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,4 +1,11 @@ :root { + /* Callout heading colors also serve as borders; keep text readable on tint. */ + --color-callout-info: var(--color-info); + --color-callout-success: var(--color-success); + --color-callout-warning: var(--color-warning); + --color-callout-danger: var(--color-error); + --color-callout-important: var(--color-accent-secondary); + --color-callout-quote: var(--color-text-secondary); /* Background */ --color-background-primary: #ffffff; --color-background-secondary: #f7f8fa; @@ -117,7 +124,22 @@ --statusbar-height: 28px; } +[data-theme='light'] { + --color-callout-info: #175da6; + --color-callout-success: #236b3b; + --color-callout-warning: #855700; + --color-callout-danger: #ad2935; + --color-callout-important: #7443ad; + --color-callout-quote: #59636e; +} + [data-theme='dark'] { + --color-callout-info: #8bbdff; + --color-callout-success: #80ce93; + --color-callout-warning: #efc66f; + --color-callout-danger: #ff9b9b; + --color-callout-important: #c8a5ff; + --color-callout-quote: #abb6c2; --color-background-primary: #0d1117; --color-background-secondary: #161b22; --color-background-tertiary: #21262d; @@ -168,6 +190,12 @@ } [data-theme='sepia'] { + --color-callout-info: #396578; + --color-callout-success: #496b3b; + --color-callout-warning: #805918; + --color-callout-danger: #a04438; + --color-callout-important: #785476; + --color-callout-quote: #746653; --color-background-primary: #fbf3df; --color-background-secondary: #f4e8ca; --color-background-tertiary: #eadbb8; diff --git a/frontend/src/utils/callouts.spec.ts b/frontend/src/utils/callouts.spec.ts new file mode 100644 index 0000000..5662b5c --- /dev/null +++ b/frontend/src/utils/callouts.spec.ts @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { calloutTypes, parseCallout } from './callouts' +import { renderMarkdown } from './markdown' + +describe('callouts', () => { + 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] \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('') + }) + 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() + }) +}) diff --git a/frontend/src/utils/callouts.ts b/frontend/src/utils/callouts.ts new file mode 100644 index 0000000..8c9e08b --- /dev/null +++ b/frontend/src/utils/callouts.ts @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) +} diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index eb28bf3..289de90 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -9,6 +9,19 @@ import { renderMermaid } from '@/services/mermaidService' import { appendDiagramControls } from './diagramControls' import katex from 'katex' import 'katex/dist/katex.min.css' +import { parseCallout, escapeCalloutTitle } from './callouts' +import '@/styles/callouts.css' + +marked.use({ renderer: { blockquote(token) { + 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 + ? `
${title}
${body}
` + : `` +} } }) function mathHtml(source: string, displayMode: boolean) { const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'}) diff --git a/frontend/tests/visual/README.md b/frontend/tests/visual/README.md index f48e6ba..89ad7eb 100644 --- a/frontend/tests/visual/README.md +++ b/frontend/tests/visual/README.md @@ -6,5 +6,6 @@ - `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。 - `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。 - `/tests/visual/mermaid-matrix.html`:真实 Mermaid(无渲染 mock)的 6 图型 × 6 主题矩阵。顶部报告检查结果;加 `?theme=paper-moments` 可查看单主题外观。 +- `/tests/visual/callouts.html?theme=paper-moments`:工作区与静态预览的警告框类型、嵌套和折叠对照;可切换上述六个主题,不读写用户笔记。 运行自动回归:`pnpm test`。AppDialog 测试覆盖滚动锁引用计数、恢复焦点、禁止隐式关闭;主题预览矩阵覆盖六主题的实际共享 CSS、控件状态和 CSP/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。 diff --git a/frontend/tests/visual/callouts.html b/frontend/tests/visual/callouts.html new file mode 100644 index 0000000..4a0c777 --- /dev/null +++ b/frontend/tests/visual/callouts.html @@ -0,0 +1,21 @@ +警告框渲染验收 +
From 8ad1db33f7bb19aa644cde787eaa186415334942 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 6 Sep 2026 12:57:09 +0800 Subject: [PATCH 3/5] feat(editor): add markdown presets, heading folding and external file refresh --- backend/app/contracts.py | 1 + backend/app/routes.py | 5 +- backend/app/services/workspace_service.py | 8 ++ backend/tests/test_workspace.py | 31 +++++ docs/README.md | 3 + .../Markdown语法预设与外部文件刷新.md | 48 +++++++ docs/development/标题折叠与样式开发说明.md | 52 ++++++++ frontend/README.md | 4 + .../src/assets/themes/paper-moments.theme | 2 +- frontend/src/components/common/AppShell.vue | 2 + .../src/components/common/MarkdownContent.vue | 10 +- .../src/composables/useWorkspaceRefresh.ts | 40 ++++++ frontend/src/features/editor/EditorHeader.vue | 17 ++- frontend/src/features/editor/EditorPane.vue | 2 +- .../editor/HeadingStyleSettings.spec.ts | 20 +++ .../features/editor/HeadingStyleSettings.vue | 37 ++++++ .../editor/MarkdownPreferenceSettings.vue | 45 +++++++ .../editor/VisualMarkdownEditor.spec.ts | 43 +++++++ .../features/editor/VisualMarkdownEditor.vue | 74 ++++++++--- .../features/editor/headingFolding.spec.ts | 32 +++++ .../src/features/editor/headingFolding.ts | 119 ++++++++++++++++++ .../src/features/settings/SettingsView.vue | 4 +- .../features/themes/CommunityThemePreview.vue | 3 +- .../src/features/themes/ThemesView.spec.ts | 4 +- frontend/src/features/themes/ThemesView.vue | 2 + .../src/features/themes/themeSpecimen.html | 3 + .../src/features/workspace/FileTreePanel.vue | 1 + frontend/src/services/editorCommandService.ts | 1 + frontend/src/services/noteService.ts | 2 +- frontend/src/services/themePackageService.ts | 4 +- frontend/src/services/workspaceService.ts | 10 +- frontend/src/stores/editor.ts | 48 ++++++- frontend/src/stores/editorSave.spec.ts | 26 +++- frontend/src/stores/headingAppearance.spec.ts | 28 +++++ frontend/src/stores/headingAppearance.ts | 40 ++++++ .../src/stores/markdownPreferences.spec.ts | 20 +++ frontend/src/stores/markdownPreferences.ts | 49 ++++++++ frontend/src/stores/theme.ts | 8 +- frontend/src/stores/workspace.ts | 23 +++- frontend/src/stores/workspaceRefresh.spec.ts | 20 +++ frontend/src/styles/headings.css | 19 +++ frontend/src/utils/callouts.spec.ts | 14 +++ frontend/src/utils/markdown.ts | 31 +++-- 43 files changed, 901 insertions(+), 54 deletions(-) create mode 100644 docs/development/Markdown语法预设与外部文件刷新.md create mode 100644 docs/development/标题折叠与样式开发说明.md create mode 100644 frontend/src/composables/useWorkspaceRefresh.ts create mode 100644 frontend/src/features/editor/HeadingStyleSettings.spec.ts create mode 100644 frontend/src/features/editor/HeadingStyleSettings.vue create mode 100644 frontend/src/features/editor/MarkdownPreferenceSettings.vue create mode 100644 frontend/src/features/editor/headingFolding.spec.ts create mode 100644 frontend/src/features/editor/headingFolding.ts create mode 100644 frontend/src/stores/headingAppearance.spec.ts create mode 100644 frontend/src/stores/headingAppearance.ts create mode 100644 frontend/src/stores/markdownPreferences.spec.ts create mode 100644 frontend/src/stores/markdownPreferences.ts create mode 100644 frontend/src/stores/workspaceRefresh.spec.ts create mode 100644 frontend/src/styles/headings.css diff --git a/backend/app/contracts.py b/backend/app/contracts.py index a7b4f6f..fa8913c 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -116,6 +116,7 @@ class NoteUpdateRequest(Contract): title: str | None = None markdown: str | None = None tags: list[str] | None = None + expected_content_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") class NoteMoveRequest(Contract): diff --git a/backend/app/routes.py b/backend/app/routes.py index 2cd285d..2fee500 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -225,7 +225,7 @@ async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot: @router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"]) async def get_workspace_tree() -> list[WorkspaceEntry]: - return workspace_service.get_workspace_tree() + return await workspace_service.refresh_workspace_tree() @router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"]) @@ -286,7 +286,8 @@ async def get_note(note_id: str) -> Note: @router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"]) async def update_note(note_id: str, request: NoteUpdateRequest) -> Note: return await note_service.update_note( - note_id, title=request.title, markdown=request.markdown, tags=request.tags, defer_vectors=True + note_id, title=request.title, markdown=request.markdown, tags=request.tags, + expected_content_hash=request.expected_content_hash, defer_vectors=True ) diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index 402885d..0046b8d 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -105,6 +105,14 @@ def get_workspace_tree() -> list[WorkspaceEntry]: return _tree(get_settings().vault_path.resolve(), locations) +async def refresh_workspace_tree() -> list[WorkspaceEntry]: + """Observe external creates/deletes without waiting for vector inference.""" + if get_workspace_info().requires_refresh: + await _register_workspace_files() + index_service.schedule_workspace_rebuild() + return get_workspace_tree() + + async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot: """打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。""" diff --git a/backend/tests/test_workspace.py b/backend/tests/test_workspace.py index d32bea3..4794917 100644 --- a/backend/tests/test_workspace.py +++ b/backend/tests/test_workspace.py @@ -114,3 +114,34 @@ def test_workspace_openapi_paths_are_published() -> None: "/api/workspace/folders/delete", "/api/notes/{note_id}/rename", } <= paths.keys() + +def test_external_files_are_registered_and_removed_without_vector_wait(monkeypatch) -> None: + from app.services import index_service + scheduled = [] + monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: scheduled.append(True)) + vault = get_settings().vault_path + vault.mkdir(parents=True, exist_ok=True) + external = vault / 'external.md' + external.write_text('# External\n', encoding='utf-8') + tree = asyncio.run(get_workspace_tree()) + assert tree[0].note_id is not None + external.rename(vault / 'renamed.md') + tree = asyncio.run(get_workspace_tree()) + assert [item.name for item in tree] == ['renamed.md'] + (vault / 'renamed.md').unlink() + assert asyncio.run(get_workspace_tree()) == [] + assert len(scheduled) == 3 + + +def test_save_rejects_external_content_change() -> None: + import hashlib + from app.contracts import NoteUpdateRequest + from app.routes import update_note + original = '# Original\n' + note = asyncio.run(create_note(NoteCreateRequest(title='Conflict', markdown=original))) + disk = get_settings().vault_path / note.file_path + disk.write_text('# External\n', encoding='utf-8') + with pytest.raises(ApiError) as error: + asyncio.run(update_note(note.note_id, NoteUpdateRequest(markdown='# Editor\n', expected_content_hash=hashlib.sha256(original.encode()).hexdigest()))) + assert error.value.code == 'NOTE_CONTENT_CONFLICT' + assert disk.read_text(encoding='utf-8') == '# External\n' diff --git a/docs/README.md b/docs/README.md index 2cb6e38..44877b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ - [模型上下文管理](development/模型上下文管理.md) - [Markdown 渲染检查](development/Markdown渲染检查.md) - [警告框与桌面编辑命令开发说明](development/警告框与桌面编辑命令开发说明.md) +- [标题折叠与样式开发说明](development/标题折叠与样式开发说明.md) - [主题组件覆盖检查](development/主题组件覆盖检查.md) - [第二阶段补充验收工具](development/第二阶段补充验收工具.md) @@ -92,3 +93,5 @@ - 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。 - `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。 - 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。 + +- [Markdown 语法预设与外部文件刷新](development/Markdown语法预设与外部文件刷新.md) diff --git a/docs/development/Markdown语法预设与外部文件刷新.md b/docs/development/Markdown语法预设与外部文件刷新.md new file mode 100644 index 0000000..24c63d6 --- /dev/null +++ b/docs/development/Markdown语法预设与外部文件刷新.md @@ -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.0;Ocean Blue 1.5.0;Midnight Purple 2.3.0。社区预览增加语法控件和标题折叠样本。 + +标题箭头使用统一 CSS 形状,默认隐藏,悬停标题或键盘聚焦按钮时显示;无悬停能力的触摸设备保持可见。用户自定义标题外观独立于主题配色。 + +## 3. 外部文件刷新与保存保护 + +Web 当前采用串行后台轮询:前一次完成后间隔两秒,在窗口聚焦时也检查。隐藏页面暂停读取,卸载移除监听。此机制不是原生文件事件监听;第三阶段可由 Tauri 文件事件替代。 + +`GET /workspace/tree` 检查磁盘新增、删除和重命名,为新文件登记元数据及全文索引,向量任务继续后台执行。前端保留文件夹展开状态,并丢弃过期请求响应。读取失败保留旧树并显示重试入口。 + +当前打开且未修改的文件检测到外部正文变化后更新编辑器;存在本地编辑时保留缓冲区,停止自动保存并提示冲突。重新加载磁盘版本需要用户确认。`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. 分别在正文未修改、有未保存编辑时从外部修改同一文件,验证自动加载与冲突保护;拒绝重新加载应保留编辑内容。 + +本次自动验证覆盖预设持久化、解析选项隔离、写作语法输出、标题折叠、树刷新竞态、外部文件登记及保存冲突。 diff --git a/docs/development/标题折叠与样式开发说明.md b/docs/development/标题折叠与样式开发说明.md new file mode 100644 index 0000000..ae5945e --- /dev/null +++ b/docs/development/标题折叠与样式开发说明.md @@ -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 粗细,返回笔记检查外观,刷新后检查偏好仍在;关闭自定义后依次切换六个主题核对主题原有样式。 diff --git a/frontend/README.md b/frontend/README.md index a5e5d3a..ddbed52 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,6 +41,8 @@ pnpm dev 编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 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、密码等结构化或敏感输入保持关闭。 @@ -92,3 +94,5 @@ Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补 ## 构建体积检查 执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。 + +Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。 diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index ea37ee9..667b04f 100644 --- a/frontend/src/assets/themes/paper-moments.theme +++ b/frontend/src/assets/themes/paper-moments.theme @@ -1,6 +1,6 @@ theme_id: paper-moments name: 纸间时光 · Paper Moments -version: 1.7.0 +version: 1.8.0 author: NotesAgent description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 min_app_version: 0.2.0 diff --git a/frontend/src/components/common/AppShell.vue b/frontend/src/components/common/AppShell.vue index 26cc83f..2d792ee 100644 --- a/frontend/src/components/common/AppShell.vue +++ b/frontend/src/components/common/AppShell.vue @@ -12,6 +12,8 @@ import TitleBar from './TitleBar.vue' import CommandPalette from './CommandPalette.vue' import { getIndexStatus } from '@/services/indexService' import { navigateToCitation } from '@/composables/useCitationNavigation' +import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh' +useWorkspaceRefresh() defineProps<{ showSecondarySidebar?: boolean diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue index ebb1576..0fdf620 100644 --- a/frontend/src/components/common/MarkdownContent.vue +++ b/frontend/src/components/common/MarkdownContent.vue @@ -3,6 +3,10 @@ import DiagramInteractions from './DiagramInteractions.vue' import { computed, ref, watch } from 'vue' import { renderMarkdown } from '@/utils/markdown' import { useThemeStore } from '@/stores/theme' +import { useHeadingAppearanceStore } from '@/stores/headingAppearance' +const headingAppearance = useHeadingAppearanceStore() +import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' +const markdownPreferences = useMarkdownPreferencesStore() const props = defineProps<{ source: string }>() const themeStore = useThemeStore() @@ -12,15 +16,15 @@ let renderVersion = 0 const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light')) // 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。 -watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async ([source, theme]) => { +watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized)], async ([source, theme]) => { const version = ++renderVersion - const result = await renderMarkdown(source, { theme }) + const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized }) if (version === renderVersion) html.value = result }, { immediate: true, flush: 'post' }) diff --git a/frontend/src/features/editor/MarkdownPreferenceSettings.vue b/frontend/src/features/editor/MarkdownPreferenceSettings.vue new file mode 100644 index 0000000..bdbdff3 --- /dev/null +++ b/frontend/src/features/editor/MarkdownPreferenceSettings.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts index 086359b..795c90d 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -15,6 +15,8 @@ import { EditorView as CodeMirror } from '@codemirror/view' import { renderMarkdown } from '@/utils/markdown' import { useEditorStore } from '@/stores/editor' import { executeEditorCommand } from '@/services/editorCommandService' +import { headingFoldKey } from './headingFolding' +import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' type EditorComponent = { getEditor: () => Editor | undefined } @@ -53,6 +55,47 @@ afterEach(() => { }) describe('VisualMarkdownEditor formatting toolbars', () => { + it('applies syntax and renderer preferences when opening the visual editor', async () => { + const preferences = useMarkdownPreferencesStore() + preferences.preferences.heading = 'setext' + preferences.preferences.bullet = '+' + preferences.preferences.fence = '~' + preferences.preferences.callouts = false + preferences.preferences.math = false + preferences.preferences.autoLinks = false + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# Heading\n\n- first\n- second\n\n> [!NOTE]\n> text\n\nhttps://example.com\n\n```text\ncode\n```' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + const result = editor.action(getMarkdown()) + expect(result).toContain('Heading\n===') + expect(result).toContain('+ first') + expect(result).toContain('~~~text') + expect(wrapper.find('.markdown-callout').exists()).toBe(false) + expect(wrapper.find('.ProseMirror a').exists()).toBe(false) + }) + it('folds heading sections, retains nested state and opens hidden outline targets', async () => { + const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nvisible' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').trigger('click') + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click') + expect(wrapper.findAll('.heading-fold-hidden').length).toBeGreaterThan(1) + await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click') + expect(wrapper.get('.heading-fold-toggle[aria-label="展开 H2 B"]').attributes('aria-expanded')).toBe('false') + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + let position = 0 + view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'child') position = pos }) + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position))) + expect(headingFoldKey.getState(view.state)?.size).toBe(0) + }) + expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) + expect(editor.action(getMarkdown()).trim()).toBe(source) + await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click') + await wrapper.get('button[aria-label="展开所有章节"]').trigger('click') + expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) + }) 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 }) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index bbaf9be..4a06067 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -8,7 +8,9 @@ import { Link } from '@element-plus/icons-vue' import { Crepe } from '@milkdown/crepe' import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { basicSetup } from 'codemirror' -import { keymap } from '@codemirror/view' +import { keymap, EditorView as CodeEditorView } from '@codemirror/view' +import { indentUnit } from '@codemirror/language' +import { EditorState as CodeEditorState } from '@codemirror/state' import { indentWithTab } from '@codemirror/commands' import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror' import './language-icons.css' @@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover' import { installCodeBlockLabels } from './codeBlockLabels' import { createMermaidPreview } from './mermaidPreview' import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' -import { getMarkdown } from '@milkdown/kit/utils' +import { getMarkdown, $remark } from '@milkdown/kit/utils' import { createCodeBlockCommand, toggleEmphasisCommand, @@ -28,7 +30,7 @@ import { wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark' -import { commandsCtx, editorViewCtx, parserCtx } from '@milkdown/kit/core' +import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core' import { Slice } from '@milkdown/kit/prose/model' import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService' import { TextSelection } from '@milkdown/kit/prose/state' @@ -41,11 +43,16 @@ import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdow import { inlineCodeInputPlugin } from './inlineCodeInput' import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin' import { calloutTypes } from '@/utils/callouts' +import { headingFoldingPlugin, headingFoldTransaction } from './headingFolding' +import { useHeadingAppearanceStore } from '@/stores/headingAppearance' +import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { t } from '@/i18n' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' const props = defineProps<{ initialContent: string }>() +const headingAppearance = useHeadingAppearanceStore() +const markdownPreferences = { ...useMarkdownPreferencesStore().normalized } const metadata = ref(splitNoteMetadata(props.initialContent)) const tagDraft = ref('') function setTags(tags: string[]) { @@ -93,8 +100,14 @@ function insertCallout(event: Event) { function installCommands() { const targetPath = editorStore.currentFilePath const handlers: Partial> = {} + 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) handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } } + 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' } @@ -113,6 +126,7 @@ function installCommands() { 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 if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title) @@ -126,6 +140,14 @@ function installCommands() { 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 void }>() function renderDiagram(source: string, apply: (value: HTMLElement) => void) { for (const [id, entry] of diagramPreviews) { @@ -173,7 +195,7 @@ function runCommand(command: ToolbarCommand) { 'ordered-list': callCommand(wrapInOrderedListCommand.key), 'bullet-list': callCommand(wrapInBulletListCommand.key), 'inline-code': callCommand(toggleInlineCodeCommand.key), - 'code-block': callCommand(createCodeBlockCommand.key, ''), + 'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage), 'inline-math': callCommand('ToggleLatex'), 'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'), } @@ -240,7 +262,7 @@ onMounted(async () => { crepe = new Crepe({ root: editorRoot.value, defaultValue: metadata.value?.body ?? props.initialContent, - features: { [Crepe.Feature.TopBar]: false }, + features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.CodeMirror]: { @@ -304,14 +326,35 @@ onMounted(async () => { languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), renderLanguage: renderCodeLanguage, renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' - ? renderDiagram(content, applyPreview) + ? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null : config.renderPreview(language, content, applyPreview), - extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)], + extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme), + indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent), + ...(markdownPreferences.wrapCode ? [CodeEditorView.lineWrapping] : [])], }))) + crepe.editor.config(ctx => ctx.update(remarkStringifyOptionsCtx, options => ({ + ...options, setext: markdownPreferences.heading === 'setext', bullet: markdownPreferences.bullet, + incrementListMarker: markdownPreferences.incrementList, fence: markdownPreferences.fence, + }))) + if (!markdownPreferences.autoLinks) crepe.editor.use($remark('disable-bare-autolinks', () => () => (tree, file) => { + type Ast = { type: string; value?: string; url?: string; children?: Ast[]; position?: { start: { offset?: number }; end: { offset?: number } } } + const source = String(file.value) + const walk = (node: Ast) => { + if (node.type === 'link' && node.position) { + const raw = source.slice(node.position.start.offset, node.position.end.offset) + if (/^(?:https?:\/\/|www\.)\S+$/.test(raw)) { + node.type = 'text'; node.value = raw; delete node.children; delete node.url + } + } + node.children?.forEach(walk) + } + walk(tree as Ast) + })) crepe.editor.use(fontSizeMarkdownPlugin) crepe.editor.use(inlineCodeInputPlugin) - crepe.editor.use(calloutPlugin) - crepe.editor.config(configureCalloutSerialization) + if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin) + crepe.editor.use(headingFoldingPlugin) + if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization) crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 @@ -350,9 +393,11 @@ defineExpose({ getEditor: () => crepe?.editor })