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 })