feat(frontend): 添加 GitHub 代码块主题设置
This commit is contained in:
@@ -24,19 +24,26 @@ watch(() => props.source, async (source) => {
|
||||
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }
|
||||
.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
|
||||
.markdown-content :deep(li::marker) { color: var(--color-markdown-marker); font-weight: 700; }
|
||||
.markdown-content :deep(.shiki) { overflow: auto; padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); }
|
||||
.markdown-content :deep(.shiki) { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
|
||||
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||
.markdown-content :deep(pre code) { padding: 0; background: transparent; }
|
||||
.markdown-content :deep(.shiki code) { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||
.markdown-content :deep(.shiki .line) { display: block; min-height: 1.45em; }
|
||||
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
||||
.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
|
||||
.markdown-content :deep(th) { background: var(--color-markdown-table-header); font-weight: 700; }
|
||||
.markdown-content :deep(img) { max-width: 100%; }
|
||||
.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||
:global([data-theme='dark']) .markdown-content :deep(.shiki),
|
||||
:global([data-theme='dark']) .markdown-content :deep(.shiki span) {
|
||||
:global([data-code-theme='github-light']) .markdown-content :deep(.shiki),
|
||||
:global([data-code-theme='github-light']) .markdown-content :deep(.shiki span) {
|
||||
color: var(--shiki-light) !important;
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
}
|
||||
:global([data-code-theme='github-dark']) .markdown-content :deep(.shiki),
|
||||
:global([data-code-theme='github-dark']) .markdown-content :deep(.shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
|
||||
@@ -329,6 +329,7 @@ export interface ThemeConfig {
|
||||
is_dark: boolean
|
||||
author?: string
|
||||
builtin: boolean
|
||||
code_theme?: 'github-light' | 'github-dark'
|
||||
}
|
||||
|
||||
// ============ Index ============
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
function updateContent(event: Event) {
|
||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
@@ -12,7 +14,7 @@ function updateContent(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="editorStore.currentFilePath ?? 'empty'"
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
|
||||
aria-label="Markdown 源码编辑器" @input="updateContent" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import {
|
||||
createCodeBlockCommand,
|
||||
toggleEmphasisCommand,
|
||||
@@ -19,6 +20,7 @@ import { callCommand } from '@milkdown/kit/utils'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
@@ -26,6 +28,7 @@ import '@milkdown/crepe/theme/frame.css'
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const editorRoot = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const fontSizeInput = ref(16)
|
||||
@@ -104,6 +107,7 @@ onMounted(async () => {
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
|
||||
previewOnlyByDefault: false,
|
||||
searchPlaceholder: '搜索语言',
|
||||
noResultText: '没有匹配的语言',
|
||||
@@ -269,6 +273,12 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
|
||||
.milkdown-host :deep(.milkdown-code-block .cm-editor),
|
||||
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
|
||||
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
|
||||
.milkdown-host :deep(.milkdown-code-block .cm-content) { caret-color: var(--color-code-text); font-family: var(--font-editor-mono); }
|
||||
.milkdown-host :deep(.milkdown-code-block .language-button) { color: var(--color-code-muted); }
|
||||
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
|
||||
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
|
||||
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
|
||||
|
||||
@@ -13,7 +13,7 @@ const themeStore = useThemeStore()
|
||||
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel preference-panel"><h2 class="panel-title">编辑器外观</h2><div class="form-grid"><div class="field"><label>字号:{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div><div class="field"><label>行高:{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div><div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div></div><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存,更在于被重新发现和使用。</p><code>const notes = await search('本地优先')</code></div></div>
|
||||
<div class="panel preference-panel"><h2 class="panel-title">编辑器外观</h2><div class="form-grid"><div class="field"><label>字号:{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div><div class="field"><label>行高:{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div><div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div><div class="field"><label>代码块样式</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">跟随主题</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>Markdown 渲染使用对应的 Shiki GitHub 主题</small></div></div><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存,更在于被重新发现和使用。</p><pre class="code-preview"><code><span class="code-keyword">const</span> <span class="code-variable">notes</span> = <span class="code-keyword">await</span> <span class="code-function">search</span>(<span class="code-string">'本地优先'</span>)</code></pre></div></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -28,5 +28,15 @@ const themeStore = useThemeStore()
|
||||
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
|
||||
.preference-panel { display: grid; gap: var(--space-xl); }
|
||||
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.editor-preview p { margin: var(--space-sm) 0; }.editor-preview code { color: var(--color-accent-primary); }
|
||||
.editor-preview p { margin: var(--space-sm) 0; }
|
||||
.field small { color: var(--color-text-tertiary); }
|
||||
.code-preview { overflow: auto; margin-top: var(--space-md); padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); font: 13px/1.45 var(--font-ui-mono); }
|
||||
:global([data-code-theme='github-light']) .code-keyword { color: #cf222e; }
|
||||
:global([data-code-theme='github-light']) .code-variable { color: #24292f; }
|
||||
:global([data-code-theme='github-light']) .code-function { color: #8250df; }
|
||||
:global([data-code-theme='github-light']) .code-string { color: #0a3069; }
|
||||
:global([data-code-theme='github-dark']) .code-keyword { color: #ff7b72; }
|
||||
:global([data-code-theme='github-dark']) .code-variable { color: #c9d1d9; }
|
||||
:global([data-code-theme='github-dark']) .code-function { color: #d2a8ff; }
|
||||
:global([data-code-theme='github-dark']) .code-string { color: #a5d6ff; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useThemeStore } from './theme'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.removeAttribute('data-code-theme')
|
||||
setActivePinia(createPinia())
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
describe('代码块主题偏好', () => {
|
||||
it('跟随应用主题选择对应的 GitHub 代码主题', async () => {
|
||||
const store = useThemeStore()
|
||||
store.applyTheme('dark')
|
||||
await nextTick()
|
||||
|
||||
expect(store.resolvedCodeBlockTheme).toBe('github-dark')
|
||||
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
|
||||
})
|
||||
|
||||
it('允许代码块主题独立于应用主题', async () => {
|
||||
const store = useThemeStore()
|
||||
store.applyTheme('dark')
|
||||
store.codeBlockTheme = 'github-light'
|
||||
await nextTick()
|
||||
|
||||
expect(store.resolvedCodeBlockTheme).toBe('github-light')
|
||||
expect(document.documentElement.dataset.codeTheme).toBe('github-light')
|
||||
})
|
||||
|
||||
it('恢复持久化的代码块主题偏好', async () => {
|
||||
localStorage.setItem('editor-appearance', JSON.stringify({ codeBlockTheme: 'github-dark' }))
|
||||
const store = useThemeStore()
|
||||
store.initTheme()
|
||||
await nextTick()
|
||||
|
||||
expect(store.codeBlockTheme).toBe('github-dark')
|
||||
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
|
||||
})
|
||||
})
|
||||
@@ -3,17 +3,24 @@ import { ref, computed, watch } from 'vue'
|
||||
import type { ThemeConfig } from '@/contracts'
|
||||
|
||||
const builtinThemes: ThemeConfig[] = [
|
||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
|
||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
|
||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
|
||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
]
|
||||
|
||||
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
||||
|
||||
function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePreference {
|
||||
return value === 'auto' || value === 'github-light' || value === 'github-dark'
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const themes = ref<ThemeConfig[]>(builtinThemes)
|
||||
const currentThemeId = ref<string>('light')
|
||||
const fontEditorSize = ref(15)
|
||||
const fontEditorFamily = ref('system-ui')
|
||||
const lineHeight = ref(1.7)
|
||||
const codeBlockTheme = ref<CodeBlockThemePreference>('auto')
|
||||
let appearanceHydrated = false
|
||||
|
||||
const currentTheme = computed(() =>
|
||||
@@ -21,6 +28,10 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
)
|
||||
|
||||
const isDark = computed(() => currentTheme.value?.is_dark || false)
|
||||
const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
|
||||
if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
|
||||
return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
|
||||
})
|
||||
|
||||
function applyTheme(themeId: string) {
|
||||
const theme = themes.value.find((t) => t.theme_id === themeId)
|
||||
@@ -41,10 +52,11 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
const savedAppearance = localStorage.getItem('editor-appearance')
|
||||
if (savedAppearance) {
|
||||
try {
|
||||
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number }
|
||||
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number; codeBlockTheme?: unknown }
|
||||
if (value.size) fontEditorSize.value = value.size
|
||||
if (value.family) fontEditorFamily.value = value.family
|
||||
if (value.lineHeight) lineHeight.value = value.lineHeight
|
||||
if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
|
||||
} catch { localStorage.removeItem('editor-appearance') }
|
||||
}
|
||||
const saved = localStorage.getItem('theme')
|
||||
@@ -67,12 +79,20 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
fontEditorSize.value = 15
|
||||
fontEditorFamily.value = 'system-ui'
|
||||
lineHeight.value = 1.7
|
||||
codeBlockTheme.value = 'auto'
|
||||
}
|
||||
|
||||
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
|
||||
size: fontEditorSize.value, family: fontEditorFamily.value, lineHeight: lineHeight.value,
|
||||
size: fontEditorSize.value,
|
||||
family: fontEditorFamily.value,
|
||||
lineHeight: lineHeight.value,
|
||||
codeBlockTheme: codeBlockTheme.value,
|
||||
}))
|
||||
|
||||
watch(resolvedCodeBlockTheme, (theme) => {
|
||||
document.documentElement.setAttribute('data-code-theme', theme)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(fontEditorSize, (v) => {
|
||||
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
|
||||
if (appearanceHydrated) persistAppearance()
|
||||
@@ -88,6 +108,10 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
if (appearanceHydrated) persistAppearance()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(codeBlockTheme, () => {
|
||||
if (appearanceHydrated) persistAppearance()
|
||||
})
|
||||
|
||||
return {
|
||||
themes,
|
||||
currentThemeId,
|
||||
@@ -96,6 +120,8 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
fontEditorSize,
|
||||
fontEditorFamily,
|
||||
lineHeight,
|
||||
codeBlockTheme,
|
||||
resolvedCodeBlockTheme,
|
||||
applyTheme,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
--color-text-link: #7b512e;
|
||||
}
|
||||
|
||||
[data-code-theme='github-light'] {
|
||||
--color-code-background: #f6f8fa;
|
||||
--color-code-border: #d0d7de;
|
||||
--color-code-text: #24292f;
|
||||
--color-code-muted: #57606a;
|
||||
}
|
||||
|
||||
[data-code-theme='github-dark'] {
|
||||
--color-code-background: #161b22;
|
||||
--color-code-border: #30363d;
|
||||
--color-code-text: #c9d1d9;
|
||||
--color-code-muted: #8b949e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { highlightCode } from './markdown'
|
||||
|
||||
describe('Shiki GitHub 双主题', () => {
|
||||
it('一次渲染同时生成 GitHub Light 和 GitHub Dark 颜色变量', async () => {
|
||||
const html = await highlightCode('const answer = 42', 'typescript')
|
||||
|
||||
expect(html).toContain('github-light')
|
||||
expect(html).toContain('github-dark')
|
||||
expect(html).toContain('--shiki-light')
|
||||
expect(html).toContain('--shiki-dark')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user