diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue index 3e3f173..6580f2e 100644 --- a/frontend/src/components/common/MarkdownContent.vue +++ b/frontend/src/components/common/MarkdownContent.vue @@ -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; diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 2a3d973..237fa4f 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -329,6 +329,7 @@ export interface ThemeConfig { is_dark: boolean author?: string builtin: boolean + code_theme?: 'github-light' | 'github-dark' } // ============ Index ============ diff --git a/frontend/src/features/editor/EditorPane.vue b/frontend/src/features/editor/EditorPane.vue index 2e64f78..2767e6a 100644 --- a/frontend/src/features/editor/EditorPane.vue +++ b/frontend/src/features/editor/EditorPane.vue @@ -1,10 +1,12 @@ - diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index a339f4a..6a37edc 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -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(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; } diff --git a/frontend/src/features/themes/ThemesView.vue b/frontend/src/features/themes/ThemesView.vue index 35ec448..1a93a5d 100644 --- a/frontend/src/features/themes/ThemesView.vue +++ b/frontend/src/features/themes/ThemesView.vue @@ -13,7 +13,7 @@ const themeStore = useThemeStore() v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }} - 编辑器外观字号:{{ themeStore.fontEditorSize }}px行高:{{ themeStore.lineHeight }}字体系统字体衬线字体等宽字体主题预览知识的价值不只在于保存,更在于被重新发现和使用。const notes = await search('本地优先') + 编辑器外观字号:{{ themeStore.fontEditorSize }}px行高:{{ themeStore.lineHeight }}字体系统字体衬线字体等宽字体代码块样式跟随主题GitHub LightGitHub DarkMarkdown 渲染使用对应的 Shiki GitHub 主题主题预览知识的价值不只在于保存,更在于被重新发现和使用。const notes = await search('本地优先') @@ -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; } diff --git a/frontend/src/stores/theme.spec.ts b/frontend/src/stores/theme.spec.ts new file mode 100644 index 0000000..9d1d0cc --- /dev/null +++ b/frontend/src/stores/theme.spec.ts @@ -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') + }) +}) diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index 5b591f6..ffd0f23 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -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(builtinThemes) const currentThemeId = ref('light') const fontEditorSize = ref(15) const fontEditorFamily = ref('system-ui') const lineHeight = ref(1.7) + const codeBlockTheme = ref('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, diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index a13cbfc..b4109a2 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -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; diff --git a/frontend/src/utils/markdown.spec.ts b/frontend/src/utils/markdown.spec.ts new file mode 100644 index 0000000..11b2b33 --- /dev/null +++ b/frontend/src/utils/markdown.spec.ts @@ -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') + }) +})
v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}
知识的价值不只在于保存,更在于被重新发现和使用。
const notes = await search('本地优先')