fix(editor): recover missing files and synchronize section toggle

This commit is contained in:
2026-09-06 13:29:19 +08:00
parent d4ba08b944
commit 750e17212e
8 changed files with 138 additions and 11 deletions
@@ -24,7 +24,7 @@ Web 当前采用串行后台轮询:前一次完成后间隔两秒,在窗口
`GET /workspace/tree` 检查磁盘新增、删除和重命名,为新文件登记元数据及全文索引,向量任务继续后台执行。前端保留文件夹展开状态,并丢弃过期请求响应。读取失败保留旧树并显示重试入口。 `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`,防止覆盖外部修改。
范围限制:现有文件的外部正文修改会刷新当前编辑器,但本轮目录检查不会据此重建其搜索索引;可通过重建索引同步检索内容。 范围限制:现有文件的外部正文修改会刷新当前编辑器,但本轮目录检查不会据此重建其搜索索引;可通过重建索引同步检索内容。
@@ -6,7 +6,7 @@
H1–H6 标题旁在悬停时显示统一折叠箭头;键盘聚焦也显示,触摸设备保持可见。章节从标题之后开始,结束于同一容器内下一个同级或更高级标题;末尾没有后续内容的标题不显示按钮。引用等容器中的标题只影响所在容器,不折叠外部正文。 H1–H6 标题旁在悬停时显示统一折叠箭头;键盘聚焦也显示,触摸设备保持可见。章节从标题之后开始,结束于同一容器内下一个同级或更高级标题;末尾没有后续内容的标题不显示按钮。引用等容器中的标题只影响所在容器,不折叠外部正文。
- 工具栏提供“折叠所有章节”和“展开所有章节” - 工具栏使用单个按钮:有可见章节展开时显示“全部折叠”,否则显示“全部展开”。父章节隐藏的子章节不影响按钮判断,其自身折叠状态仍保留。无可折叠章节时按钮禁用
- 折叠父章节不会清空子章节的折叠状态。 - 折叠父章节不会清空子章节的折叠状态。
- 折叠时若选区在将隐藏的正文中,光标先移到标题。 - 折叠时若选区在将隐藏的正文中,光标先移到标题。
- 从大纲、查找或键盘跳到隐藏内容时,展开包含目标的章节,避免隐藏光标。 - 从大纲、查找或键盘跳到隐藏内容时,展开包含目标的章节,避免隐藏光标。
@@ -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: '<div />', 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')
})
+23 -1
View File
@@ -10,6 +10,24 @@ const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const { actionDialog, resolveAction, askConfirm } = useActionDialog() const { actionDialog, resolveAction, askConfirm } = useActionDialog()
const reloadError = ref('') 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() { async function reload() {
const path = editorStore.currentFilePath, snapshot = editorStore.content 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 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<Record<string, string>>(() => ({
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div> <div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions"> <div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span> <span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<button v-if="['conflict', 'external_changed'].includes(editorStore.saveStatus)" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button> <button v-if="needsRecovery && !missingFile" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
<span v-if="missingFile" class="save-status conflict">{{ t('原文件已删除或移动', 'Original file deleted or moved') }}</span>
<button v-if="needsRecovery" class="button-secondary" @click="downloadCopy">{{ t('下载 Markdown 副本', 'Download Markdown copy') }}</button>
<button v-if="needsRecovery" class="button-secondary" @click="discard">{{ t('关闭当前笔记', 'Close current note') }}</button>
<span v-if="reloadError" class="save-status conflict" role="alert">{{ reloadError }}</span> <span v-if="reloadError" class="save-status conflict" role="alert">{{ reloadError }}</span>
<div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')"> <div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button> <button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
@@ -53,6 +74,7 @@ const statusText = computed<Record<string, string>>(() => ({
.file-identity { display: grid; min-width: 0; } .file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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); } .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); } .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 { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); } .save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
@@ -93,9 +93,34 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false) expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
expect(editor.action(getMarkdown()).trim()).toBe(source) 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.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') 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) 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 () => { it('renders and folds callouts without losing portable Markdown on serialization', async () => {
const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容' const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
@@ -4,7 +4,7 @@ import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog() const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue' import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from '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 { Crepe } from '@milkdown/crepe'
import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror' import { basicSetup } from 'codemirror'
@@ -18,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels' import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview' import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown, $remark } from '@milkdown/kit/utils' import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
import { import {
createCodeBlockCommand, createCodeBlockCommand,
toggleEmphasisCommand, toggleEmphasisCommand,
@@ -33,7 +33,7 @@ import {
import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core' import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
import { Slice } from '@milkdown/kit/prose/model' import { Slice } from '@milkdown/kit/prose/model'
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService' 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 { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
@@ -43,7 +43,7 @@ import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdow
import { inlineCodeInputPlugin } from './inlineCodeInput' import { inlineCodeInputPlugin } from './inlineCodeInput'
import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin' import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin'
import { calloutTypes } from '@/utils/callouts' import { calloutTypes } from '@/utils/callouts'
import { headingFoldingPlugin, headingFoldTransaction } from './headingFolding' import { headingFoldingPlugin, headingFoldTransaction, headingFoldKey, headingSections } from './headingFolding'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance' import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -74,6 +74,8 @@ const settingsStore = useSettingsStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null) const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true) const loading = ref(true)
const allHeadingsFolded = ref(false)
const hasFoldableHeadings = ref(false)
const fontSizeInput = ref(16) const fontSizeInput = ref(16)
let crepe: Crepe | null = null let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined let disposeLanguagePicker: (() => void) | undefined
@@ -354,6 +356,25 @@ onMounted(async () => {
crepe.editor.use(inlineCodeInputPlugin) crepe.editor.use(inlineCodeInputPlugin)
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin) if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.use(headingFoldingPlugin) 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) if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization)
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
@@ -396,8 +417,15 @@ defineExpose({ getEditor: () => crepe?.editor })
<DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"> <DiagramInteractions class="visual-editor" :class="{ 'hide-code-line-numbers': !markdownPreferences.lineNumbers }" :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')"> <div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<button type="button" :title="t('折叠所有章节', 'Fold all sections')" :aria-label="t('折叠所有章节', 'Fold all sections')" @click="foldHeadings('all')"></button> <div class="section-actions">
<button type="button" :title="t('展开所有章节', 'Unfold all sections')" :aria-label="t('展开所有章节', 'Unfold all sections')" @click="foldHeadings('none')"></button> <button type="button" :disabled="loading || !hasFoldableHeadings"
:title="allHeadingsFolded ? t('展开所有章节正文', 'Expand all section content') : t('折叠所有章节,保留标题', 'Collapse all sections, keeping headings visible')"
:aria-label="allHeadingsFolded ? t('展开所有章节', 'Unfold all sections') : t('折叠所有章节', 'Fold all sections')"
@click="foldHeadings(allHeadingsFolded ? 'none' : 'all')">
<AppIcon :icon="allHeadingsFolded ? Expand : Fold" :size="16" />
<span>{{ allHeadingsFolded ? t('全部展开', 'Expand all') : t('全部折叠', 'Collapse all') }}</span>
</button>
</div>
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading"> <select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
@@ -461,6 +489,11 @@ defineExpose({ getEditor: () => crepe?.editor })
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); } .markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); } .markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; } .markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.section-actions { display: inline-flex; align-items: center; flex-shrink: 0; margin-inline-end: 8px; padding: 2px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-toolbar .section-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 28px; padding: 4px 8px; font: inherit; font-size: var(--font-size-xs); line-height: 1.25; white-space: nowrap; color: var(--color-text-secondary); }
.section-actions :deep(.app-icon) { transform: rotate(90deg); }
.markdown-toolbar .section-actions button:hover:not(:disabled) { background: var(--color-background-hover); color: var(--color-accent-primary); }
.markdown-toolbar .section-actions button:disabled { opacity: .45; cursor: default; }
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; } .format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
.heading-glyph { font-weight: 800; } .heading-glyph { font-weight: 800; }
.font-size-glyph { font-size: 18px; } .font-size-glyph { font-size: 18px; }
@@ -84,7 +84,6 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
decorations.push(Decoration.widget(section.from + 1, view => { decorations.push(Decoration.widget(section.from + 1, view => {
const button = document.createElement('button') const button = document.createElement('button')
button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false' button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false'
button.textContent = collapsed ? '▸' : '▾'
button.setAttribute('aria-expanded', String(!collapsed)) button.setAttribute('aria-expanded', String(!collapsed))
button.setAttribute('aria-label', `${collapsed ? t('展开', 'Expand') : t('折叠', 'Collapse')} H${section.level} ${state.doc.nodeAt(section.from)?.textContent ?? ''}`) button.setAttribute('aria-label', `${collapsed ? t('展开', 'Expand') : t('折叠', 'Collapse')} H${section.level} ${state.doc.nodeAt(section.from)?.textContent ?? ''}`)
button.onmousedown = event => event.preventDefault() button.onmousedown = event => event.preventDefault()
+8 -1
View File
@@ -161,7 +161,13 @@ export const useEditorStore = defineStore('editor', () => {
content.value = latest; diskContent = latest; saveStatus.value = 'saved'; contentRevision.value++ content.value = latest; diskContent = latest; saveStatus.value = 'saved'; contentRevision.value++
} }
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。 async function discardExternalChanges(path: string, snapshot: string): Promise<boolean> {
if (pendingSave) await pendingSave
if (currentFilePath.value !== path || content.value !== snapshot) return false
closeFile()
return true
}
function closeFile() { function closeFile() {
loadVersion++ loadVersion++
@@ -189,6 +195,7 @@ export const useEditorStore = defineStore('editor', () => {
contentRevision, contentRevision,
checkExternalFile, checkExternalFile,
reloadExternalFile, reloadExternalFile,
discardExternalChanges,
saveStatus, saveStatus,
lastSavedAt, lastSavedAt,
currentNoteId, currentNoteId,