feat(frontend): 完善主题导入与手帐工作区并修复 Mermaid 预览

This commit is contained in:
2026-09-05 18:42:14 +08:00
parent 311ea4a8ac
commit d5b1050a86
14 changed files with 655 additions and 96 deletions
@@ -9,6 +9,8 @@ import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -41,6 +43,7 @@ const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
@@ -125,7 +128,9 @@ onMounted(async () => {
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
previewOnlyByDefault: false,
previewOnlyByDefault: true,
previewToggleText: previewOnly => previewOnly ? t('编辑', 'Edit') : t('预览', 'Preview'),
previewLabel: t('图表预览', 'Preview'),
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
copyText: t('复制', 'Copy'),
@@ -182,6 +187,9 @@ onMounted(async () => {
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? createMermaidPreview(content, themeStore.isDark, applyPreview)
: config.renderPreview(language, content, applyPreview),
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
@@ -195,13 +203,14 @@ onMounted(async () => {
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences()
loading.value = false
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -273,6 +282,9 @@ defineExpose({ getEditor: () => crepe?.editor })
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; }
.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); }
.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; }
.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; }
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
.milkdown-host :deep(.milkdown) {
min-height: 100%;
@@ -0,0 +1,18 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { installCodeBlockLabels } from './codeBlockLabels'
it('keeps footer labels in sync when the language changes and stops after disposal', async () => {
const root = document.createElement('div')
root.innerHTML = '<div class="milkdown-code-block"><button class="language-button">Python</button></div>'
const dispose = installCodeBlockLabels(root)
const block = root.firstElementChild as HTMLElement
expect(block.dataset.languageLabel).toBe('Python')
block.querySelector('button')!.textContent = 'TypeScript'
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('TypeScript')
dispose()
block.querySelector('button')!.textContent = 'Rust'
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('TypeScript')
})
@@ -0,0 +1,11 @@
/** Mirror the live picker label for theme decorations without changing Markdown. */
export function installCodeBlockLabels(root: HTMLElement): () => void {
const sync = () => root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => {
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label
})
const observer = new MutationObserver(sync)
observer.observe(root, { subtree: true, childList: true, characterData: true })
sync()
return () => observer.disconnect()
}
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { renderMermaid } from '@/services/mermaidService'
import { createMermaidPreview } from './mermaidPreview'
vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn() }))
it('renders SVG with the requested theme and keeps async revisions isolated', async () => {
let finish!: (value: any) => void
vi.mocked(renderMermaid).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '<svg><text>new</text></svg>', warnings: [], width: 10, height: 10 })
const oldPublish = vi.fn()
const latestPublish = vi.fn()
const old = createMermaidPreview('graph TD; A-->B', false, oldPublish)
const latest = createMermaidPreview('graph TD; A-->C', true, latestPublish)
document.body.append(latest.cloneNode(true))
await flushPromises()
finish({ svg: '<svg><text>old</text></svg>', warnings: [] })
await flushPromises()
expect(latest.querySelector('svg')?.textContent).toBe('new')
expect(old.querySelector('svg')?.textContent).toBe('old')
expect(oldPublish).not.toHaveBeenCalled()
expect(latestPublish).toHaveBeenCalledWith(latest)
expect(latestPublish.mock.calls[0]![0]).not.toBe(latest)
document.getElementById(latest.id)?.remove()
expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' })
})
it('shows syntax errors as text without executing markup', async () => {
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: ['<img src=x onerror=alert(1)>'], width: 0, height: 0 })
const preview = createMermaidPreview('invalid', false, vi.fn())
await flushPromises()
expect(preview.classList.contains('has-error')).toBe(true)
expect(preview.querySelector('img')).toBeNull()
expect(preview.textContent).toContain('点击编辑')
})
@@ -0,0 +1,39 @@
import { nextTick } from 'vue'
import { renderMermaid } from '@/services/mermaidService'
import { t } from '@/i18n'
let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
// Each revision owns its element, so a slow render cannot replace newer content.
const container = document.createElement('div')
container.className = 'editor-mermaid-preview'
container.id = `editor-mermaid-preview-${++previewId}`
container.setAttribute('aria-live', 'polite')
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => {
await nextTick()
// Milkdown sanitizes and copies this element. Publish only if its revision
// still exists; edits, language changes and unmounts remove the old marker.
const visible = document.getElementById(container.id)
if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback.
applyPreview(container.cloneNode(true) as HTMLElement)
}
}
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
if (result.warnings.length) {
container.classList.add('has-error')
container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}`
void publish()
return
}
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
container.innerHTML = result.svg
void publish()
}).catch(() => {
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
void publish()
})
return container
}
@@ -16,10 +16,15 @@ const previewDocument = computed(() => {
style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
doc.head.append(style)
const article = doc.createElement('article')
article.className = 'panel'
const header = doc.createElement('header'); header.className = 'feature-header'
const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId
header.append(heading)
const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;'
const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.')
const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button')
article.append(heading, text, button); doc.body.append(article)
journal.append(text)
article.append(header, journal, button); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML
})
</script>
@@ -5,10 +5,62 @@ import { createPinia, setActivePinia } from 'pinia'
import ThemesView from './ThemesView.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService'
import paperPackage from '@/assets/themes/paper-moments.theme?raw'
let wrapper: VueWrapper
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
afterEach(() => { useThemeStore().applyTheme('light'); wrapper?.unmount(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers() })
it('downloads a URL for inspection without automatically installing it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(paperPackage)))
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme')
await wrapper.get('.url-import').trigger('submit')
await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true))
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
expect(wrapper.get('.inspection-result').text()).toContain('纸间时光')
})
it('ignores a URL response after the dialog is cancelled', async () => {
let respond!: (response: Response) => void
vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { respond = resolve })))
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme')
await wrapper.get('.url-import').trigger('submit')
await wrapper.get('.import-modal .inline-actions button').trigger('click')
respond(new Response(paperPackage))
await flushPromises()
expect(useThemeStore().pendingInspection).toBeNull()
expect(wrapper.find('.import-modal').exists()).toBe(false)
})
it('opens the file picker from the styled button and imports the actual paper theme', async () => {
const store = useThemeStore()
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
const input = wrapper.get<HTMLInputElement>('input[type="file"]')
const click = vi.spyOn(input.element, 'click').mockImplementation(() => {})
await wrapper.get('.upload-area .button-primary').trigger('click')
expect(click).toHaveBeenCalledOnce()
Object.defineProperty(input.element, 'files', { value: [new File([paperPackage], 'paper-moments.theme', { type: 'text/plain' })] })
await input.trigger('change')
await vi.waitFor(() => expect(store.pendingInspection?.compatible).toBe(true))
expect(store.pendingInspection!.warnings).toEqual([])
await wrapper.get('.import-modal .inline-actions .button-primary').trigger('click')
await flushPromises()
expect(store.isThemeInstalled('paper-moments')).toBe(true)
expect(localStorage.getItem('installed-themes-css-paper-moments')).toBe(getCommunityThemePreviewCss('paper-moments'))
expect(document.getElementById('theme-style-paper-moments')).toBeNull()
store.applyTheme('paper-moments')
expect(document.getElementById('theme-style-paper-moments')!.textContent).toBe(getCommunityThemePreviewCss('paper-moments'))
store.applyTheme('light')
expect(document.getElementById('theme-style-paper-moments')).toBeNull()
})
it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => {
const store = useThemeStore()
+83 -34
View File
@@ -1,19 +1,58 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes } from '@/services/themePackageService'
import { mockCommunityThemes, decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from '@/services/themePackageService'
import type { ThemePackageInspection } from '@/contracts'
import { t } from '@/i18n'
import CommunityThemePreview from './CommunityThemePreview.vue'
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
const themeStore = useThemeStore()
const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const previewThemeId = ref<string | null>(null)
const communityPreviewId = ref<string | null>(null)
const actionError = ref('')
const importUrl = ref('')
const importing = ref(false)
let importGeneration = 0
let downloadController: AbortController | undefined
function resetImport() {
importGeneration++
downloadController?.abort()
importing.value = false
themeStore.pendingInspection = null
themeStore.importError = null
actionError.value = ''
}
function closeImport() { resetImport(); showImportDialog.value = false }
function openImport() { resetImport(); showImportDialog.value = true }
onBeforeUnmount(resetImport)
async function importPackage(load: () => Promise<string>) {
resetImport()
const generation = importGeneration
importing.value = true
try {
const result = await inspectThemePackage(await load())
if (generation !== importGeneration) return
themeStore.pendingInspection = result
if (!result.compatible) actionError.value = result.warnings[0] ?? '主题包无法解析'
} catch (error) {
if (generation === importGeneration) actionError.value = error instanceof Error ? error.message : '导入失败'
} finally { if (generation === importGeneration) importing.value = false }
}
function importFromUrl() {
void importPackage(() => {
downloadController = new AbortController()
return fetchThemePackage(importUrl.value, downloadController.signal)
})
}
const shikiPreview = `\`\`\`typescript
const notes = await search('本地优先')
@@ -30,23 +69,16 @@ function handleFileImport(event: Event) {
const file = input.files?.[0]
input.value = ''
if (!file) return
actionError.value = ''
const reader = new FileReader()
reader.onload = async () => {
try {
const result = await themeStore.inspectThemePackage(String(reader.result ?? ''))
if (result.compatible) {
previewThemeId.value = result.manifest.theme_id
} else {
actionError.value = result.warnings[0] ?? '主题包无法解析'
}
} catch (error) {
actionError.value = error instanceof Error ? error.message : '导入失败'
}
}
reader.onerror = () => { actionError.value = '文件读取失败' }
// 主题包是文本格式(YAML 清单 + --- + CSS),二进制包在解析阶段会被拒绝。
reader.readAsText(file)
void importPackage(async () => {
if (file.size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsArrayBuffer(file)
})
return decodeThemePackage(new Uint8Array(bytes))
})
}
async function confirmInstall(inspection: ThemePackageInspection) {
@@ -90,7 +122,7 @@ onMounted(() => {
<p>浏览导入和管理主题打造你的知识工作流</p>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
<button class="button-secondary" @click="openImport">导入主题</button>
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
</div>
</header>
@@ -124,7 +156,7 @@ onMounted(() => {
:class="{ selected: themeStore.currentThemeId === theme.theme_id }"
@click="themeStore.applyTheme(theme.theme_id)"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
@@ -150,7 +182,7 @@ onMounted(() => {
:key="theme.theme_id"
class="item-card theme-card"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : 'preview-light'">
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : 'preview-light'">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
@@ -165,14 +197,15 @@ onMounted(() => {
<span v-for="tag in theme.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="theme-actions">
<a v-if="theme.theme_id === 'paper-moments'" class="button-secondary small" :href="paperMomentsUrl" download="paper-moments.theme">下载主题包</a>
<button
v-if="themeStore.isThemeInstalled(theme.theme_id)"
v-if="themeStore.allThemes.some(installed => installed.theme_id === theme.theme_id && installed.version === theme.version)"
class="button-secondary small"
@click="themeStore.applyTheme(theme.theme_id)"
>启用</button>
<template v-else>
<button class="button-secondary small" @click="previewCommunity(theme.theme_id)">预览</button>
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">安装</button>
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">{{ themeStore.isThemeInstalled(theme.theme_id) ? '更新' : '安装' }}</button>
</template>
</div>
</article>
@@ -193,11 +226,12 @@ onMounted(() => {
</div>
</div>
<div v-if="showImportDialog" class="modal-backdrop" @click.self="showImportDialog = false">
<div v-if="showImportDialog" class="modal-backdrop" @click.self="closeImport">
<div class="modal import-modal">
<span class="badge info">主题导入</span>
<h2>导入主题包</h2>
<p class="subtle">单文件主题包YAML 清单 + 一行 <code>---</code> + 主题 CSS安装前会校验清单 CSS 安全性</p>
<p class="subtle">选择本地文件或粘贴主题包直链支持单文件主题 ZIP安装前会校验清单 CSS</p>
<p v-if="actionError" class="error-banner" role="alert">{{ actionError }}</p>
<div v-if="themeStore.pendingInspection?.compatible" class="inspection-result">
<div class="inspect-head">
@@ -222,13 +256,21 @@ onMounted(() => {
</div>
<div v-else class="upload-area">
<input type="file" accept=".yaml,.yml,.theme" @change="handleFileImport" />
<p>点击选择主题包文件</p>
<p class="subtle">支持 .yaml / .yml / .themeZIP 需要 Host 端解压暂不支持</p>
<input ref="fileInput" class="theme-file-input" type="file" accept=".yaml,.yml,.theme,.zip" tabindex="-1" aria-label="主题包文件" @change="handleFileImport" />
<button type="button" class="button-primary" :disabled="importing" @click="fileInput?.click()">选择主题包文件</button>
<p>从本地导入你喜欢的主题</p>
<p class="subtle">支持 .yaml / .yml / .theme / .zip最大 5 MB</p>
<form class="url-import" @submit.prevent="importFromUrl">
<label for="theme-package-url"> URL 导入</label>
<input id="theme-package-url" v-model="importUrl" class="input" type="url" required placeholder="https://example.com/theme.zip" :disabled="importing" />
<button class="button-secondary" type="submit" :disabled="importing">{{ importing ? '正在读取…' : '下载并校验' }}</button>
<p class="subtle">请使用文件直链远程服务器需允许跨域访问</p>
</form>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = false">取消</button>
<button v-if="themeStore.pendingInspection?.compatible" class="button-secondary" @click="resetImport">重新选择</button>
<button class="button-secondary" @click="closeImport">取消</button>
<button
v-if="themeStore.pendingInspection?.compatible"
class="button-primary"
@@ -262,9 +304,16 @@ onMounted(() => {
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }
.preview-sepia span { background: #d8c69c; }
.preview-sepia div { background: #f4e8ca; }
.preview-paper { background: #fffdf5; border: 1px dashed #8b7865; box-shadow: 3px 3px 0 #d8e6e2, 6px 6px 0 #f0d8cf; }
.preview-paper span { background: #efd8d0; }
.preview-paper span:nth-child(2) { background: #d8e7e8; }
.preview-paper span:nth-child(3) { background: #f6e9b8; }
.preview-paper div { border: 1px solid #b5a693; background: repeating-linear-gradient(#fffef8 0 14px, #dce4db 14px 15px); }
.theme-actions a { text-decoration: none; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
.theme-info strong { display: block; margin-bottom: 2px; }
.theme-info > .badge { flex-shrink: 0; white-space: nowrap; }
.theme-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
@@ -347,10 +396,10 @@ onMounted(() => {
transition: border-color var(--motion-fast);
}
.upload-area:hover { border-color: var(--color-accent-secondary); }
.upload-area input {
display: block;
margin: 0 auto var(--space-md);
}
.url-import { display: grid; gap: 10px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--color-border-default); text-align: left; }
.url-import .input { width: 100%; min-width: 0; }
.upload-area .theme-file-input { display: none; }
.upload-area > button { margin-bottom: var(--space-md); }
.upload-area p { color: var(--color-text-secondary); }
.inspection-result {