feat(editor): add markdown presets, heading folding and external file refresh

This commit is contained in:
2026-09-06 12:57:09 +08:00
parent d21ce23932
commit e09b4ac0ce
40 changed files with 798 additions and 54 deletions
+42 -6
View File
@@ -3,10 +3,13 @@ import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
const content = ref('')
const contentRevision = ref(0)
let diskContent: string | undefined
const saveStatus = ref<SaveStatus>('idle')
const lastSavedAt = ref<string | null>(null)
const currentNoteId = ref<string | null>(null)
@@ -35,13 +38,14 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) {
content.value = newContent
saveStatus.value = 'dirty'
if (saveStatus.value !== 'conflict' && saveStatus.value !== 'external_changed') saveStatus.value = 'dirty'
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) {
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = null
@@ -51,20 +55,23 @@ export const useEditorStore = defineStore('editor', () => {
async function save() {
if (!currentFilePath.value) return
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value
const snapshot = content.value
const baseline = diskContent
saveStatus.value = 'saving'
pendingSave = (async () => {
try {
await workspaceService.saveFileContent(targetPath, snapshot)
await workspaceService.saveFileContent(targetPath, snapshot, baseline)
if (currentFilePath.value === targetPath) {
diskContent = snapshot
saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
lastSavedAt.value = new Date().toISOString()
}
} catch {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
} catch (error) {
if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
} finally {
pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
@@ -86,7 +93,7 @@ export const useEditorStore = defineStore('editor', () => {
}
if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
if (['dirty', 'save_failed', 'conflict', 'external_changed'].includes(saveStatus.value)) {
throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.'))
}
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
@@ -102,6 +109,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = filePath
currentNoteId.value = loadedNoteId
content.value = loadedContent
diskContent = loadedContent
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch (error) {
@@ -122,13 +130,37 @@ export const useEditorStore = defineStore('editor', () => {
}
function setExternalChanged() {
if (saveStatus.value === 'dirty') {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed' || saveStatus.value === 'conflict') {
saveStatus.value = 'conflict'
} else {
saveStatus.value = 'external_changed'
}
}
async function checkExternalFile() {
if (!currentFilePath.value || pendingSave || diskContent === undefined || saveStatus.value === 'conflict') return
const path = currentFilePath.value, baseline = diskContent
try {
const latest = await workspaceService.readFileContent(path)
if (path !== currentFilePath.value || pendingSave || diskContent !== baseline || latest === baseline) return
if (content.value === baseline && saveStatus.value === 'saved') {
content.value = latest; diskContent = latest; contentRevision.value++
} else {
setExternalChanged(); saveStatus.value = 'conflict'
}
} catch { /* Tree polling reports missing files; transient network errors retain edits. */ }
}
async function reloadExternalFile() {
const path = currentFilePath.value, snapshot = content.value
if (!path || pendingSave) return
const latest = await workspaceService.readFileContent(path)
if (path !== currentFilePath.value || content.value !== snapshot || pendingSave) return
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
content.value = latest; diskContent = latest; saveStatus.value = 'saved'; contentRevision.value++
}
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
function closeFile() {
@@ -137,6 +169,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = null
currentNoteId.value = null
content.value = ''
diskContent = undefined
saveStatus.value = 'idle'
lastSavedAt.value = null
highlightBlockId.value = null
@@ -153,6 +186,9 @@ export const useEditorStore = defineStore('editor', () => {
jumpToHeading,
mode,
content,
contentRevision,
checkExternalFile,
reloadExternalFile,
saveStatus,
lastSavedAt,
currentNoteId,