diff --git a/frontend/package.json b/frontend/package.json index 92c3212..39d7157 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,12 @@ "type-check": "vue-tsc --noEmit" }, "dependencies": { + "@codemirror/commands": "6.11.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/view": "6.43.9", "@element-plus/icons-vue": "^2.3.2", "@milkdown/crepe": "7.22.1", "@milkdown/kit": "7.22.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6ad34de..6d22fa8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,12 +8,24 @@ importers: .: dependencies: + '@codemirror/commands': + specifier: 6.11.0 + version: 6.11.0 '@codemirror/lang-markdown': specifier: ^6.5.0 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': specifier: ^6.1.0 version: 6.1.3 + '@codemirror/view': + specifier: 6.43.9 + version: 6.43.9 '@element-plus/icons-vue': specifier: ^2.3.2 version: 2.3.2(vue@3.5.41(typescript@5.9.3)) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts index 2520ebe..a89bdbc 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts +++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts @@ -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('.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) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index 88035a4..7acad31 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -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(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('.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 }) @@ -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); } diff --git a/frontend/src/features/editor/languagePickerPopover.ts b/frontend/src/features/editor/languagePickerPopover.ts new file mode 100644 index 0000000..c8134a8 --- /dev/null +++ b/frontend/src/features/editor/languagePickerPopover.ts @@ -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() + function sync() { + root.querySelectorAll('.language-picker').forEach(menu => { + const trigger = menu.parentElement?.querySelector('.language-button') + if (!trigger || typeof menu.showPopover !== 'function') return + menus.add(menu) + menu.setAttribute('popover', 'manual') + const search = menu.querySelector('.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() + } +} diff --git a/frontend/src/features/editor/shikiCodeMirror.spec.ts b/frontend/src/features/editor/shikiCodeMirror.spec.ts new file mode 100644 index 0000000..2b0d114 --- /dev/null +++ b/frontend/src/features/editor/shikiCodeMirror.spec.ts @@ -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('.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('.shiki-token')!.style.color + view.dispatch({ effects: config.reconfigure(await shikiLanguage('javascript', 'github-dark')) }) + expect(view.dom.querySelector('.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') +}) diff --git a/frontend/src/features/editor/shikiCodeMirror.ts b/frontend/src/features/editor/shikiCodeMirror.ts new file mode 100644 index 0000000..02e1081 --- /dev/null +++ b/frontend/src/features/editor/shikiCodeMirror.ts @@ -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 { + 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' }) +} diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 3751939..008c66f 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -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 { const html = marked.parse(source, { async: false }) as string const documentNode = new DOMParser().parseFromString(`${html}`, 'text/html')