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/data/vault/产品/RAG 检索增强与引用定位.md b/backend/data/vault/产品/RAG 检索增强与引用定位.md index 791a584..051d5bb 100644 --- a/backend/data/vault/产品/RAG 检索增强与引用定位.md +++ b/backend/data/vault/产品/RAG 检索增强与引用定位.md @@ -16,3 +16,5 @@ tags: RAG, 产品 粗排后使用 Reranker 对候选块重新打分,提升相关性。 +
+ diff --git a/backend/data/vault/功能演示/00 演示导航.md b/backend/data/vault/功能演示/00 演示导航.md index 2078084..adff248 100644 --- a/backend/data/vault/功能演示/00 演示导航.md +++ b/backend/data/vault/功能演示/00 演示导航.md @@ -2,20 +2,20 @@ title: 功能演示导航 tags: 演示, 入门 --- - # 功能演示导航 这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。 ## 建议阅读顺序 -| 笔记 | 可以查看的功能 | -| --- | --- | -| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 | -| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 | -| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 | -| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 | -| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 | +| 笔记 | 可以查看的功能 | +| ----------------------------- | ---------------------- | +| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 | +| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 | +| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 | +| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 | +| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 | +| [06 警告框与提示框](06%20警告框与提示框.md) | 类型与别名、标题、折叠、嵌套和主题配色 | ## 工作区操作 @@ -35,3 +35,4 @@ tags: 演示, 入门 - [ ] 在已配置模型后进行一次带知识库检索的问答。 > 上述清单供体验时自行勾选,不是自动验收结果。模型调用可能产生费用,图表与代码示例本身不会执行代码。 + diff --git a/backend/data/vault/功能演示/04 星灯项目资料.md b/backend/data/vault/功能演示/04 星灯项目资料.md index 189bfee..66f0835 100644 --- a/backend/data/vault/功能演示/04 星灯项目资料.md +++ b/backend/data/vault/功能演示/04 星灯项目资料.md @@ -2,7 +2,6 @@ title: 星灯资料站项目简报 tags: 演示, 星灯项目, 检索 --- - # 星灯资料站 星灯资料站是本组演示中的虚构项目,目标是为一个读书小组建立离线可用的学习资料目录。项目代号为 ST-27。 @@ -38,3 +37,4 @@ tags: 演示, 星灯项目, 检索 最后一个问题在本笔记中没有答案。检查回答是否说明资料不足,而不是编造负责人。其他问题可以对照正文并点击引用定位核实。 > 新建笔记需要完成索引后才能参与检索。没有模型配置时,也可以先在搜索页使用项目名、代号或独特检索词查找原文。 + 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/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/frontend/README.md b/frontend/README.md index 8b1f23b..ddbed52 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,6 +41,10 @@ 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、密码等结构化或敏感输入保持关闭。 ## 数据边界 @@ -86,3 +90,9 @@ pnpm build Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。 开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。 + +## 构建体积检查 + +执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。 + +Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。 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/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index 0a24502..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.6.2 +version: 1.8.0 author: NotesAgent description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 min_app_version: 0.2.0 @@ -9,6 +9,12 @@ css_entry: theme.css license: MIT --- [data-theme="paper-moments"] { + --color-callout-info: #406b7b; + --color-callout-success: #536f43; + --color-callout-warning: #875f25; + --color-callout-danger: #a34e42; + --color-callout-important: #805c7e; + --color-callout-quote: #6e6053; color-scheme: light; --color-background-primary: #faf7ee; --color-background-secondary: #f3eee3; @@ -322,3 +328,15 @@ license: MIT /* Nested choices retain a quiet paper border without repeating tape/shadows. */ [data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; } [data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); } + +/* Callout paper: no tape over titles, no repeated shadows in nested blocks. */ +[data-theme="paper-moments"] .markdown-callout, +[data-theme="paper-moments"] .milkdown .ProseMirror blockquote.markdown-callout { + border-radius: 10px 3px 10px 3px; + outline: 1px dashed color-mix(in srgb, var(--callout-color) 30%, transparent); + outline-offset: -6px; + box-shadow: 3px 3px 0 color-mix(in srgb, var(--callout-color) 12%, transparent); + padding: 14px 18px; +} +[data-theme="paper-moments"] .markdown-callout .markdown-callout { box-shadow: none; } +[data-theme="paper-moments"] .markdown-callout > .callout-title { font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; } 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 ffdc997..492fa66 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -13,6 +13,10 @@ import { useThemeStore } from '@/stores/theme' import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { EditorView as CodeMirror } from '@codemirror/view' import { renderMarkdown } from '@/utils/markdown' +import { useEditorStore } from '@/stores/editor' +import { executeEditorCommand } from '@/services/editorCommandService' +import { headingFoldKey } from './headingFolding' +import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' type EditorComponent = { getEditor: () => Editor | undefined } @@ -51,6 +55,111 @@ afterEach(() => { }) describe('VisualMarkdownEditor formatting toolbars', () => { + it('applies syntax and renderer preferences when opening the visual editor', async () => { + const preferences = useMarkdownPreferencesStore() + preferences.preferences.heading = 'setext' + preferences.preferences.bullet = '+' + preferences.preferences.fence = '~' + preferences.preferences.callouts = false + preferences.preferences.math = false + preferences.preferences.autoLinks = false + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# Heading\n\n- first\n- second\n\n> [!NOTE]\n> text\n\nhttps://example.com\n\n```text\ncode\n```' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + const result = editor.action(getMarkdown()) + expect(result).toContain('Heading\n===') + expect(result).toContain('+ first') + expect(result).toContain('~~~text') + expect(wrapper.find('.markdown-callout').exists()).toBe(false) + expect(wrapper.find('.ProseMirror a').exists()).toBe(false) + }) + it('folds heading sections, retains nested state and opens hidden outline targets', async () => { + const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nvisible' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').trigger('click') + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click') + expect(wrapper.findAll('.heading-fold-hidden').length).toBeGreaterThan(1) + await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click') + expect(wrapper.get('.heading-fold-toggle[aria-label="展开 H2 B"]').attributes('aria-expanded')).toBe('false') + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + let position = 0 + view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'child') position = pos }) + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position))) + expect(headingFoldKey.getState(view.state)?.size).toBe(0) + }) + expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) + expect(editor.action(getMarkdown()).trim()).toBe(source) + await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click') + expect(wrapper.findAll('.section-actions button')).toHaveLength(1) + expect(wrapper.get('.section-actions button').text()).toBe('全部展开') + await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 C"]').trigger('click') + expect(wrapper.get('.section-actions button').text()).toBe('全部折叠') + await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click') + await wrapper.get('button[aria-label="展开所有章节"]').trigger('click') + expect(wrapper.get('.section-actions button').text()).toBe('全部折叠') + expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) + }) + it('offers expand all when individually collapsed parents hide expanded children', async () => { + const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nbody' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click') + expect(wrapper.get('.section-actions button').text()).toBe('全部折叠') + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 C"]').trigger('click') + expect(wrapper.get('.section-actions button').text()).toBe('全部展开') + expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true') + await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click') + expect(wrapper.get('.section-actions button').text()).toBe('全部折叠') + expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true') + await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click') + await wrapper.get('.section-actions button').trigger('click') + expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) + expect(wrapper.get('.section-actions button').text()).toBe('全部折叠') + expect(editor.action(getMarkdown()).trim()).toBe(source) + }) + it('renders and folds callouts without losing portable Markdown on serialization', async () => { + const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(wrapper.findAll('.markdown-callout')).toHaveLength(2) + expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('true') + await wrapper.get('.callout-title').trigger('click') + expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('false') + const markdown = editor.action(getMarkdown()) + expect(markdown.trim()).toBe(source) + }) + it('dispatches native-ready commands through editor transactions and rejects invalid parameters', async () => { + useEditorStore().currentFilePath = 'test.md' + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'text' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(await executeEditorCommand('editor.heading', 8)).toEqual({ ok: false, reason: 'invalid-params' }) + expect(await executeEditorCommand('editor.heading', 2)).toEqual({ ok: true }) + expect(editor.action(getMarkdown())).toContain('## text') + expect(await executeEditorCommand('editor.callout', { type: 'tip', body: '**test**' })).toEqual({ ok: true }) + expect(wrapper.find('.markdown-callout').exists()).toBe(true) + useEditorStore().saveStatus = 'conflict' + expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' }) + }) + it('keeps code examples as ordinary quotes and renders newly typed markers', async () => { + const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body }) + mounted.push(wrapper) + const editor = await waitForEditor(wrapper) + expect(wrapper.find('.markdown-callout').exists()).toBe(false) + editor.action(ctx => { + const view = ctx.get(editorViewCtx) + let position = 0 + view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'text') position = pos }) + view.dispatch(view.state.tr.insertText('[!TIP]', position, position + 4)) + }) + expect(wrapper.find('.markdown-callout').exists()).toBe(true) + expect(editor.action(getMarkdown())).toContain('[!TIP]') + }) it('renders the supported format matrix and preserves inline code', async () => { const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6', '正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二', diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index b3302c0..0687e4a 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -4,11 +4,13 @@ import { useActionDialog } from '@/composables/useActionDialog' const { actionDialog, resolveAction, askPrompt } = useActionDialog() import DiagramInteractions from '@/components/common/DiagramInteractions.vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { Link } from '@element-plus/icons-vue' +import { Link, Fold, Expand } from '@element-plus/icons-vue' import { Crepe } from '@milkdown/crepe' import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { basicSetup } from 'codemirror' -import { keymap } from '@codemirror/view' +import { keymap, EditorView as CodeEditorView } from '@codemirror/view' +import { indentUnit } from '@codemirror/language' +import { EditorState as CodeEditorState } from '@codemirror/state' import { indentWithTab } from '@codemirror/commands' import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror' import './language-icons.css' @@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover' import { installCodeBlockLabels } from './codeBlockLabels' import { createMermaidPreview } from './mermaidPreview' import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' -import { getMarkdown } from '@milkdown/kit/utils' +import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils' import { createCodeBlockCommand, toggleEmphasisCommand, @@ -28,8 +30,10 @@ import { wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark' -import { commandsCtx, editorViewCtx } from '@milkdown/kit/core' -import { TextSelection } from '@milkdown/kit/prose/state' +import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core' +import { Slice } from '@milkdown/kit/prose/model' +import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService' +import { TextSelection, Plugin } from '@milkdown/kit/prose/state' import { callCommand } from '@milkdown/kit/utils' import AppIcon from '@/components/common/AppIcon.vue' import { useEditorStore } from '@/stores/editor' @@ -37,11 +41,18 @@ import { useSettingsStore } from '@/stores/settings' import { useThemeStore } from '@/stores/theme' import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown' import { inlineCodeInputPlugin } from './inlineCodeInput' +import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin' +import { calloutTypes } from '@/utils/callouts' +import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSections } from './headingFolding' +import { useHeadingAppearanceStore } from '@/stores/headingAppearance' +import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { t } from '@/i18n' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' const props = defineProps<{ initialContent: string }>() +const headingAppearance = useHeadingAppearanceStore() +const markdownPreferences = { ...useMarkdownPreferencesStore().normalized } const metadata = ref(splitNoteMetadata(props.initialContent)) const tagDraft = ref('') function setTags(tags: string[]) { @@ -63,10 +74,82 @@ const settingsStore = useSettingsStore() const themeStore = useThemeStore() const editorRoot = ref(null) const loading = ref(true) +const allHeadingsFolded = ref(false) +const hasFoldableHeadings = ref(false) const fontSizeInput = ref(16) let crepe: Crepe | null = null let disposeLanguagePicker: (() => void) | undefined let disposeCodeLabels: (() => void) | undefined +let disposeCommands: (() => void) | undefined +let disposed = false + +function insertMarkdown(source: string) { + crepe?.editor.action(ctx => { + const doc = ctx.get(parserCtx)(source) + if (!doc) throw new Error('Invalid Markdown') + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)).scrollIntoView()) + view.focus() + }) +} + +function insertCallout(event: Event) { + const select = event.target as HTMLSelectElement + if (select.value) insertMarkdown(`> [!${select.value.toUpperCase()}]\n> ${t('提示内容', 'Callout content')}`) + select.value = '' +} + +function installCommands() { + const targetPath = editorStore.currentFilePath + const handlers: Partial> = {} + for (const [id, action] of [['editor.heading.toggle-fold', 'toggle'], ['editor.heading.fold-all', 'all'], ['editor.heading.unfold-all', 'none']] as const) { + handlers[id] = () => { foldHeadings(action); return { ok: true } } + } + const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block'] + for (const command of toolbar) { + if (!markdownPreferences.math && command.includes('math')) continue + handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } } + } + handlers['editor.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } } + handlers['editor.heading'] = params => { + if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-params' } + crepe!.editor.action(callCommand(wrapInHeadingCommand.key, Number(params))) + return { ok: true } + } + handlers['editor.font-size'] = params => { + if (typeof params !== 'number' || !Number.isFinite(params) || params < 8 || params > 96) return { ok: false, reason: 'invalid-params' } + fontSizeInput.value = params + applyFontSizeValue() + return { ok: true } + } + handlers['editor.insert-markdown'] = params => { + if (typeof params !== 'string' || !params.trim() || params.length > 100000) return { ok: false, reason: 'invalid-params' } + insertMarkdown(params) + return { ok: true } + } + handlers['editor.callout'] = params => { + if (!markdownPreferences.callouts) return { ok: false, reason: 'unsupported' } + if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' } + const { type, title = '', body = '', fold = '' } = params as Record + if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title) + || typeof body !== 'string' || !['', '+', '-'].includes(String(fold)) || title.length + body.length > 100000) return { ok: false, reason: 'invalid-params' } + insertMarkdown(`> [!${type}]${fold} ${title}\n${body.split(/\r?\n/).map(line => `> ${line}`).join('\n')}`) + return { ok: true } + } + disposeCommands = registerEditorCommands({ + available: () => !loading.value && !!crepe && editorStore.mode === 'wysiwyg' && editorStore.saveStatus !== 'conflict' + && !!targetPath && targetPath === editorStore.currentFilePath && crepe.editor.action(ctx => ctx.get(editorViewCtx).editable), + handlers, + }) +} + +function foldHeadings(action: 'toggle' | 'all' | 'none') { + crepe?.editor.action(ctx => { + const view = ctx.get(editorViewCtx) + const tr = headingFoldTransaction(view.state, action) + if (tr) view.dispatch(tr) + }) +} const diagramPreviews = new Map void }>() function renderDiagram(source: string, apply: (value: HTMLElement) => void) { for (const [id, entry] of diagramPreviews) { @@ -114,7 +197,7 @@ function runCommand(command: ToolbarCommand) { 'ordered-list': callCommand(wrapInOrderedListCommand.key), 'bullet-list': callCommand(wrapInBulletListCommand.key), 'inline-code': callCommand(toggleInlineCodeCommand.key), - 'code-block': callCommand(createCodeBlockCommand.key, ''), + 'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage), 'inline-math': callCommand('ToggleLatex'), 'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'), } @@ -181,7 +264,7 @@ onMounted(async () => { crepe = new Crepe({ root: editorRoot.value, defaultValue: metadata.value?.body ?? props.initialContent, - features: { [Crepe.Feature.TopBar]: false }, + features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.CodeMirror]: { @@ -245,12 +328,54 @@ onMounted(async () => { languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), renderLanguage: renderCodeLanguage, renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' - ? renderDiagram(content, applyPreview) + ? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null : config.renderPreview(language, content, applyPreview), - extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)], + extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme), + indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent), + ...(markdownPreferences.wrapCode ? [CodeEditorView.lineWrapping] : [])], }))) + crepe.editor.config(ctx => ctx.update(remarkStringifyOptionsCtx, options => ({ + ...options, setext: markdownPreferences.heading === 'setext', bullet: markdownPreferences.bullet, + incrementListMarker: markdownPreferences.incrementList, fence: markdownPreferences.fence, + }))) + if (!markdownPreferences.autoLinks) crepe.editor.use($remark('disable-bare-autolinks', () => () => (tree, file) => { + type Ast = { type: string; value?: string; url?: string; children?: Ast[]; position?: { start: { offset?: number }; end: { offset?: number } } } + const source = String(file.value) + const walk = (node: Ast) => { + if (node.type === 'link' && node.position) { + const raw = source.slice(node.position.start.offset, node.position.end.offset) + if (/^(?:https?:\/\/|www\.)\S+$/.test(raw)) { + node.type = 'text'; node.value = raw; delete node.children; delete node.url + } + } + node.children?.forEach(walk) + } + walk(tree as Ast) + })) crepe.editor.use(fontSizeMarkdownPlugin) crepe.editor.use(inlineCodeInputPlugin) + if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin) + crepe.editor.use(headingFoldingPlugin) + crepe.editor.use($prose(() => new Plugin({ + view(view) { + const sync = (current: typeof view) => { + const sections = headingSections(current.state.doc) + const folded = headingFoldKey.getState(current.state) + hasFoldableHeadings.value = sections.length > 0 + // Hidden descendants retain their own state but are not visible expanded sections. + let hiddenUntil = -1 + allHeadingsFolded.value = sections.length > 0 && sections.every(section => { + if (section.from < hiddenUntil) return true + if (!folded?.has(section.from)) return false + hiddenUntil = section.end + return true + }) + } + sync(view) + return { update: sync } + }, + }))) + if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization) crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 @@ -265,6 +390,7 @@ onMounted(async () => { if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value) applyProofingPreferences() loading.value = false + if (!disposed) installCommands() }) watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences) @@ -282,15 +408,24 @@ watch(() => editorStore.headingRequest, request => { }) }) -onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() }) +onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() }) defineExpose({ getEditor: () => crepe?.editor })