fix(editor): 修复语言菜单裁剪并接入 GitHub Shiki 配色
This commit is contained in:
@@ -7,6 +7,9 @@ import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
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 }
|
||||
|
||||
@@ -45,6 +48,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
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 {
|
||||
createCodeBlockCommand,
|
||||
toggleEmphasisCommand,
|
||||
@@ -34,6 +39,7 @@ const editorRoot = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const fontSizeInput = ref(16)
|
||||
let crepe: Crepe | null = null
|
||||
let disposeLanguagePicker: (() => void) | undefined
|
||||
|
||||
function applyProofingPreferences() {
|
||||
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
|
||||
@@ -118,7 +124,6 @@ onMounted(async () => {
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
|
||||
previewOnlyByDefault: false,
|
||||
searchPlaceholder: t('搜索语言', 'Search languages'),
|
||||
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.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
@@ -180,13 +192,14 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
await crepe.create()
|
||||
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
|
||||
applyProofingPreferences()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
|
||||
|
||||
onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
@@ -288,7 +301,10 @@ 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) { 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-gutters),
|
||||
.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' })
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { marked } from 'marked'
|
||||
import { createHighlighterCore } from 'shiki/core'
|
||||
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
||||
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 javascript from '@shikijs/langs/javascript'
|
||||
import json from '@shikijs/langs/json'
|
||||
@@ -19,7 +21,7 @@ marked.setOptions({ gfm: true, breaks: true })
|
||||
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||
const highlighter = createHighlighterCore({
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
|
||||
Reference in New Issue
Block a user