feat(editor): add markdown presets, heading folding and external file refresh
This commit is contained in:
@@ -41,6 +41,8 @@ pnpm dev
|
||||
|
||||
编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
|
||||
|
||||
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown;详见 [标题折叠与样式开发说明](../docs/development/标题折叠与样式开发说明.md)。
|
||||
|
||||
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。
|
||||
|
||||
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
|
||||
@@ -92,3 +94,5 @@ Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补
|
||||
## 构建体积检查
|
||||
|
||||
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。
|
||||
|
||||
Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
theme_id: paper-moments
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.7.0
|
||||
version: 1.8.0
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
|
||||
@@ -12,6 +12,8 @@ import TitleBar from './TitleBar.vue'
|
||||
import CommandPalette from './CommandPalette.vue'
|
||||
import { getIndexStatus } from '@/services/indexService'
|
||||
import { navigateToCitation } from '@/composables/useCitationNavigation'
|
||||
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
|
||||
useWorkspaceRefresh()
|
||||
|
||||
defineProps<{
|
||||
showSecondarySidebar?: boolean
|
||||
|
||||
@@ -3,6 +3,10 @@ import DiagramInteractions from './DiagramInteractions.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
const headingAppearance = useHeadingAppearanceStore()
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
const markdownPreferences = useMarkdownPreferencesStore()
|
||||
|
||||
const props = defineProps<{ source: string }>()
|
||||
const themeStore = useThemeStore()
|
||||
@@ -12,15 +16,15 @@ let renderVersion = 0
|
||||
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
|
||||
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async ([source, theme]) => {
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized)], async ([source, theme]) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source, { theme })
|
||||
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized })
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true, flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiagramInteractions><div class="markdown-content" v-html="html" /></DiagramInteractions>
|
||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" v-html="html" /></DiagramInteractions>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
/** Web fallback until the desktop host supplies filesystem events. No overlapping polls. */
|
||||
export function useWorkspaceRefresh() {
|
||||
const workspace = useWorkspaceStore()
|
||||
const editor = useEditorStore()
|
||||
let stopped = false
|
||||
let running = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function refresh() {
|
||||
if (running || stopped) return
|
||||
clearTimeout(timer)
|
||||
running = true
|
||||
try {
|
||||
if (document.visibilityState !== 'hidden' && workspace.hasVault) {
|
||||
await workspace.refreshFileTree()
|
||||
if (!stopped) {
|
||||
if (editor.currentFilePath && editor.currentFilePath === workspace.activeFilePath && !workspace.activeFile) editor.setExternalChanged()
|
||||
else await editor.checkExternalFile()
|
||||
}
|
||||
}
|
||||
} catch { /* Keep the existing tree; the store exposes the error and retries. */ }
|
||||
finally {
|
||||
running = false
|
||||
if (!stopped) timer = setTimeout(refresh, 2000)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', refresh)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
void refresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
stopped = true; clearTimeout(timer)
|
||||
window.removeEventListener('focus', refresh)
|
||||
document.removeEventListener('visibilitychange', refresh)
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
const reloadError = ref('')
|
||||
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
|
||||
if (path !== editorStore.currentFilePath || snapshot !== editorStore.content) return
|
||||
try { await editorStore.reloadExternalFile(); reloadError.value = '' } catch (error) { reloadError.value = error instanceof Error ? error.message : '重新加载失败' }
|
||||
}
|
||||
|
||||
const statusText = computed<Record<string, string>>(() => ({
|
||||
idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'),
|
||||
@@ -15,14 +25,17 @@ const statusText = computed<Record<string, string>>(() => ({
|
||||
|
||||
<template>
|
||||
<header class="editor-header">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
|
||||
<div class="editor-actions">
|
||||
<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>
|
||||
<span v-if="reloadError" class="save-status conflict" role="alert">{{ reloadError }}</span>
|
||||
<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 === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
|
||||
</div>
|
||||
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
|
||||
<button type="button" class="save-button" :disabled="['saving','conflict','external_changed'].includes(editorStore.saveStatus)" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ function updateContent(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import HeadingStyleSettings from './HeadingStyleSettings.vue'
|
||||
it('previews individual heading settings and restores theme inheritance', async () => {
|
||||
localStorage.clear()
|
||||
const wrapper = mount(HeadingStyleSettings, { global: { plugins: [createPinia()] } })
|
||||
try {
|
||||
expect(wrapper.get('input[aria-label="H1 字号"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.get('input[aria-label="H1 字号"]').setValue(42)
|
||||
await wrapper.get('select[aria-label="H1 粗细"]').setValue('400')
|
||||
expect(wrapper.get('.heading-style-preview').attributes('style')).toContain('--heading-1-size: 42px')
|
||||
expect(wrapper.get('.heading-style-preview').attributes('style')).toContain('--heading-1-weight: 400')
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.get('.heading-style-preview').attributes('data-heading-style')).toBeUndefined()
|
||||
expect(wrapper.get('.heading-style-preview').attributes('style') ?? '').not.toContain('--heading-1-size')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
import { t } from '@/i18n'
|
||||
const appearance = useHeadingAppearanceStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="ui-disclosure heading-style-settings">
|
||||
<summary>{{ t('标题样式', 'Heading styles') }}</summary>
|
||||
<div class="heading-settings-body">
|
||||
<label><input v-model="appearance.preferences.custom" type="checkbox" /> {{ t('自定义标题样式', 'Customize heading styles') }}</label>
|
||||
<p class="subtle">{{ t('关闭时跟随主题。设置作用于正文 H1–H6,不改写 Markdown,也不改变笔记属性标题。', 'Disable to follow the theme. Applies to document H1–H6 without rewriting Markdown or the metadata title.') }}</p>
|
||||
<label>{{ t('标题字体', 'Heading font') }}
|
||||
<select v-model="appearance.preferences.family" class="select" :disabled="!appearance.preferences.custom">
|
||||
<option value="inherit">{{ t('跟随正文', 'Follow body') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="sans-serif">{{ t('无衬线字体', 'Sans serif') }}</option><option value="monospace">{{ t('等宽字体', 'Monospace') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-for="(level, index) in appearance.preferences.levels" :key="index" class="heading-setting-row">
|
||||
<strong>H{{ index + 1 }}</strong>
|
||||
<label>{{ t('字号 px', 'Size px') }}<input v-model.number="level.size" class="input" type="number" min="12" max="72" :disabled="!appearance.preferences.custom" :aria-label="`H${index + 1} ${t('字号', 'size')}`" /></label>
|
||||
<label>{{ t('粗细', 'Weight') }}<select v-model.number="level.weight" class="select" :disabled="!appearance.preferences.custom" :aria-label="`H${index + 1} ${t('粗细', 'weight')}`"><option :value="400">{{ t('常规', 'Regular') }}</option><option :value="500">Medium</option><option :value="600">Semibold</option><option :value="700">{{ t('加粗', 'Bold') }}</option><option :value="800">Extra bold</option></select></label>
|
||||
</div>
|
||||
<button class="button-secondary" type="button" @click="appearance.reset">{{ t('恢复跟随主题', 'Restore theme defaults') }}</button>
|
||||
<div class="heading-style-preview" :data-heading-style="appearance.preferences.custom ? 'custom' : undefined" :style="appearance.cssVariables">
|
||||
<div class="markdown-content"><component :is="`h${index + 1}`" v-for="(_, index) in appearance.preferences.levels" :key="index">H{{ index + 1 }} {{ t('标题预览', 'Heading preview') }}</component></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.heading-settings-body { display: grid; gap: 14px; padding: 16px; }
|
||||
.heading-setting-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: end; }
|
||||
.heading-setting-row label { display: grid; gap: 6px; min-width: 0; }
|
||||
.heading-setting-row strong { align-self: center; }
|
||||
.heading-style-preview { border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 16px; overflow-wrap: anywhere; background: var(--color-surface-primary); color: var(--color-text-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMarkdownPreferencesStore, markdownPresets } from '@/stores/markdownPreferences'
|
||||
import { t } from '@/i18n'
|
||||
const store = useMarkdownPreferencesStore()
|
||||
const name = ref('')
|
||||
const error = ref('')
|
||||
function save() { error.value = store.savePreset(name.value) ? '' : t('请输入名称,最多保存 20 个预设。', 'Enter a name; up to 20 presets.'); if (!error.value) name.value = '' }
|
||||
</script>
|
||||
<template>
|
||||
<section class="markdown-preferences surface-nested">
|
||||
<h3>{{ t('Markdown 语法与编辑预设', 'Markdown syntax and editing presets') }}</h3>
|
||||
<p class="subtle">{{ t('语法和代码设置在下次打开写作编辑器时应用;不批量改写已有笔记。静态预览即时更新。', 'Syntax and code settings apply when the visual editor next opens; existing notes are not rewritten in bulk. Static previews update immediately.') }}</p>
|
||||
<div class="inline-actions"><button class="button-secondary" @click="store.apply(markdownPresets.extended)">{{ t('扩展 Markdown', 'Extended Markdown') }}</button><button class="button-secondary" @click="store.apply(markdownPresets.github)">GitHub</button><button class="button-secondary" @click="store.apply(markdownPresets.plain)">{{ t('基础 Markdown', 'Basic Markdown') }}</button></div>
|
||||
<div class="form-grid">
|
||||
<label>{{ t('标题语法', 'Heading syntax') }}<select v-model="store.preferences.heading" class="select"><option value="atx">ATX (#)</option><option value="setext">Setext (=== / ---)</option></select></label>
|
||||
<label>{{ t('无序列表', 'Bullet list') }}<select v-model="store.preferences.bullet" class="select"><option>-</option><option>*</option><option>+</option></select></label>
|
||||
<label>{{ t('有序列表', 'Ordered list') }}<select v-model="store.preferences.incrementList" class="select"><option :value="true">1. 2. 3.</option><option :value="false">1. 1. 1.</option></select></label>
|
||||
<label>{{ t('代码围栏', 'Code fence') }}<select v-model="store.preferences.fence" class="select"><option value="`">```</option><option value="~">~~~</option></select></label>
|
||||
</div>
|
||||
<p class="subtle">{{ t('Setext 适用于 H1/H2,H3–H6 仍使用 #。写作模式保存时会规范化整篇正文的标记;源码模式保留手写语法。', 'Setext applies to H1/H2; H3–H6 use #. Visual-mode saves normalize document markers; source mode preserves handwritten syntax.') }}</p>
|
||||
<div class="markdown-switches">
|
||||
<label><input v-model="store.preferences.autoLinks" type="checkbox" />{{ t('自动识别裸链接', 'Recognize bare URLs') }}</label>
|
||||
<label><input v-model="store.preferences.math" type="checkbox" />{{ t('数学公式', 'Math') }}</label>
|
||||
<label><input v-model="store.preferences.callouts" type="checkbox" />{{ t('警告框与提示框', 'Alerts and callouts') }}</label>
|
||||
<label><input v-model="store.preferences.diagrams" type="checkbox" />Mermaid</label>
|
||||
<label><input v-model="store.preferences.lineNumbers" type="checkbox" />{{ t('代码行号', 'Code line numbers') }}</label>
|
||||
<label><input v-model="store.preferences.wrapCode" type="checkbox" />{{ t('代码自动换行', 'Wrap code') }}</label>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>{{ t('代码缩进', 'Code indent') }}<select v-model.number="store.preferences.indent" class="select"><option :value="2">2</option><option :value="4">4</option><option :value="8">8</option></select></label>
|
||||
<label>{{ t('新建代码块默认语言', 'Default language for new code blocks') }}<input v-model="store.preferences.defaultLanguage" class="input" maxlength="40" placeholder="python" /></label>
|
||||
</div>
|
||||
<form class="inline-actions" @submit.prevent="save"><input v-model="name" class="input" maxlength="40" :aria-label="t('预设名称', 'Preset name')" :placeholder="t('我的预设名称', 'My preset name')" /><button class="button-primary">{{ t('保存为预设', 'Save preset') }}</button></form>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<div v-for="(preset, index) in store.customPresets" :key="preset.name" class="inline-actions"><strong>{{ preset.name }}</strong><button class="button-secondary" @click="store.apply(preset.preferences)">{{ t('应用', 'Apply') }}</button><button class="button-danger" @click="store.customPresets.splice(index, 1)">{{ t('删除', 'Delete') }}</button></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.markdown-preferences { display: grid; gap: 16px; padding: 16px; margin-block: 16px; }
|
||||
.markdown-preferences .form-grid > label { display: grid; gap: 6px; min-width: 0; }
|
||||
.markdown-switches { display: grid; grid-template-columns: repeat(auto-fit,minmax(190px,1fr)); gap: 12px; }
|
||||
.markdown-switches label { display: flex; align-items: center; gap: 8px; }
|
||||
.inline-actions { flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -15,6 +15,8 @@ import { EditorView as CodeMirror } from '@codemirror/view'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { executeEditorCommand } from '@/services/editorCommandService'
|
||||
import { headingFoldKey } from './headingFolding'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
|
||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||
|
||||
@@ -53,6 +55,47 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('applies syntax and renderer preferences when opening the visual editor', async () => {
|
||||
const preferences = useMarkdownPreferencesStore()
|
||||
preferences.preferences.heading = 'setext'
|
||||
preferences.preferences.bullet = '+'
|
||||
preferences.preferences.fence = '~'
|
||||
preferences.preferences.callouts = false
|
||||
preferences.preferences.math = false
|
||||
preferences.preferences.autoLinks = false
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# Heading\n\n- first\n- second\n\n> [!NOTE]\n> text\n\nhttps://example.com\n\n```text\ncode\n```' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
const result = editor.action(getMarkdown())
|
||||
expect(result).toContain('Heading\n===')
|
||||
expect(result).toContain('+ first')
|
||||
expect(result).toContain('~~~text')
|
||||
expect(wrapper.find('.markdown-callout').exists()).toBe(false)
|
||||
expect(wrapper.find('.ProseMirror a').exists()).toBe(false)
|
||||
})
|
||||
it('folds heading sections, retains nested state and opens hidden outline targets', async () => {
|
||||
const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nvisible'
|
||||
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="折叠 H2 B"]').trigger('click')
|
||||
await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
|
||||
expect(wrapper.findAll('.heading-fold-hidden').length).toBeGreaterThan(1)
|
||||
await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click')
|
||||
expect(wrapper.get('.heading-fold-toggle[aria-label="展开 H2 B"]').attributes('aria-expanded')).toBe('false')
|
||||
editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
let position = 0
|
||||
view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'child') position = pos })
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position)))
|
||||
expect(headingFoldKey.getState(view.state)?.size).toBe(0)
|
||||
})
|
||||
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
|
||||
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.find('.heading-fold-hidden').exists()).toBe(false)
|
||||
})
|
||||
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 })
|
||||
|
||||
@@ -8,7 +8,9 @@ import { Link } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import { basicSetup } from 'codemirror'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { keymap, EditorView as CodeEditorView } from '@codemirror/view'
|
||||
import { indentUnit } from '@codemirror/language'
|
||||
import { EditorState as CodeEditorState } from '@codemirror/state'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
|
||||
import './language-icons.css'
|
||||
@@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||
import { installCodeBlockLabels } from './codeBlockLabels'
|
||||
import { createMermaidPreview } from './mermaidPreview'
|
||||
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import { getMarkdown, $remark } from '@milkdown/kit/utils'
|
||||
import {
|
||||
createCodeBlockCommand,
|
||||
toggleEmphasisCommand,
|
||||
@@ -28,7 +30,7 @@ import {
|
||||
wrapInHeadingCommand,
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/kit/preset/commonmark'
|
||||
import { commandsCtx, editorViewCtx, parserCtx } from '@milkdown/kit/core'
|
||||
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'
|
||||
@@ -41,11 +43,16 @@ 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 { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import { t } from '@/i18n'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const headingAppearance = useHeadingAppearanceStore()
|
||||
const markdownPreferences = { ...useMarkdownPreferencesStore().normalized }
|
||||
const metadata = ref(splitNoteMetadata(props.initialContent))
|
||||
const tagDraft = ref('')
|
||||
function setTags(tags: string[]) {
|
||||
@@ -93,8 +100,14 @@ function insertCallout(event: Event) {
|
||||
function installCommands() {
|
||||
const targetPath = editorStore.currentFilePath
|
||||
const handlers: Partial<Record<EditorCommandId, CommandHandler>> = {}
|
||||
for (const [id, action] of [['editor.heading.toggle-fold', 'toggle'], ['editor.heading.fold-all', 'all'], ['editor.heading.unfold-all', 'none']] as const) {
|
||||
handlers[id] = () => { foldHeadings(action); return { ok: true } }
|
||||
}
|
||||
const toolbar: ToolbarCommand[] = ['bold', 'italic', 'ordered-list', 'bullet-list', 'inline-code', 'code-block', 'inline-math', 'math-block']
|
||||
for (const command of toolbar) handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } }
|
||||
for (const command of toolbar) {
|
||||
if (!markdownPreferences.math && command.includes('math')) continue
|
||||
handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } }
|
||||
}
|
||||
handlers['editor.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } }
|
||||
handlers['editor.heading'] = params => {
|
||||
if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-params' }
|
||||
@@ -113,6 +126,7 @@ function installCommands() {
|
||||
return { ok: true }
|
||||
}
|
||||
handlers['editor.callout'] = params => {
|
||||
if (!markdownPreferences.callouts) return { ok: false, reason: 'unsupported' }
|
||||
if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' }
|
||||
const { type, title = '', body = '', fold = '' } = params as Record<string, unknown>
|
||||
if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title)
|
||||
@@ -126,6 +140,14 @@ function installCommands() {
|
||||
handlers,
|
||||
})
|
||||
}
|
||||
|
||||
function foldHeadings(action: 'toggle' | 'all' | 'none') {
|
||||
crepe?.editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const tr = headingFoldTransaction(view.state, action)
|
||||
if (tr) view.dispatch(tr)
|
||||
})
|
||||
}
|
||||
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
|
||||
for (const [id, entry] of diagramPreviews) {
|
||||
@@ -173,7 +195,7 @@ function runCommand(command: ToolbarCommand) {
|
||||
'ordered-list': callCommand(wrapInOrderedListCommand.key),
|
||||
'bullet-list': callCommand(wrapInBulletListCommand.key),
|
||||
'inline-code': callCommand(toggleInlineCodeCommand.key),
|
||||
'code-block': callCommand(createCodeBlockCommand.key, ''),
|
||||
'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage),
|
||||
'inline-math': callCommand('ToggleLatex'),
|
||||
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
|
||||
}
|
||||
@@ -240,7 +262,7 @@ onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: metadata.value?.body ?? props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false },
|
||||
features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
@@ -304,14 +326,35 @@ onMounted(async () => {
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
renderLanguage: renderCodeLanguage,
|
||||
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
|
||||
? renderDiagram(content, applyPreview)
|
||||
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
|
||||
: config.renderPreview(language, content, applyPreview),
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme),
|
||||
indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent),
|
||||
...(markdownPreferences.wrapCode ? [CodeEditorView.lineWrapping] : [])],
|
||||
})))
|
||||
crepe.editor.config(ctx => ctx.update(remarkStringifyOptionsCtx, options => ({
|
||||
...options, setext: markdownPreferences.heading === 'setext', bullet: markdownPreferences.bullet,
|
||||
incrementListMarker: markdownPreferences.incrementList, fence: markdownPreferences.fence,
|
||||
})))
|
||||
if (!markdownPreferences.autoLinks) crepe.editor.use($remark('disable-bare-autolinks', () => () => (tree, file) => {
|
||||
type Ast = { type: string; value?: string; url?: string; children?: Ast[]; position?: { start: { offset?: number }; end: { offset?: number } } }
|
||||
const source = String(file.value)
|
||||
const walk = (node: Ast) => {
|
||||
if (node.type === 'link' && node.position) {
|
||||
const raw = source.slice(node.position.start.offset, node.position.end.offset)
|
||||
if (/^(?:https?:\/\/|www\.)\S+$/.test(raw)) {
|
||||
node.type = 'text'; node.value = raw; delete node.children; delete node.url
|
||||
}
|
||||
}
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
walk(tree as Ast)
|
||||
}))
|
||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||
crepe.editor.use(inlineCodeInputPlugin)
|
||||
crepe.editor.use(calloutPlugin)
|
||||
crepe.editor.config(configureCalloutSerialization)
|
||||
if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
|
||||
crepe.editor.use(headingFoldingPlugin)
|
||||
if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization)
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
||||
@@ -350,9 +393,11 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiagramInteractions class="visual-editor">
|
||||
<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" />
|
||||
<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>
|
||||
<button type="button" :title="t('展开所有章节', 'Unfold all sections')" :aria-label="t('展开所有章节', 'Unfold all sections')" @click="foldHeadings('none')">▾</button>
|
||||
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
|
||||
@@ -383,11 +428,11 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<span class="toolbar-divider" />
|
||||
<button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph"></></code></button>
|
||||
<button type="button" :title="t('代码块', 'Code block')" :aria-label="t('代码块', 'Code block')" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
|
||||
<button type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
|
||||
<button type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph">∑</span></button>
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
|
||||
<button v-if="markdownPreferences.math" type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph">∑</span></button>
|
||||
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||
<label class="toolbar-select">
|
||||
<select :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
|
||||
<select v-if="markdownPreferences.callouts" :aria-label="t('插入警告框', 'Insert callout')" @change="insertCallout">
|
||||
<option value="">{{ t('提示框', 'Callout') }}</option>
|
||||
<option v-for="(_, type) in calloutTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
@@ -411,6 +456,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
|
||||
<style scoped>
|
||||
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
|
||||
.hide-code-line-numbers :deep(.cm-lineNumbers) { display: none; }
|
||||
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-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); }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { Schema } from '@milkdown/kit/prose/model'
|
||||
import { EditorState, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { headingSections, headingFoldTransaction } from './headingFolding'
|
||||
|
||||
const schema = new Schema({ nodes: {
|
||||
doc: { content: 'block+' }, text: { group: 'inline' },
|
||||
heading: { group: 'block', content: 'inline*', attrs: { level: { default: 1 } } },
|
||||
paragraph: { group: 'block', content: 'inline*' },
|
||||
blockquote: { group: 'block', content: 'block+' },
|
||||
} })
|
||||
const h = (level: number, text: string) => schema.nodes.heading!.create({ level }, schema.text(text))
|
||||
const p = (text: string) => schema.nodes.paragraph!.create(null, schema.text(text))
|
||||
it('ends sections at same-or-higher headings and confines nested quotes to their parent', () => {
|
||||
const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('a'), h(2, 'B'), p('b'), h(1, 'C'), p('c'), schema.nodes.blockquote!.create(null, [h(2, 'D'), p('d')])])
|
||||
const sections = headingSections(doc)
|
||||
expect(sections.map(section => doc.nodeAt(section.from)?.textContent)).toEqual(['A', 'B', 'C', 'D'])
|
||||
expect(sections[0]!.end).toBe(sections[2]!.from)
|
||||
expect(sections[1]!.end).toBe(sections[2]!.from)
|
||||
expect(sections[3]!.end).toBe(doc.content.size - 1)
|
||||
})
|
||||
it('moves the caret out of collapsed content without changing document content or history', () => {
|
||||
const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('body'), h(1, 'B')])
|
||||
const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5) })
|
||||
const tr = headingFoldTransaction(state, 'all')!
|
||||
expect(tr.doc.eq(doc)).toBe(true)
|
||||
expect(tr.docChanged).toBe(false)
|
||||
expect(tr.selection.from).toBe(1)
|
||||
expect(tr.getMeta('addToHistory')).toBe(false)
|
||||
expect(headingSections(doc)).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin, PluginKey, TextSelection, type EditorState } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import type { Node } from '@milkdown/kit/prose/model'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
|
||||
type Section = { from: number; body: number; end: number; level: number }
|
||||
const sectionCache = new WeakMap<Node, Section[]>()
|
||||
/** A section ends at the next sibling heading of the same or a higher rank. */
|
||||
export function headingSections(doc: Node): Section[] {
|
||||
const cached = sectionCache.get(doc)
|
||||
if (cached) return cached
|
||||
const sections: Section[] = []
|
||||
function visit(parent: Node, start: number) {
|
||||
const children: { node: Node; pos: number }[] = []
|
||||
parent.forEach((node, offset) => children.push({ node, pos: start + offset }))
|
||||
const following: { pos: number; level: number }[] = []
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
const { node, pos } = children[i]!
|
||||
if (node.type.name === 'heading') {
|
||||
while (following.length && following[following.length - 1]!.level > node.attrs.level) following.pop()
|
||||
const next = following[following.length - 1]
|
||||
const body = pos + node.nodeSize
|
||||
const end = next?.pos ?? start + parent.content.size
|
||||
if (end > body) sections.push({ from: pos, body, end, level: Number(node.attrs.level) })
|
||||
following.push({ pos, level: Number(node.attrs.level) })
|
||||
}
|
||||
if (!node.isTextblock && node.childCount) visit(node, pos + 1)
|
||||
}
|
||||
}
|
||||
visit(doc, 0)
|
||||
sections.sort((a, b) => a.from - b.from)
|
||||
sectionCache.set(doc, sections)
|
||||
return sections
|
||||
}
|
||||
|
||||
export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'all' | 'none', position?: number) {
|
||||
const sections = headingSections(state.doc)
|
||||
const folded = new Set(headingFoldKey.getState(state) ?? [])
|
||||
if (action === 'none') folded.clear()
|
||||
else if (action === 'all') sections.forEach(section => folded.add(section.from))
|
||||
else {
|
||||
const section = position === undefined
|
||||
? sections.filter(item => item.from <= state.selection.from && item.end >= state.selection.from).pop()
|
||||
: sections.find(item => item.from === position)
|
||||
if (!section) return null
|
||||
if (folded.has(section.from)) folded.delete(section.from)
|
||||
else folded.add(section.from)
|
||||
}
|
||||
const tr = state.tr
|
||||
const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
|
||||
if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
|
||||
return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
|
||||
}
|
||||
|
||||
export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
key: headingFoldKey,
|
||||
state: {
|
||||
init: () => new Set(),
|
||||
apply(tr, previous) {
|
||||
const explicit = tr.getMeta(headingFoldKey) as Set<number> | undefined
|
||||
if (explicit) return explicit
|
||||
const sections = headingSections(tr.doc)
|
||||
const mapped = new Set<number>()
|
||||
for (const old of previous) {
|
||||
const result = tr.mapping.mapResult(old, 1)
|
||||
if (!result.deleted && sections.some(section => section.from === result.pos)) mapped.add(result.pos)
|
||||
}
|
||||
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
|
||||
if (tr.selectionSet || tr.docChanged) {
|
||||
for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
|
||||
}
|
||||
return mapped
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const folded = headingFoldKey.getState(state) ?? new Set<number>()
|
||||
const sections = headingSections(state.doc)
|
||||
const decorations: Decoration[] = []
|
||||
for (const section of sections) {
|
||||
const collapsed = folded.has(section.from)
|
||||
decorations.push(Decoration.widget(section.from + 1, view => {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false'
|
||||
button.textContent = 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.onmousedown = event => event.preventDefault()
|
||||
button.onclick = event => {
|
||||
event.preventDefault()
|
||||
const tr = headingFoldTransaction(view.state, 'toggle', section.from)
|
||||
if (tr) view.dispatch(tr)
|
||||
}
|
||||
return button
|
||||
}, { key: `${section.from}:${collapsed}:${state.doc.nodeAt(section.from)?.textContent}`, side: -1, stopEvent: () => true }))
|
||||
}
|
||||
const hidden: { body: number; end: number }[] = []
|
||||
for (const section of sections) {
|
||||
if (!folded.has(section.from)) continue
|
||||
const previous = hidden[hidden.length - 1]
|
||||
if (previous && section.body <= previous.end) previous.end = Math.max(previous.end, section.end)
|
||||
else hidden.push({ body: section.body, end: section.end })
|
||||
}
|
||||
let rangeIndex = 0
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isBlock) return
|
||||
while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
|
||||
const range = hidden[rangeIndex]
|
||||
if (range && pos >= range.body && pos + node.nodeSize <= range.end) {
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'heading-fold-hidden' }))
|
||||
return false
|
||||
}
|
||||
})
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -5,6 +5,8 @@ const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { ProviderConfig } from '@/contracts'
|
||||
import ProviderForm from './ProviderForm.vue'
|
||||
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
|
||||
import MarkdownPreferenceSettings from '@/features/editor/MarkdownPreferenceSettings.vue'
|
||||
import ChatPersonaDialog from '@/features/chat/ChatPersonaDialog.vue'
|
||||
import ProviderLogo from './ProviderLogo.vue'
|
||||
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||
@@ -85,7 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
|
||||
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
|
||||
|
||||
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
|
||||
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label><MarkdownPreferenceSettings /><HeadingStyleSettings /></div>
|
||||
|
||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||
<section class="panel provider-settings-card">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { t } from '@/i18n'
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
|
||||
import tokensCss from '@/styles/tokens.css?raw'
|
||||
import featuresCss from '@/styles/features.css?raw'
|
||||
import headingsCss from '@/styles/headings.css?raw'
|
||||
import calloutsCss from '@/styles/callouts.css?raw'
|
||||
import { calloutTypes } from '@/utils/callouts'
|
||||
import specimenHtml from './themeSpecimen.html?raw'
|
||||
@@ -22,7 +23,7 @@ const previewDocument = computed(() => {
|
||||
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
|
||||
doc.head.append(policy)
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${headingsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||
|
||||
doc.head.append(style)
|
||||
const article = doc.createElement('article')
|
||||
|
||||
@@ -96,7 +96,7 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS
|
||||
|
||||
it('offers and applies the paper theme update without discarding the active theme', async () => {
|
||||
const store = useThemeStore()
|
||||
const old = await inspectThemePackage(paperPackage.replace('version: 1.7.0', 'version: 1.6.1'))
|
||||
const old = await inspectThemePackage(paperPackage.replace('version: 1.8.0', 'version: 1.6.1'))
|
||||
await store.installThemeFromInspection(old.manifest, old.css)
|
||||
store.applyTheme('paper-moments')
|
||||
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
|
||||
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
|
||||
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
|
||||
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.7.0')
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.0')
|
||||
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
@@ -222,6 +223,7 @@ onMounted(() => {
|
||||
<div class="field"><label>{{ t('字体', 'Font') }}</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">{{ t('系统字体', 'System font') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="var(--font-ui-mono)">{{ t('等宽字体', 'Monospace') }}</option></select></div>
|
||||
<div class="field"><label>{{ t('代码块样式', 'Code block style') }}</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">{{ t('跟随主题', 'Follow theme') }}</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>{{ t('Markdown 渲染使用对应的 Shiki GitHub 主题', 'Markdown rendering uses the matching Shiki GitHub theme') }}</small></div>
|
||||
</div>
|
||||
<HeadingStyleSettings />
|
||||
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
|
||||
<div class="preview-heading"><h3>{{ t('主题预览', 'Theme Preview') }}</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
|
||||
<p>{{ t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') }}</p>
|
||||
|
||||
@@ -17,3 +17,6 @@ console.log(note);</code></pre><table><thead><tr><th>名称</th><th>状态</th><
|
||||
<figure class="specimen-chart"><figcaption>数据配色示例</figcaption><div role="img" aria-label="本地用量 30,提供商用量 70"><span style="height:30%;background:var(--color-accent-primary)"></span><span style="height:70%;background:var(--color-accent-secondary)"></span></div></figure>
|
||||
<p class="subtle specimen-long">超长模型标识:provider/model-with-a-very-long-identifier-for-layout-validation-012345678901234567890123456789</p>
|
||||
</div>
|
||||
|
||||
<section class="surface-nested markdown-preferences"><h2>Markdown 语法预设</h2><label>标题样式 <select class="select"><option>ATX (#)</option><option>Setext</option></select></label><label><input type="checkbox" checked> 警告框与提示框</label><button class="button-secondary">保存为预设</button></section>
|
||||
<section class="milkdown"><div class="ProseMirror"><h2><button class="heading-fold-toggle" aria-expanded="true" aria-label="折叠示例标题"></button>悬停查看折叠箭头</h2><p>折叠按钮跟随主题,键盘聚焦时也可见。</p></div></section>
|
||||
|
||||
@@ -216,6 +216,7 @@ function containingFolder(path: string): string {
|
||||
|
||||
<template>
|
||||
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
|
||||
<p v-if="workspaceStore.treeRefreshError" class="subtle" role="status">{{ t('文件树暂未同步,将自动重试。', 'File tree sync delayed; retrying automatically.') }}</p>
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
|
||||
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const editorCommandIds = [
|
||||
'editor.font-size', 'editor.insert-markdown', 'editor.import-note-properties',
|
||||
'editor.metadata.edit', 'editor.metadata.title', 'editor.metadata.tags',
|
||||
'editor.reference-link', 'editor.html', 'editor.undo', 'editor.redo',
|
||||
'editor.heading.toggle-fold', 'editor.heading.fold-all', 'editor.heading.unfold-all',
|
||||
] as const
|
||||
export type EditorCommandId = typeof editorCommandIds[number]
|
||||
export type CommandResult = { ok: true } | { ok: false; reason: 'unsupported' | 'unavailable' | 'invalid-params' | 'failed' }
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function createNote(data: {
|
||||
|
||||
export async function updateNote(
|
||||
noteId: string,
|
||||
data: { title?: string; markdown?: string; tags?: string[] }
|
||||
data: { title?: string; markdown?: string; tags?: string[]; expected_content_hash?: string }
|
||||
): Promise<ApiNote> {
|
||||
return apiClient.patch(`/api/notes/${noteId}`, data)
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{
|
||||
theme_id: 'ocean-blue',
|
||||
name: 'Ocean Blue',
|
||||
version: '1.4.0',
|
||||
version: '1.5.0',
|
||||
author: 'community',
|
||||
description: '宁静的海洋蓝色主题,适合长时间阅读',
|
||||
min_app_version: '0.2.0',
|
||||
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{
|
||||
theme_id: 'midnight-purple',
|
||||
name: 'Midnight Purple',
|
||||
version: '2.2.0',
|
||||
version: '2.3.0',
|
||||
author: 'night-owl',
|
||||
description: '深紫色暗夜主题,适合编码',
|
||||
min_app_version: '0.2.0',
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface VaultInfo {
|
||||
}
|
||||
|
||||
let cachedTree: FileNode[] | null = null
|
||||
let treeRequestVersion = 0
|
||||
const noteIdByPath = new Map<string, string>()
|
||||
const typeByPath = new Map<string, FileNode['type']>()
|
||||
|
||||
@@ -88,6 +89,7 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
}
|
||||
|
||||
export async function openVault(path: string): Promise<VaultInfo> {
|
||||
treeRequestVersion++
|
||||
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
|
||||
cacheEntries(snapshot.items)
|
||||
return {
|
||||
@@ -104,7 +106,9 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
|
||||
}
|
||||
|
||||
export async function refreshTree(): Promise<FileNode[]> {
|
||||
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree')
|
||||
const version = ++treeRequestVersion
|
||||
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree', { timeoutMs: 10000 })
|
||||
if (version !== treeRequestVersion) return cachedTree ?? []
|
||||
return cacheEntries(entries)
|
||||
}
|
||||
|
||||
@@ -122,10 +126,12 @@ export async function getNoteId(filePath: string): Promise<string> {
|
||||
return requireNoteId(filePath)
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
export async function saveFileContent(filePath: string, content: string, expectedContent?: string): Promise<void> {
|
||||
const metadata = splitNoteMetadata(content)
|
||||
const expectedHash = expectedContent === undefined ? undefined : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedContent)))).map(byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
await noteService.updateNote(await requireNoteId(filePath), {
|
||||
markdown: content,
|
||||
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
|
||||
// Explicit [] clears the index; absent tags retain API-managed tags.
|
||||
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
.heading-fold-hidden { display: none !important; }
|
||||
.milkdown .ProseMirror .heading-fold-toggle { opacity: 0; pointer-events: none; transition: opacity 120ms ease; }
|
||||
.milkdown .ProseMirror :is(h1,h2,h3,h4,h5,h6):hover > .heading-fold-toggle,
|
||||
.milkdown .ProseMirror .heading-fold-toggle:focus-visible { opacity: 1; pointer-events: auto; }
|
||||
@media (hover: none) { .milkdown .ProseMirror .heading-fold-toggle { opacity: 1; pointer-events: auto; } }
|
||||
@media (prefers-reduced-motion: reduce) { .milkdown .ProseMirror .heading-fold-toggle { transition: none; } }
|
||||
.milkdown .ProseMirror .heading-fold-toggle { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; width: 24px; height: 28px; padding: 0; margin-inline-end: 5px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); font: 16px/1 system-ui; cursor: pointer; user-select: none; }
|
||||
.milkdown .ProseMirror .heading-fold-toggle:hover { background: var(--color-background-hover); color: var(--color-accent-primary); }
|
||||
.milkdown .ProseMirror .heading-fold-toggle:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) :is(h1,h2,h3,h4,h5,h6) { font-family: var(--heading-family) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h1 { font-size: var(--heading-1-size) !important; font-weight: var(--heading-1-weight) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h2 { font-size: var(--heading-2-size) !important; font-weight: var(--heading-2-weight) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h3 { font-size: var(--heading-3-size) !important; font-weight: var(--heading-3-weight) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h4 { font-size: var(--heading-4-size) !important; font-weight: var(--heading-4-weight) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h5 { font-size: var(--heading-5-size) !important; font-weight: var(--heading-5-weight) !important; }
|
||||
[data-heading-style='custom'] :is(.ProseMirror, .markdown-content) h6 { font-size: var(--heading-6-size) !important; font-weight: var(--heading-6-weight) !important; }
|
||||
|
||||
.milkdown .ProseMirror .heading-fold-toggle::before { content: ''; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); }
|
||||
.milkdown .ProseMirror .heading-fold-toggle[aria-expanded='false']::before { transform: rotate(-45deg); }
|
||||
@@ -2,8 +2,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calloutTypes, parseCallout } from './callouts'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { defaultMarkdownPreferences } from '@/stores/markdownPreferences'
|
||||
|
||||
describe('callouts', () => {
|
||||
it('keeps disabled syntax literal without leaking settings between render requests', async () => {
|
||||
const source = '> [!WARNING]\n> text\n\n$x$\n\nhttps://example.com'
|
||||
const [plain, extended] = await Promise.all([
|
||||
renderMarkdown(source, { preferences: { ...defaultMarkdownPreferences, callouts: false, math: false, autoLinks: false } }),
|
||||
renderMarkdown(source),
|
||||
])
|
||||
expect(plain).not.toContain('markdown-callout')
|
||||
expect(plain).not.toContain('katex')
|
||||
expect(plain).not.toContain('<a ')
|
||||
expect(extended).toContain('markdown-callout')
|
||||
expect(extended).toContain('katex')
|
||||
expect(extended).toContain('<a ')
|
||||
})
|
||||
for (const [type, aliases] of Object.entries(calloutTypes)) {
|
||||
for (const alias of aliases) it(`renders ${alias}`, async () => {
|
||||
const root = document.createElement('div')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { Marked } from 'marked'
|
||||
import { defaultMarkdownPreferences, type MarkdownPreferences } from '@/stores/markdownPreferences'
|
||||
import { createHighlighterCore } from 'shiki/core'
|
||||
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||
import { bundledLanguagesInfo } from 'shiki/langs'
|
||||
@@ -12,7 +13,16 @@ import 'katex/dist/katex.min.css'
|
||||
import { parseCallout, escapeCalloutTitle } from './callouts'
|
||||
import '@/styles/callouts.css'
|
||||
|
||||
function mathHtml(source: string, displayMode: boolean) {
|
||||
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
|
||||
const label = source.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
|
||||
}
|
||||
|
||||
function createMarkdownParser(preferences: MarkdownPreferences) {
|
||||
const marked = new Marked()
|
||||
marked.use({ renderer: { blockquote(token) {
|
||||
if (!preferences.callouts) return false
|
||||
const callout = parseCallout(token.text)
|
||||
if (!callout) return false
|
||||
const title = escapeCalloutTitle(callout.title)
|
||||
@@ -23,13 +33,7 @@ marked.use({ renderer: { blockquote(token) {
|
||||
: `<aside ${attributes}><div class="callout-title">${title}</div><div class="callout-body">${body}</div></aside>`
|
||||
} } })
|
||||
|
||||
function mathHtml(source: string, displayMode: boolean) {
|
||||
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
|
||||
const label = source.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
|
||||
}
|
||||
|
||||
marked.use({extensions:[
|
||||
if (preferences.math) marked.use({extensions:[
|
||||
{name:'blockMath',level:'block',tokenizer(source) {
|
||||
const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(source)
|
||||
if (match) return {type:'blockMath',raw:match[0],text:match[1]!.trim()}
|
||||
@@ -43,6 +47,9 @@ marked.use({extensions:[
|
||||
]})
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
if (!preferences.autoLinks) marked.use({ tokenizer: { url() { return undefined } } })
|
||||
return marked
|
||||
}
|
||||
|
||||
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||
let highlighter: ReturnType<typeof createHighlighterCore> | undefined
|
||||
@@ -95,7 +102,9 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark' }): Promise<string> {
|
||||
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark'; preferences?: MarkdownPreferences }): Promise<string> {
|
||||
const preferences = options?.preferences ?? defaultMarkdownPreferences
|
||||
const marked = createMarkdownParser(preferences)
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
|
||||
@@ -103,11 +112,11 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
|
||||
for (const code of documentNode.querySelectorAll('pre > code')) {
|
||||
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
|
||||
if (requestedLanguage === 'mermaid') {
|
||||
if (requestedLanguage === 'mermaid' && preferences.diagrams) {
|
||||
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
|
||||
continue
|
||||
}
|
||||
if (requestedLanguage.toLowerCase() === 'latex') {
|
||||
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
|
||||
code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true)))
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user