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
+1
View File
@@ -116,6 +116,7 @@ class NoteUpdateRequest(Contract):
title: str | None = None title: str | None = None
markdown: str | None = None markdown: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
expected_content_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
class NoteMoveRequest(Contract): class NoteMoveRequest(Contract):
+3 -2
View File
@@ -225,7 +225,7 @@ async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"]) @router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
async def get_workspace_tree() -> list[WorkspaceEntry]: async def get_workspace_tree() -> list[WorkspaceEntry]:
return workspace_service.get_workspace_tree() return await workspace_service.refresh_workspace_tree()
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"]) @router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
@@ -286,7 +286,8 @@ async def get_note(note_id: str) -> Note:
@router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"]) @router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
async def update_note(note_id: str, request: NoteUpdateRequest) -> Note: async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
return await note_service.update_note( return await note_service.update_note(
note_id, title=request.title, markdown=request.markdown, tags=request.tags, defer_vectors=True note_id, title=request.title, markdown=request.markdown, tags=request.tags,
expected_content_hash=request.expected_content_hash, defer_vectors=True
) )
@@ -105,6 +105,14 @@ def get_workspace_tree() -> list[WorkspaceEntry]:
return _tree(get_settings().vault_path.resolve(), locations) return _tree(get_settings().vault_path.resolve(), locations)
async def refresh_workspace_tree() -> list[WorkspaceEntry]:
"""Observe external creates/deletes without waiting for vector inference."""
if get_workspace_info().requires_refresh:
await _register_workspace_files()
index_service.schedule_workspace_rebuild()
return get_workspace_tree()
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot: async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。""" """打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。"""
+31
View File
@@ -114,3 +114,34 @@ def test_workspace_openapi_paths_are_published() -> None:
"/api/workspace/folders/delete", "/api/workspace/folders/delete",
"/api/notes/{note_id}/rename", "/api/notes/{note_id}/rename",
} <= paths.keys() } <= paths.keys()
def test_external_files_are_registered_and_removed_without_vector_wait(monkeypatch) -> None:
from app.services import index_service
scheduled = []
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: scheduled.append(True))
vault = get_settings().vault_path
vault.mkdir(parents=True, exist_ok=True)
external = vault / 'external.md'
external.write_text('# External\n', encoding='utf-8')
tree = asyncio.run(get_workspace_tree())
assert tree[0].note_id is not None
external.rename(vault / 'renamed.md')
tree = asyncio.run(get_workspace_tree())
assert [item.name for item in tree] == ['renamed.md']
(vault / 'renamed.md').unlink()
assert asyncio.run(get_workspace_tree()) == []
assert len(scheduled) == 3
def test_save_rejects_external_content_change() -> None:
import hashlib
from app.contracts import NoteUpdateRequest
from app.routes import update_note
original = '# Original\n'
note = asyncio.run(create_note(NoteCreateRequest(title='Conflict', markdown=original)))
disk = get_settings().vault_path / note.file_path
disk.write_text('# External\n', encoding='utf-8')
with pytest.raises(ApiError) as error:
asyncio.run(update_note(note.note_id, NoteUpdateRequest(markdown='# Editor\n', expected_content_hash=hashlib.sha256(original.encode()).hexdigest())))
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert disk.read_text(encoding='utf-8') == '# External\n'
+4
View File
@@ -41,6 +41,8 @@ pnpm dev
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。 编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown;详见 [标题折叠与样式开发说明](../docs/development/标题折叠与样式开发说明.md)。
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。 工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。 语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
@@ -92,3 +94,5 @@ Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补
## 构建体积检查 ## 构建体积检查
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。 执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。
Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。
@@ -1,6 +1,6 @@
theme_id: paper-moments theme_id: paper-moments
name: 纸间时光 · Paper Moments name: 纸间时光 · Paper Moments
version: 1.7.0 version: 1.8.0
author: NotesAgent author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0 min_app_version: 0.2.0
@@ -12,6 +12,8 @@ import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue' import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService' import { getIndexStatus } from '@/services/indexService'
import { navigateToCitation } from '@/composables/useCitationNavigation' import { navigateToCitation } from '@/composables/useCitationNavigation'
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
useWorkspaceRefresh()
defineProps<{ defineProps<{
showSecondarySidebar?: boolean showSecondarySidebar?: boolean
@@ -3,6 +3,10 @@ import DiagramInteractions from './DiagramInteractions.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme' 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 props = defineProps<{ source: string }>()
const themeStore = useThemeStore() const themeStore = useThemeStore()
@@ -12,15 +16,15 @@ let renderVersion = 0
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light')) const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。 // 主题切换需要重渲染: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 version = ++renderVersion
const result = await renderMarkdown(source, { theme }) const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized })
if (version === renderVersion) html.value = result if (version === renderVersion) html.value = result
}, { immediate: true, flush: 'post' }) }, { immediate: true, flush: 'post' })
</script> </script>
<template> <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> </template>
<style> <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)
})
}
+15 -2
View File
@@ -1,11 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace' 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' import { t } from '@/i18n'
const editorStore = useEditorStore() const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore() 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>>(() => ({ const statusText = computed<Record<string, string>>(() => ({
idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'), 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> <template>
<header class="editor-header"> <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="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions"> <div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span> <span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<button v-if="['conflict', 'external_changed'].includes(editorStore.saveStatus)" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
<span v-if="reloadError" class="save-status conflict" role="alert">{{ reloadError }}</span>
<div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')"> <div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button> <button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button> <button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
</div> </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> </div>
</header> </header>
</template> </template>
+1 -1
View File
@@ -24,7 +24,7 @@ function updateContent(event: Event) {
</script> </script>
<template> <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" /> :initial-content="editorStore.content" />
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck" <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" /> :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 H1H6 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/H2H3–H6 仍使用 #。写作模式保存时会规范化整篇正文的标记;源码模式保留手写语法。', 'Setext applies to H1/H2; H3H6 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 { renderMarkdown } from '@/utils/markdown'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService' import { executeEditorCommand } from '@/services/editorCommandService'
import { headingFoldKey } from './headingFolding'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
type EditorComponent = { getEditor: () => Editor | undefined } type EditorComponent = { getEditor: () => Editor | undefined }
@@ -53,6 +55,47 @@ afterEach(() => {
}) })
describe('VisualMarkdownEditor formatting toolbars', () => { 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 () => { it('renders and folds callouts without losing portable Markdown on serialization', async () => {
const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容' const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body }) const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
@@ -8,7 +8,9 @@ import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe' import { Crepe } from '@milkdown/crepe'
import { codeBlockConfig } from '@milkdown/kit/component/code-block' import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror' import { basicSetup } from 'codemirror'
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 { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror' import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css' import './language-icons.css'
@@ -16,7 +18,7 @@ import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels' import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview' import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown } from '@milkdown/kit/utils' import { getMarkdown, $remark } from '@milkdown/kit/utils'
import { import {
createCodeBlockCommand, createCodeBlockCommand,
toggleEmphasisCommand, toggleEmphasisCommand,
@@ -28,7 +30,7 @@ import {
wrapInHeadingCommand, wrapInHeadingCommand,
wrapInOrderedListCommand, wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark' } 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 { Slice } from '@milkdown/kit/prose/model'
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService' import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService'
import { TextSelection } from '@milkdown/kit/prose/state' import { TextSelection } from '@milkdown/kit/prose/state'
@@ -41,11 +43,16 @@ import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdow
import { inlineCodeInputPlugin } from './inlineCodeInput' import { inlineCodeInputPlugin } from './inlineCodeInput'
import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin' import { calloutPlugin, configureCalloutSerialization } from './calloutPlugin'
import { calloutTypes } from '@/utils/callouts' import { calloutTypes } from '@/utils/callouts'
import { headingFoldingPlugin, headingFoldTransaction } from './headingFolding'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { t } from '@/i18n' import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>() const props = defineProps<{ initialContent: string }>()
const headingAppearance = useHeadingAppearanceStore()
const markdownPreferences = { ...useMarkdownPreferencesStore().normalized }
const metadata = ref(splitNoteMetadata(props.initialContent)) const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('') const tagDraft = ref('')
function setTags(tags: string[]) { function setTags(tags: string[]) {
@@ -93,8 +100,14 @@ function insertCallout(event: Event) {
function installCommands() { function installCommands() {
const targetPath = editorStore.currentFilePath const targetPath = editorStore.currentFilePath
const handlers: Partial<Record<EditorCommandId, CommandHandler>> = {} 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'] 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.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } }
handlers['editor.heading'] = params => { handlers['editor.heading'] = params => {
if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-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 } return { ok: true }
} }
handlers['editor.callout'] = params => { handlers['editor.callout'] = params => {
if (!markdownPreferences.callouts) return { ok: false, reason: 'unsupported' }
if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' } if (!params || typeof params !== 'object') return { ok: false, reason: 'invalid-params' }
const { type, title = '', body = '', fold = '' } = params as Record<string, unknown> 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) if (typeof type !== 'string' || !/^[\w-]{1,64}$/.test(type) || typeof title !== 'string' || /[\r\n]/.test(title)
@@ -126,6 +140,14 @@ function installCommands() {
handlers, 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 }>() const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
function renderDiagram(source: string, apply: (value: HTMLElement) => void) { function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
for (const [id, entry] of diagramPreviews) { for (const [id, entry] of diagramPreviews) {
@@ -173,7 +195,7 @@ function runCommand(command: ToolbarCommand) {
'ordered-list': callCommand(wrapInOrderedListCommand.key), 'ordered-list': callCommand(wrapInOrderedListCommand.key),
'bullet-list': callCommand(wrapInBulletListCommand.key), 'bullet-list': callCommand(wrapInBulletListCommand.key),
'inline-code': callCommand(toggleInlineCodeCommand.key), 'inline-code': callCommand(toggleInlineCodeCommand.key),
'code-block': callCommand(createCodeBlockCommand.key, ''), 'code-block': callCommand(createCodeBlockCommand.key, markdownPreferences.defaultLanguage),
'inline-math': callCommand('ToggleLatex'), 'inline-math': callCommand('ToggleLatex'),
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'), 'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
} }
@@ -240,7 +262,7 @@ onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: editorRoot.value, root: editorRoot.value,
defaultValue: metadata.value?.body ?? props.initialContent, defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false }, features: { [Crepe.Feature.TopBar]: false, [Crepe.Feature.Latex]: markdownPreferences.math },
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') }, [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: { [Crepe.Feature.CodeMirror]: {
@@ -304,14 +326,35 @@ onMounted(async () => {
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme), languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage, renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid' renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? renderDiagram(content, applyPreview) ? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
: config.renderPreview(language, content, applyPreview), : 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(fontSizeMarkdownPlugin)
crepe.editor.use(inlineCodeInputPlugin) crepe.editor.use(inlineCodeInputPlugin)
crepe.editor.use(calloutPlugin) if (markdownPreferences.callouts) crepe.editor.use(calloutPlugin)
crepe.editor.config(configureCalloutSerialization) crepe.editor.use(headingFoldingPlugin)
if (markdownPreferences.callouts) crepe.editor.config(configureCalloutSerialization)
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
@@ -350,9 +393,11 @@ defineExpose({ getEditor: () => crepe?.editor })
</script> </script>
<template> <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" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')"> <div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<button type="button" :title="t('折叠所有章节', 'Fold all sections')" :aria-label="t('折叠所有章节', 'Fold all sections')" @click="foldHeadings('all')"></button>
<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')"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading"> <select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
@@ -383,11 +428,11 @@ defineExpose({ getEditor: () => crepe?.editor })
<span class="toolbar-divider" /> <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">&lt;/&gt;</code></button> <button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</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('代码块', '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 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 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('公式块', '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> <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"> <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 value="">{{ t('提示框', 'Callout') }}</option>
<option v-for="(_, type) in calloutTypes" :key="type" :value="type">{{ type }}</option> <option v-for="(_, type) in calloutTypes" :key="type" :value="type">{{ type }}</option>
</select> </select>
@@ -411,6 +456,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<style scoped> <style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); } .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 { 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 { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); } .markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
@@ -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 { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts' import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue' 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 ChatPersonaDialog from '@/features/chat/ChatPersonaDialog.vue'
import ProviderLogo from './ProviderLogo.vue' import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue' import ModelRoutingSettings from './ModelRoutingSettings.vue'
@@ -85,7 +87,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" /> <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-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"> <div v-else-if="activeSection === 'providers'" class="settings-section">
<section class="panel provider-settings-card"> <section class="panel provider-settings-card">
@@ -5,6 +5,7 @@ import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService' import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw' import tokensCss from '@/styles/tokens.css?raw'
import featuresCss from '@/styles/features.css?raw' import featuresCss from '@/styles/features.css?raw'
import headingsCss from '@/styles/headings.css?raw'
import calloutsCss from '@/styles/callouts.css?raw' import calloutsCss from '@/styles/callouts.css?raw'
import { calloutTypes } from '@/utils/callouts' import { calloutTypes } from '@/utils/callouts'
import specimenHtml from './themeSpecimen.html?raw' 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'" 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) doc.head.append(policy)
const style = doc.createElement('style') 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) doc.head.append(style)
const article = doc.createElement('article') 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 () => { it('offers and applies the paper theme update without discarding the active theme', async () => {
const store = useThemeStore() 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) await store.installThemeFromInspection(old.manifest, old.css)
store.applyTheme('paper-moments') store.applyTheme('paper-moments')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) 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'))! 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 card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
await flushPromises() 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') expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
}) })
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import HeadingStyleSettings from '@/features/editor/HeadingStyleSettings.vue'
import AppDialog from '@/components/common/AppDialog.vue' import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.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('字体', '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 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> </div>
<HeadingStyleSettings />
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"> <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> <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> <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> <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> <p class="subtle specimen-long">超长模型标识:provider/model-with-a-very-long-identifier-for-layout-validation-012345678901234567890123456789</p>
</div> </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> <template>
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch"> <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" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs"> <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> <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.font-size', 'editor.insert-markdown', 'editor.import-note-properties',
'editor.metadata.edit', 'editor.metadata.title', 'editor.metadata.tags', 'editor.metadata.edit', 'editor.metadata.title', 'editor.metadata.tags',
'editor.reference-link', 'editor.html', 'editor.undo', 'editor.redo', 'editor.reference-link', 'editor.html', 'editor.undo', 'editor.redo',
'editor.heading.toggle-fold', 'editor.heading.fold-all', 'editor.heading.unfold-all',
] as const ] as const
export type EditorCommandId = typeof editorCommandIds[number] export type EditorCommandId = typeof editorCommandIds[number]
export type CommandResult = { ok: true } | { ok: false; reason: 'unsupported' | 'unavailable' | 'invalid-params' | 'failed' } export type CommandResult = { ok: true } | { ok: false; reason: 'unsupported' | 'unavailable' | 'invalid-params' | 'failed' }
+1 -1
View File
@@ -25,7 +25,7 @@ export async function createNote(data: {
export async function updateNote( export async function updateNote(
noteId: string, noteId: string,
data: { title?: string; markdown?: string; tags?: string[] } data: { title?: string; markdown?: string; tags?: string[]; expected_content_hash?: string }
): Promise<ApiNote> { ): Promise<ApiNote> {
return apiClient.patch(`/api/notes/${noteId}`, data) return apiClient.patch(`/api/notes/${noteId}`, data)
} }
+2 -2
View File
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{ {
theme_id: 'ocean-blue', theme_id: 'ocean-blue',
name: 'Ocean Blue', name: 'Ocean Blue',
version: '1.4.0', version: '1.5.0',
author: 'community', author: 'community',
description: '宁静的海洋蓝色主题,适合长时间阅读', description: '宁静的海洋蓝色主题,适合长时间阅读',
min_app_version: '0.2.0', min_app_version: '0.2.0',
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
{ {
theme_id: 'midnight-purple', theme_id: 'midnight-purple',
name: 'Midnight Purple', name: 'Midnight Purple',
version: '2.2.0', version: '2.3.0',
author: 'night-owl', author: 'night-owl',
description: '深紫色暗夜主题,适合编码', description: '深紫色暗夜主题,适合编码',
min_app_version: '0.2.0', min_app_version: '0.2.0',
+8 -2
View File
@@ -19,6 +19,7 @@ export interface VaultInfo {
} }
let cachedTree: FileNode[] | null = null let cachedTree: FileNode[] | null = null
let treeRequestVersion = 0
const noteIdByPath = new Map<string, string>() const noteIdByPath = new Map<string, string>()
const typeByPath = new Map<string, FileNode['type']>() 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> { export async function openVault(path: string): Promise<VaultInfo> {
treeRequestVersion++
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 }) const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items) cacheEntries(snapshot.items)
return { return {
@@ -104,7 +106,9 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
} }
export async function refreshTree(): Promise<FileNode[]> { 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) return cacheEntries(entries)
} }
@@ -122,10 +126,12 @@ export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath) 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 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), { await noteService.updateNote(await requireNoteId(filePath), {
markdown: content, markdown: content,
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
// Explicit [] clears the index; absent tags retain API-managed tags. // Explicit [] clears the index; absent tags retain API-managed tags.
...(metadata?.hasTags ? { tags: metadata.tags } : {}), ...(metadata?.hasTags ? { tags: metadata.tags } : {}),
}) })
+42 -6
View File
@@ -3,10 +3,13 @@ import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts' import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService' import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n' import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg') const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
const content = ref('') const content = ref('')
const contentRevision = ref(0)
let diskContent: string | undefined
const saveStatus = ref<SaveStatus>('idle') const saveStatus = ref<SaveStatus>('idle')
const lastSavedAt = ref<string | null>(null) const lastSavedAt = ref<string | null>(null)
const currentNoteId = ref<string | null>(null) const currentNoteId = ref<string | null>(null)
@@ -35,13 +38,14 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) { function updateContent(newContent: string) {
content.value = newContent 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 saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) { function scheduleAutoSave(delay = 1500) {
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
saveTimer = null saveTimer = null
@@ -51,20 +55,23 @@ export const useEditorStore = defineStore('editor', () => {
async function save() { async function save() {
if (!currentFilePath.value) return if (!currentFilePath.value) return
if (saveStatus.value === 'conflict' || saveStatus.value === 'external_changed') return
if (pendingSave) return pendingSave if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。 // 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value const targetPath = currentFilePath.value
const snapshot = content.value const snapshot = content.value
const baseline = diskContent
saveStatus.value = 'saving' saveStatus.value = 'saving'
pendingSave = (async () => { pendingSave = (async () => {
try { try {
await workspaceService.saveFileContent(targetPath, snapshot) await workspaceService.saveFileContent(targetPath, snapshot, baseline)
if (currentFilePath.value === targetPath) { if (currentFilePath.value === targetPath) {
diskContent = snapshot
saveStatus.value = content.value === snapshot ? 'saved' : 'dirty' saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
} }
} catch { } catch (error) {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed' if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
} finally { } finally {
pendingSave = null pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave() if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
@@ -86,7 +93,7 @@ export const useEditorStore = defineStore('editor', () => {
} }
if (pendingSave) await pendingSave if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save() 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.')) 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 currentFilePath.value = filePath
currentNoteId.value = loadedNoteId currentNoteId.value = loadedNoteId
content.value = loadedContent content.value = loadedContent
diskContent = loadedContent
saveStatus.value = 'saved' saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
} catch (error) { } catch (error) {
@@ -122,13 +130,37 @@ export const useEditorStore = defineStore('editor', () => {
} }
function setExternalChanged() { 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' saveStatus.value = 'conflict'
} else { } else {
saveStatus.value = 'external_changed' 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): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。 // TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
function closeFile() { function closeFile() {
@@ -137,6 +169,7 @@ export const useEditorStore = defineStore('editor', () => {
currentFilePath.value = null currentFilePath.value = null
currentNoteId.value = null currentNoteId.value = null
content.value = '' content.value = ''
diskContent = undefined
saveStatus.value = 'idle' saveStatus.value = 'idle'
lastSavedAt.value = null lastSavedAt.value = null
highlightBlockId.value = null highlightBlockId.value = null
@@ -153,6 +186,9 @@ export const useEditorStore = defineStore('editor', () => {
jumpToHeading, jumpToHeading,
mode, mode,
content, content,
contentRevision,
checkExternalFile,
reloadExternalFile,
saveStatus, saveStatus,
lastSavedAt, lastSavedAt,
currentNoteId, currentNoteId,
+25 -1
View File
@@ -6,6 +6,30 @@ import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() }) 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 () => { it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers() vi.useFakeTimers()
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -20,6 +44,6 @@ it('saves text typed while the previous save is still pending', async () => {
await saving await saving
expect(store.saveStatus).toBe('dirty') expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500) await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest') expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest', 'first')
expect(store.saveStatus).toBe('saved') 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 { defineStore } from 'pinia'
import { useHeadingAppearanceStore } from './headingAppearance'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts' import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService' import * as themePkg from '@/services/themePackageService'
import { t } from '@/i18n' import { t } from '@/i18n'
const builtinThemes = (): ThemeConfig[] => [ 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: '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.2.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' }, { 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.2.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' }, { 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' export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
@@ -158,6 +159,7 @@ export const useThemeStore = defineStore('theme', () => {
} }
function resetToDefault() { function resetToDefault() {
useHeadingAppearanceStore().reset()
applyTheme('light') applyTheme('light')
fontEditorSize.value = 15 fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui' fontEditorFamily.value = 'system-ui'
+22 -1
View File
@@ -13,6 +13,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const isLoading = ref(false) const isLoading = ref(false)
const hasVault = ref(false) const hasVault = ref(false)
const recentVaults = ref<workspaceService.VaultInfo[]>([]) const recentVaults = ref<workspaceService.VaultInfo[]>([])
const treeRefreshError = ref<string | null>(null)
let refreshSequence = 0
const activeFile = computed(() => { const activeFile = computed(() => {
if (!activeFilePath.value) return null if (!activeFilePath.value) return null
@@ -65,10 +67,27 @@ export const useWorkspaceStore = defineStore('workspace', () => {
/** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */ /** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */
async function refreshFileTree() { async function refreshFileTree() {
if (!hasVault.value) return 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) { async function openVault(path: string) {
refreshSequence++
isLoading.value = true isLoading.value = true
try { try {
const info = await workspaceService.openVault(path) const info = await workspaceService.openVault(path)
@@ -84,6 +103,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
} }
async function createVault(path: string, name: string) { async function createVault(path: string, name: string) {
refreshSequence++
isLoading.value = true isLoading.value = true
try { try {
const info = await workspaceService.createVault(path, name) const info = await workspaceService.createVault(path, name)
@@ -162,6 +182,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading, isLoading,
hasVault, hasVault,
recentVaults, recentVaults,
treeRefreshError,
toggleFolder, toggleFolder,
openFile, openFile,
closeFile, 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)
})
+19
View File
@@ -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); }
+14
View File
@@ -2,8 +2,22 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { calloutTypes, parseCallout } from './callouts' import { calloutTypes, parseCallout } from './callouts'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import { defaultMarkdownPreferences } from '@/stores/markdownPreferences'
describe('callouts', () => { 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 [type, aliases] of Object.entries(calloutTypes)) {
for (const alias of aliases) it(`renders ${alias}`, async () => { for (const alias of aliases) it(`renders ${alias}`, async () => {
const root = document.createElement('div') const root = document.createElement('div')
+20 -11
View File
@@ -1,5 +1,6 @@
import DOMPurify from 'dompurify' 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 { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs' import { bundledLanguagesInfo } from 'shiki/langs'
@@ -12,7 +13,16 @@ import 'katex/dist/katex.min.css'
import { parseCallout, escapeCalloutTitle } from './callouts' import { parseCallout, escapeCalloutTitle } from './callouts'
import '@/styles/callouts.css' 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
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) { marked.use({ renderer: { blockquote(token) {
if (!preferences.callouts) return false
const callout = parseCallout(token.text) const callout = parseCallout(token.text)
if (!callout) return false if (!callout) return false
const title = escapeCalloutTitle(callout.title) 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>` : `<aside ${attributes}><div class="callout-title">${title}</div><div class="callout-body">${body}</div></aside>`
} } }) } } })
function mathHtml(source: string, displayMode: boolean) { if (preferences.math) marked.use({extensions:[
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
const label = source.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
}
marked.use({extensions:[
{name:'blockMath',level:'block',tokenizer(source) { {name:'blockMath',level:'block',tokenizer(source) {
const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(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()} 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 }) marked.setOptions({ gfm: true, breaks: true })
if (!preferences.autoLinks) marked.use({ tokenizer: { url() { return undefined } } })
return marked
}
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。 // Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
let highlighter: ReturnType<typeof createHighlighterCore> | undefined 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 html = marked.parse(source, { async: false }) as string
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html') 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')) { for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text' 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 ?? '' }) mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
continue continue
} }
if (requestedLanguage.toLowerCase() === 'latex') { if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true))) code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true)))
continue continue
} }