From 13675f6cfd55a1e4d4f191cef69fc8c372ee227a Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 6 Sep 2026 13:29:19 +0800 Subject: [PATCH] fix(editor): recover missing files and synchronize section toggle --- .../src/features/editor/EditorHeader.spec.ts | 41 +++++++++++++++++ frontend/src/features/editor/EditorHeader.vue | 24 +++++++++- .../editor/VisualMarkdownEditor.spec.ts | 25 +++++++++++ .../features/editor/VisualMarkdownEditor.vue | 45 ++++++++++++++++--- .../src/features/editor/headingFolding.ts | 1 - frontend/src/stores/editor.ts | 9 +++- 6 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/editor/EditorHeader.spec.ts 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 })