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,
+25 -1
View File
@@ -6,6 +6,30 @@ import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
it('reloads clean external changes but preserves unsaved edits and blocks overwrite', async () => {
setActivePinia(createPinia())
vi.spyOn(workspace, 'getNoteId').mockResolvedValue('id')
const read = vi.spyOn(workspace, 'readFileContent').mockResolvedValue('original')
const write = vi.spyOn(workspace, 'saveFileContent').mockResolvedValue()
const store = useEditorStore()
await store.loadFile('/draft.md')
read.mockResolvedValue('external')
await store.checkExternalFile()
expect(store.content).toBe('external')
expect(store.contentRevision).toBe(1)
store.updateContent('my unsaved changes')
read.mockResolvedValue('new external')
await store.checkExternalFile()
expect(store.saveStatus).toBe('conflict')
expect(store.content).toBe('my unsaved changes')
store.updateContent('keep editing')
await store.save()
expect(write).not.toHaveBeenCalled()
await store.reloadExternalFile()
expect(store.content).toBe('new external')
expect(store.saveStatus).toBe('saved')
})
it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers()
setActivePinia(createPinia())
@@ -20,6 +44,6 @@ it('saves text typed while the previous save is still pending', async () => {
await saving
expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest')
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest', 'first')
expect(store.saveStatus).toBe('saved')
})
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { normalizeHeadingAppearance, useHeadingAppearanceStore } from './headingAppearance'
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
it('persists heading preferences and restores theme defaults without residual overrides', async () => {
const store = useHeadingAppearanceStore()
expect(store.cssVariables).toEqual({})
store.preferences.custom = true
store.preferences.levels[1]!.size = 35
store.preferences.levels[1]!.weight = 400
await nextTick()
setActivePinia(createPinia())
const restored = useHeadingAppearanceStore()
expect(restored.cssVariables['--heading-2-size']).toBe('35px')
expect(restored.cssVariables['--heading-2-weight']).toBe('400')
restored.reset()
expect(restored.cssVariables).toEqual({})
})
it('rejects invalid storage and limits values before applying CSS', () => {
localStorage.setItem('editor-heading-appearance', 'invalid')
expect(useHeadingAppearanceStore().preferences.custom).toBe(false)
const result = normalizeHeadingAppearance({ custom: true, family: 'url(unsafe)', levels: [{ size: 9999, weight: 2 }, { size: NaN }] })
expect(result.family).toBe('inherit')
expect(result.levels[0]).toEqual({ size: 72, weight: 700 })
expect(result.levels[1]!.size).toBe(28)
})
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import '@/styles/headings.css'
const key = 'editor-heading-appearance'
export const defaultHeadingSizes = [32, 28, 24, 21, 18, 16]
export function normalizeHeadingAppearance(value: unknown) {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const levels = Array.isArray(raw.levels) ? raw.levels : []
return {
custom: raw.custom === true,
family: ['inherit', 'serif', 'sans-serif', 'monospace'].includes(String(raw.family)) ? String(raw.family) : 'inherit',
levels: defaultHeadingSizes.map((size, index) => {
const item = levels[index] ?? {}
return {
size: typeof item.size === 'number' && Number.isFinite(item.size) ? Math.min(72, Math.max(12, item.size)) : size,
weight: [400, 500, 600, 700, 800].includes(item.weight) ? Number(item.weight) : 700,
}
}),
}
}
export const useHeadingAppearanceStore = defineStore('heading-appearance', () => {
let saved: unknown
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') } catch { saved = {} }
const preferences = ref(normalizeHeadingAppearance(saved))
watch(preferences, value => localStorage.setItem(key, JSON.stringify(normalizeHeadingAppearance(value))), { deep: true })
const cssVariables = computed(() => {
const normalized = normalizeHeadingAppearance(preferences.value)
if (!normalized.custom) return {}
const result: Record<string, string> = { '--heading-family': normalized.family }
normalized.levels.forEach((item, index) => {
result[`--heading-${index + 1}-size`] = `${item.size}px`
result[`--heading-${index + 1}-weight`] = String(item.weight)
})
return result
})
function reset() { preferences.value = normalizeHeadingAppearance({}) }
return { preferences, cssVariables, reset }
})
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useMarkdownPreferencesStore, markdownPresets } from './markdownPreferences'
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
it('saves, replaces and restores named syntax presets', async () => {
const store = useMarkdownPreferencesStore()
store.preferences.heading = 'setext'
store.preferences.bullet = '+'
expect(store.savePreset('我的格式')).toBe(true)
store.apply(markdownPresets.plain)
expect(store.normalized.callouts).toBe(false)
store.apply(store.customPresets[0]!.preferences)
expect(store.normalized.heading).toBe('setext')
await nextTick()
setActivePinia(createPinia())
expect(useMarkdownPreferencesStore().normalized.bullet).toBe('+')
expect(useMarkdownPreferencesStore().customPresets[0]!.name).toBe('我的格式')
})
@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export interface MarkdownPreferences {
heading: 'atx' | 'setext'; bullet: '-' | '*' | '+'; incrementList: boolean
fence: '`' | '~'; math: boolean; callouts: boolean; diagrams: boolean; autoLinks: boolean
lineNumbers: boolean; wrapCode: boolean; indent: number; defaultLanguage: string
}
export const defaultMarkdownPreferences: MarkdownPreferences = {
heading: 'atx', bullet: '-', incrementList: true, fence: '`', math: true, callouts: true,
diagrams: true, autoLinks: true, lineNumbers: true, wrapCode: false, indent: 4, defaultLanguage: '',
}
export function normalizeMarkdownPreferences(value: unknown): MarkdownPreferences {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const result = { ...defaultMarkdownPreferences }
for (const key of ['incrementList','math','callouts','diagrams','autoLinks','lineNumbers','wrapCode'] as const) if (typeof raw[key] === 'boolean') result[key] = raw[key]
result.heading = raw.heading === 'setext' ? 'setext' : 'atx'
result.bullet = raw.bullet === '*' || raw.bullet === '+' ? raw.bullet : '-'
result.fence = raw.fence === '~' ? '~' : '`'
result.indent = [2,4,8].includes(Number(raw.indent)) ? Number(raw.indent) : 4
result.defaultLanguage = typeof raw.defaultLanguage === 'string' && /^[\w+-]{0,40}$/.test(raw.defaultLanguage) ? raw.defaultLanguage : ''
return result
}
export const markdownPresets = {
extended: defaultMarkdownPreferences,
github: { ...defaultMarkdownPreferences, math: false },
plain: { ...defaultMarkdownPreferences, math: false, callouts: false, diagrams: false, autoLinks: false },
}
const key = 'markdown-preferences'
export const useMarkdownPreferencesStore = defineStore('markdown-preferences', () => {
let saved: Record<string, unknown> = {}
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* defaults */ }
const preferences = ref(normalizeMarkdownPreferences(saved.preferences))
const customPresets = ref<{ name: string; preferences: MarkdownPreferences }[]>(Array.isArray(saved.presets)
? saved.presets.filter(item => item && typeof item.name === 'string').slice(0, 20).map(item => ({ name: item.name.slice(0, 40), preferences: normalizeMarkdownPreferences(item.preferences) })) : [])
const normalized = computed(() => normalizeMarkdownPreferences(preferences.value))
watch([preferences, customPresets], () => localStorage.setItem(key, JSON.stringify({ preferences: normalized.value, presets: customPresets.value })), { deep: true })
function apply(value: unknown) { preferences.value = normalizeMarkdownPreferences(value) }
function savePreset(name: string) {
name = name.trim().slice(0, 40)
if (!name) return false
const existing = customPresets.value.find(item => item.name === name)
if (existing) existing.preferences = { ...normalized.value }
else if (customPresets.value.length < 20) customPresets.value.push({ name, preferences: { ...normalized.value } })
else return false
return true
}
return { preferences, normalized, customPresets, apply, savePreset }
})
+5 -3
View File
@@ -1,13 +1,14 @@
import { defineStore } from 'pinia'
import { useHeadingAppearanceStore } from './headingAppearance'
import { ref, computed, watch } from 'vue'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService'
import { t } from '@/i18n'
const builtinThemes = (): ThemeConfig[] => [
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.2.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.2.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.2.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.3.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.3.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.3.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
]
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
@@ -158,6 +159,7 @@ export const useThemeStore = defineStore('theme', () => {
}
function resetToDefault() {
useHeadingAppearanceStore().reset()
applyTheme('light')
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
+22 -1
View File
@@ -13,6 +13,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const isLoading = ref(false)
const hasVault = ref(false)
const recentVaults = ref<workspaceService.VaultInfo[]>([])
const treeRefreshError = ref<string | null>(null)
let refreshSequence = 0
const activeFile = computed(() => {
if (!activeFilePath.value) return null
@@ -65,10 +67,27 @@ export const useWorkspaceStore = defineStore('workspace', () => {
/** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */
async function refreshFileTree() {
if (!hasVault.value) return
fileTree.value = await workspaceService.getFileTree()
const sequence = ++refreshSequence
const path = vaultPath.value
try {
const fresh = await workspaceService.refreshTree()
if (sequence !== refreshSequence || path !== vaultPath.value || !hasVault.value) return
const open = new Map<string, boolean>()
const collect = (nodes: FileNode[]) => nodes.forEach(node => { if (node.type === 'folder') open.set(node.path, !!node.is_open); if (node.children) collect(node.children) })
collect(fileTree.value)
const restore = (nodes: FileNode[]) => nodes.forEach(node => { if (node.type === 'folder') node.is_open = open.get(node.path) ?? false; if (node.children) restore(node.children) })
restore(fresh)
// Avoid redrawing an unchanged tree on every background check.
if (JSON.stringify(fresh) !== JSON.stringify(fileTree.value)) fileTree.value = fresh
treeRefreshError.value = null
} catch (error) {
if (sequence === refreshSequence) treeRefreshError.value = error instanceof Error ? error.message : '文件树刷新失败'
throw error
}
}
async function openVault(path: string) {
refreshSequence++
isLoading.value = true
try {
const info = await workspaceService.openVault(path)
@@ -84,6 +103,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
}
async function createVault(path: string, name: string) {
refreshSequence++
isLoading.value = true
try {
const info = await workspaceService.createVault(path, name)
@@ -162,6 +182,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading,
hasVault,
recentVaults,
treeRefreshError,
toggleFolder,
openFile,
closeFile,
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useWorkspaceStore } from './workspace'
import * as service from '@/services/workspaceService'
beforeEach(() => { setActivePinia(createPinia()); vi.restoreAllMocks() })
it('fetches external entries while keeping folder state and ignoring stale responses', async () => {
const store = useWorkspaceStore()
store.hasVault = true; store.vaultPath = '/vault'
store.fileTree = [{ id: 'folder', path: '/folder', name: 'folder', type: 'folder', is_open: true, children: [] }]
let release!: (value: typeof store.fileTree) => void
vi.spyOn(service, 'refreshTree').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
.mockResolvedValueOnce([{ id: 'folder', path: '/folder', name: 'folder', type: 'folder', children: [{ id: 'new', path: '/folder/new.md', name: 'new.md', type: 'file' }] }])
const old = store.refreshFileTree()
await store.refreshFileTree()
expect(store.fileTree[0]!.is_open).toBe(true)
expect(store.fileTree[0]!.children).toHaveLength(1)
release([]); await old
expect(store.fileTree).toHaveLength(1)
})