diff --git a/docs/development/Markdown语法预设与外部文件刷新.md b/docs/development/Markdown语法预设与外部文件刷新.md
index 24c63d6..32eb252 100644
--- a/docs/development/Markdown语法预设与外部文件刷新.md
+++ b/docs/development/Markdown语法预设与外部文件刷新.md
@@ -24,7 +24,7 @@ Web 当前采用串行后台轮询:前一次完成后间隔两秒,在窗口
`GET /workspace/tree` 检查磁盘新增、删除和重命名,为新文件登记元数据及全文索引,向量任务继续后台执行。前端保留文件夹展开状态,并丢弃过期请求响应。读取失败保留旧树并显示重试入口。
-当前打开且未修改的文件检测到外部正文变化后更新编辑器;存在本地编辑时保留缓冲区,停止自动保存并提示冲突。重新加载磁盘版本需要用户确认。`PATCH /notes/{note_id}` 新增可选 `expected_content_hash`(原始正文 UTF-8 SHA-256,64 位小写十六进制),保存前校验,不匹配返回 `NOTE_CONTENT_CONFLICT`,防止覆盖外部修改。
+当前打开且未修改的文件检测到外部正文变化后更新编辑器;存在本地编辑时保留缓冲区,停止自动保存并提示冲突。重新加载磁盘版本需要用户确认。原文件删除或移动后,隐藏不可用的重新加载入口,提供下载当前 Markdown 副本和确认关闭笔记;取消关闭或确认期间正文改变时保留缓冲区。关闭后可重新选择其他文件,不再阻塞导航。`PATCH /notes/{note_id}` 新增可选 `expected_content_hash`(原始正文 UTF-8 SHA-256,64 位小写十六进制),保存前校验,不匹配返回 `NOTE_CONTENT_CONFLICT`,防止覆盖外部修改。
范围限制:现有文件的外部正文修改会刷新当前编辑器,但本轮目录检查不会据此重建其搜索索引;可通过重建索引同步检索内容。
diff --git a/docs/development/标题折叠与样式开发说明.md b/docs/development/标题折叠与样式开发说明.md
index ae5945e..c183920 100644
--- a/docs/development/标题折叠与样式开发说明.md
+++ b/docs/development/标题折叠与样式开发说明.md
@@ -6,7 +6,7 @@
H1–H6 标题旁在悬停时显示统一折叠箭头;键盘聚焦也显示,触摸设备保持可见。章节从标题之后开始,结束于同一容器内下一个同级或更高级标题;末尾没有后续内容的标题不显示按钮。引用等容器中的标题只影响所在容器,不折叠外部正文。
-- 工具栏提供“折叠所有章节”和“展开所有章节”。
+- 工具栏使用单个按钮:有可见章节展开时显示“全部折叠”,否则显示“全部展开”。父章节隐藏的子章节不影响按钮判断,其自身折叠状态仍保留。无可折叠章节时按钮禁用。
- 折叠父章节不会清空子章节的折叠状态。
- 折叠时若选区在将隐藏的正文中,光标先移到标题。
- 从大纲、查找或键盘跳到隐藏内容时,展开包含目标的章节,避免隐藏光标。
diff --git a/frontend/src/features/editor/EditorHeader.spec.ts b/frontend/src/features/editor/EditorHeader.spec.ts
new file mode 100644
index 0000000..32569b7
--- /dev/null
+++ b/frontend/src/features/editor/EditorHeader.spec.ts
@@ -0,0 +1,41 @@
+// @vitest-environment happy-dom
+import { mount, flushPromises } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, expect, it, vi } from 'vitest'
+import EditorHeader from './EditorHeader.vue'
+import { useEditorStore } from '@/stores/editor'
+import { useWorkspaceStore } from '@/stores/workspace'
+import * as service from '@/services/workspaceService'
+afterEach(() => vi.restoreAllMocks())
+it('offers recovery for a missing file, supports cancel, and releases navigation after confirmation', async () => {
+ const pinia = createPinia(); setActivePinia(pinia)
+ vi.spyOn(service, 'getNoteId').mockResolvedValue('id')
+ const read = vi.spyOn(service, 'readFileContent').mockResolvedValue('original')
+ const editor = useEditorStore(), workspace = useWorkspaceStore()
+ await editor.loadFile('/removed.md'); workspace.openFile('/removed.md')
+ editor.updateContent('unsaved'); editor.setExternalChanged()
+ const wrapper = mount(EditorHeader, { global: { plugins: [pinia], stubs: { ActionDialog: { name: 'ActionDialog', template: '
', props: ['message'], emits: ['resolve'] } } } })
+ const close = () => wrapper.findAll('button').find(button => button.text() === '关闭当前笔记')!
+ try {
+ expect(wrapper.text()).toContain('原文件已删除或移动')
+ expect(wrapper.text()).not.toContain('重新加载外部版本')
+ expect(wrapper.text()).toContain('下载 Markdown 副本')
+ await close().trigger('click')
+ wrapper.findComponent({ name: 'ActionDialog' }).vm.$emit('resolve', null); await flushPromises()
+ expect(editor.content).toBe('unsaved')
+ await close().trigger('click')
+ wrapper.findComponent({ name: 'ActionDialog' }).vm.$emit('resolve', ''); await flushPromises()
+ expect(editor.currentFilePath).toBeNull(); expect(workspace.activeFilePath).toBeNull()
+ read.mockResolvedValue('another file')
+ await editor.loadFile('/other.md')
+ expect(editor.content).toBe('another file')
+ } finally { wrapper.unmount() }
+})
+it('does not discard text changed after confirmation was opened', async () => {
+ setActivePinia(createPinia())
+ const editor = useEditorStore()
+ editor.currentFilePath = '/removed.md'; editor.updateContent('before'); editor.setExternalChanged()
+ editor.updateContent('after')
+ expect(await editor.discardExternalChanges('/removed.md', 'before')).toBe(false)
+ expect(editor.content).toBe('after')
+})
diff --git a/frontend/src/features/editor/EditorHeader.vue b/frontend/src/features/editor/EditorHeader.vue
index a95818f..8f86f4f 100644
--- a/frontend/src/features/editor/EditorHeader.vue
+++ b/frontend/src/features/editor/EditorHeader.vue
@@ -10,6 +10,24 @@ const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
const reloadError = ref('')
+const needsRecovery = computed(() => ['conflict', 'external_changed'].includes(editorStore.saveStatus))
+const missingFile = computed(() => needsRecovery.value && editorStore.currentFilePath === workspaceStore.activeFilePath && !workspaceStore.activeFile && !workspaceStore.treeRefreshError)
+function downloadCopy() {
+ const url = URL.createObjectURL(new Blob([editorStore.content], { type: 'text/markdown;charset=utf-8' }))
+ const link = document.createElement('a')
+ link.href = url
+ link.download = `${(editorStore.currentFilePath?.split('/').pop() ?? 'note.md').replace(/\.md$/i, '')}-recovered.md`
+ document.body.append(link); link.click(); link.remove()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+async function discard() {
+ const path = editorStore.currentFilePath, snapshot = editorStore.content
+ if (!path || !(await askConfirm(t('关闭将丢弃当前编辑内容。需要保留时,请先下载 Markdown 副本。确认关闭?', 'Closing discards the current editor content. Download a Markdown copy first if needed. Close?')))) return
+ if (!(await editorStore.discardExternalChanges(path, snapshot))) return
+ workspaceStore.closeFile(path)
+ workspaceStore.setActiveFile(null)
+ reloadError.value = ''
+}
async function reload() {
const path = editorStore.currentFilePath, snapshot = editorStore.content
if (!(await askConfirm(t('重新加载会丢弃当前未保存内容。请先复制需要保留的文字。继续吗?', 'Reload discards unsaved edits. Copy any text you need to keep first. Continue?')))) return
@@ -29,7 +47,10 @@ const statusText = computed>(() => ({
{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}{{ workspaceStore.activeFilePath }}
{{ statusText[editorStore.saveStatus] }}
-
+
+
{{ t('原文件已删除或移动', 'Original file deleted or moved') }}
+
+
{{ reloadError }}
@@ -53,6 +74,7 @@ const statusText = computed
>(() => ({
.file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-identity small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
+.editor-actions { flex-wrap: wrap; justify-content: flex-end; }
.editor-actions, .mode-switch { display: flex; align-items: center; gap: var(--space-sm); }
.save-status { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
index 795c90d..492fa66 100644
--- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
+++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
@@ -93,9 +93,34 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
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 })
diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue
index 4a06067..0687e4a 100644
--- a/frontend/src/features/editor/VisualMarkdownEditor.vue
+++ b/frontend/src/features/editor/VisualMarkdownEditor.vue
@@ -4,7 +4,7 @@ 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'
@@ -18,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
-import { getMarkdown, $remark } from '@milkdown/kit/utils'
+import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -33,7 +33,7 @@ import {
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'
+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'
@@ -43,7 +43,7 @@ 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 { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSections } from './headingFolding'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n'
@@ -74,6 +74,8 @@ 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
@@ -354,6 +356,25 @@ onMounted(async () => {
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) => {
@@ -396,8 +417,15 @@ defineExpose({ getEditor: () => crepe?.editor })