fix(editor): 修复语言菜单裁剪并接入 GitHub Shiki 配色

This commit is contained in:
2026-09-05 16:52:18 +08:00
parent 32411ce6fe
commit ed37099ba1
8 changed files with 221 additions and 5 deletions
+4
View File
@@ -11,8 +11,12 @@
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "6.11.0",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "6.12.4",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.0", "@codemirror/theme-one-dark": "^6.1.0",
"@codemirror/view": "6.43.9",
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@milkdown/crepe": "7.22.1", "@milkdown/crepe": "7.22.1",
"@milkdown/kit": "7.22.1", "@milkdown/kit": "7.22.1",
+12
View File
@@ -8,12 +8,24 @@ importers:
.: .:
dependencies: dependencies:
'@codemirror/commands':
specifier: 6.11.0
version: 6.11.0
'@codemirror/lang-markdown': '@codemirror/lang-markdown':
specifier: ^6.5.0 specifier: ^6.5.0
version: 6.5.2 version: 6.5.2
'@codemirror/language':
specifier: 6.12.4
version: 6.12.4
'@codemirror/state':
specifier: 6.7.1
version: 6.7.1
'@codemirror/theme-one-dark': '@codemirror/theme-one-dark':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.3 version: 6.1.3
'@codemirror/view':
specifier: 6.43.9
version: 6.43.9
'@element-plus/icons-vue': '@element-plus/icons-vue':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2(vue@3.5.41(typescript@5.9.3)) version: 2.3.2(vue@3.5.41(typescript@5.9.3))
@@ -7,6 +7,9 @@ import { TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils' import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue' import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
type EditorComponent = { getEditor: () => Editor | undefined } type EditorComponent = { getEditor: () => Editor | undefined }
@@ -45,6 +48,20 @@ afterEach(() => {
}) })
describe('VisualMarkdownEditor formatting toolbars', () => { describe('VisualMarkdownEditor formatting toolbars', () => {
it.each(['github-light', 'github-dark'] as const)('keeps Shiki %s mappings after Crepe merges its defaults', async theme => {
useThemeStore().codeBlockTheme = theme
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const config = editor.action(ctx => ctx.get(codeBlockConfig.key))
const matching = config.languages.filter(item => item.alias.includes('python'))
expect(matching).toHaveLength(1)
const cm = new CodeMirror({ doc: 'print("Hello")', extensions: [...config.extensions, await matching[0]!.load()] })
try {
const string = [...cm.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(el => el.textContent?.includes('Hello'))
expect(string?.style.color.toUpperCase()).toBe(theme === 'github-dark' ? '#9ECBFF' : '#032F62')
} finally { cm.destroy() }
})
it('applies bold from the top toolbar to the selected text', async () => { it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body }) const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper) mounted.push(wrapper)
@@ -2,7 +2,12 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue' import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe' import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark' import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages } from './shikiCodeMirror'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { import {
createCodeBlockCommand, createCodeBlockCommand,
toggleEmphasisCommand, toggleEmphasisCommand,
@@ -34,6 +39,7 @@ const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true) const loading = ref(true)
const fontSizeInput = ref(16) const fontSizeInput = ref(16)
let crepe: Crepe | null = null let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
function applyProofingPreferences() { function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror') const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
@@ -118,7 +124,6 @@ onMounted(async () => {
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]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false, previewOnlyByDefault: false,
searchPlaceholder: t('搜索语言', 'Search languages'), searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'), noResultText: t('没有匹配的语言', 'No matching language'),
@@ -170,6 +175,13 @@ onMounted(async () => {
}, },
}, },
}) })
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin) crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
@@ -180,13 +192,14 @@ onMounted(async () => {
}) })
}) })
await crepe.create() await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
applyProofingPreferences() applyProofingPreferences()
loading.value = false loading.value = false
}) })
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences) watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { void crepe?.destroy() }) onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor }) defineExpose({ getEditor: () => crepe?.editor })
</script> </script>
@@ -288,7 +301,10 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; } .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(.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(.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) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.language-picker[popover]) { position: fixed !important; inset: auto; left: var(--picker-left) !important; top: var(--picker-top) !important; margin: 0; padding: 0; border: 0; overflow: visible; background: transparent; color: var(--color-text-primary); }
.milkdown-host :deep(.language-picker .language-list) { height: auto; max-height: var(--picker-list-height, 280px); }
.milkdown-host :deep(.language-picker .list-wrapper) { width: min(260px, calc(100vw - 24px)); border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-code-block .cm-editor), .milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters), .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-panel) { background: var(--color-code-background); }
@@ -0,0 +1,43 @@
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
function sync() {
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) {
search.autocomplete = 'off'
search.spellcheck = false
}
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16
const above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
})
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
}
const observer = new MutationObserver(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
root.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
return () => {
observer.disconnect()
root.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
}
}
@@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { Compartment } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
const view = new EditorView({ doc: 'print("Hello")', extensions: [support] })
editors.push(view)
const tokenize = await getCodeTokenizer(theme)
const expected = tokenize('print("Hello")', 'python')[0]!.find(token => token.content.includes('Hello'))!
const colored = [...view.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(node => node.textContent?.includes('Hello'))!
expect(colored).toBeDefined()
const sample = document.createElement('span'); sample.style.color = expected.color!
expect(colored.style.color).toBe(sample.style.color)
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'def hello():\n return 42' } })
expect(view.state.doc.toString()).toContain('return 42')
expect(view.dom.querySelectorAll('.shiki-token').length).toBeGreaterThan(2)
expect(view.dom.textContent).toContain('return 42')
})
it('reconfigures language and theme without modifying the document', async () => {
const config = new Compartment()
const view = new EditorView({ doc: 'const answer = 42', extensions: [config.of(await shikiLanguage('javascript', 'github-light'))] })
editors.push(view)
const before = view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color
view.dispatch({ effects: config.reconfigure(await shikiLanguage('javascript', 'github-dark')) })
expect(view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color).not.toBe(before)
expect(view.state.doc.toString()).toBe('const answer = 42')
view.dispatch({ effects: config.reconfigure(await shikiLanguage('text', 'github-dark')) })
expect(view.state.doc.toString()).toBe('const answer = 42')
})
it('offers fenced-code aliases and retains the LaTeX selector', () => {
const languages = shikiLanguages('github-light')
expect(languages.find(item => item.name === 'Python')?.alias).toContain('py')
expect(languages.find(item => item.name === 'LaTeX')?.alias).toContain('latex')
})
@@ -0,0 +1,67 @@
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { getCodeTokenizer } from '@/utils/markdown'
type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme)
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) { this.decorations = this.highlight(view) }
update(update: ViewUpdate) {
if (update.docChanged) this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
const from = offset
offset += token.content.length
if (from === offset) return []
const fontStyle = token.fontStyle ?? 0
return [Decoration.mark({
class: 'shiki-token',
attributes: { style: `color:${token.color};font-style:${fontStyle & 1 ? 'italic' : 'normal'};font-weight:${fontStyle & 2 ? 'bold' : 'normal'};text-decoration:${fontStyle & 4 ? 'underline' : 'none'}` },
}).range(from, offset)]
})
})
return Decoration.set(ranges)
}
}, { decorations: value => value.decorations })
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
return new LanguageSupport(parser, highlights)
}
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
return [
{ name: 'C', alias: ['c'] },
{ name: 'C++', alias: ['cpp', 'c++'] },
{ name: 'Python', alias: ['python', 'py'] },
{ name: 'JavaScript', alias: ['javascript', 'js'] },
{ name: 'TypeScript', alias: ['typescript', 'ts'] },
{ name: 'HTML', alias: ['html'] },
{ name: 'CSS', alias: ['css'] },
{ name: 'JSON', alias: ['json'] },
{ name: 'Shell', alias: ['shell', 'bash', 'sh'] },
{ name: 'SQL', alias: ['sql'] },
{ name: 'Markdown', alias: ['markdown', 'md'] },
{ name: 'Plain text', alias: ['text', 'plaintext'] },
{ name: 'LaTeX', alias: ['latex'] },
].map(({ name, alias }) => LanguageDescription.of({
name, alias, load: () => shikiLanguage(alias[0]!, theme),
}))
}
export function shikiEditorTheme(theme: CodeTheme) {
return EditorView.theme({
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
'.cm-gutters': { color: 'var(--color-code-muted)', backgroundColor: 'var(--color-code-background)' },
}, { dark: theme === 'github-dark' })
}
+15 -1
View File
@@ -3,6 +3,8 @@ import { marked } from 'marked'
import { createHighlighterCore } from 'shiki/core' import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import css from '@shikijs/langs/css' import css from '@shikijs/langs/css'
import c from '@shikijs/langs/c'
import cpp from '@shikijs/langs/cpp'
import html from '@shikijs/langs/html' import html from '@shikijs/langs/html'
import javascript from '@shikijs/langs/javascript' import javascript from '@shikijs/langs/javascript'
import json from '@shikijs/langs/json' import json from '@shikijs/langs/json'
@@ -19,7 +21,7 @@ marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。 // Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({ const highlighter = createHighlighterCore({
themes: [githubLight, githubDark], themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql], langs: [markdown, html, css, javascript, typescript, json, python, shell, sql, c, cpp],
engine: createJavaScriptRegexEngine(), engine: createJavaScriptRegexEngine(),
}) })
@@ -38,6 +40,18 @@ export async function highlightCode(source: string, requestedLanguage = 'text'):
}) })
} }
/** Share the initialized grammar/theme registry with editable code blocks. */
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark') {
const shiki = await highlighter
return (source: string, requestedLanguage: string) => {
const language = languageAliases[requestedLanguage] ?? requestedLanguage
return shiki.codeToTokens(source, {
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
theme,
}).tokens
}
}
export async function renderMarkdown(source: string): Promise<string> { export async function renderMarkdown(source: string): Promise<string> {
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')