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