feat: add markdown syntax highlighting in raw editor
Wire up @codemirror/lang-markdown with a custom HighlightStyle to highlight headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code in the raw CodeMirror editor. The custom frontmatter plugin is kept for YAML highlighting; its heading decoration is removed in favour of the language-based parser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@mantine/core": "^8.3.14",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@mantine/core':
|
||||
specifier: ^8.3.14
|
||||
version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
@@ -39,22 +39,6 @@ describe('frontmatterHighlightPlugin', () => {
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('applies heading class to markdown headings', () => {
|
||||
const { view, parent } = createView('# Heading One\n\nSome text\n\n## Heading Two')
|
||||
const headings = parent.querySelectorAll('.cm-md-heading')
|
||||
expect(headings.length).toBeGreaterThanOrEqual(2)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('does not apply heading class to plain text', () => {
|
||||
const { view, parent } = createView('Just some plain text\nAnother line')
|
||||
const headings = parent.querySelectorAll('.cm-md-heading')
|
||||
expect(headings.length).toBe(0)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('handles content without frontmatter', () => {
|
||||
const { view, parent } = createView('# Just a heading\n\nNo frontmatter here.')
|
||||
const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RangeSetBuilder } from '@codemirror/state'
|
||||
const frontmatterDelimiter = Decoration.mark({ class: 'cm-frontmatter-delimiter' })
|
||||
const frontmatterKey = Decoration.mark({ class: 'cm-frontmatter-key' })
|
||||
const frontmatterValue = Decoration.mark({ class: 'cm-frontmatter-value' })
|
||||
const markdownHeading = Decoration.mark({ class: 'cm-md-heading' })
|
||||
|
||||
function findFrontmatterEnd(doc: { lines: number; line(n: number): { text: string } }): number {
|
||||
if (doc.lines < 1) return -1
|
||||
@@ -27,8 +26,6 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
|
||||
if (i <= fmEnd) {
|
||||
decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd)
|
||||
} else {
|
||||
decorateMarkdownLine(builder, line.from, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +57,6 @@ function decorateFrontmatterLine(
|
||||
}
|
||||
}
|
||||
|
||||
function decorateMarkdownLine(
|
||||
builder: RangeSetBuilder<Decoration>,
|
||||
from: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (/^#{1,6}\s/.test(text)) {
|
||||
builder.add(from, from + text.length, markdownHeading)
|
||||
}
|
||||
}
|
||||
|
||||
export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
@@ -89,12 +76,9 @@ export function frontmatterHighlightTheme() {
|
||||
const keyColor = '#c9383e'
|
||||
const valueColor = '#2a7e4f'
|
||||
const delimiterColor = '#c9383e'
|
||||
const headingColor = '#0969da'
|
||||
|
||||
return EditorView.baseTheme({
|
||||
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
|
||||
'.cm-frontmatter-key': { color: keyColor },
|
||||
'.cm-frontmatter-value': { color: valueColor },
|
||||
'.cm-md-heading': { color: headingColor, fontWeight: '600' },
|
||||
})
|
||||
}
|
||||
|
||||
51
src/extensions/markdownHighlight.test.ts
Normal file
51
src/extensions/markdownHighlight.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { markdownLanguage } from './markdownHighlight'
|
||||
|
||||
function createView(doc: string) {
|
||||
const parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [markdownLanguage()],
|
||||
})
|
||||
const view = new EditorView({ state, parent })
|
||||
return { view, parent }
|
||||
}
|
||||
|
||||
describe('markdownLanguage', () => {
|
||||
it('returns a valid extension', () => {
|
||||
const ext = markdownLanguage()
|
||||
expect(ext).toBeDefined()
|
||||
expect(Array.isArray(ext)).toBe(true)
|
||||
})
|
||||
|
||||
it('creates an editor without errors', () => {
|
||||
const { view, parent } = createView('# Heading\n\n**bold** and *italic*\n\n- list item')
|
||||
expect(view.state.doc.toString()).toContain('# Heading')
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('parses markdown content with mixed syntax', () => {
|
||||
const doc = [
|
||||
'# Title',
|
||||
'',
|
||||
'Some **bold** and *italic* text.',
|
||||
'',
|
||||
'- item one',
|
||||
'- item two',
|
||||
'',
|
||||
'[a link](http://example.com)',
|
||||
'',
|
||||
'> a blockquote',
|
||||
'',
|
||||
'`inline code`',
|
||||
].join('\n')
|
||||
const { view, parent } = createView(doc)
|
||||
expect(view.state.doc.lines).toBe(12)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
})
|
||||
28
src/extensions/markdownHighlight.ts
Normal file
28
src/extensions/markdownHighlight.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
const markdownHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
|
||||
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
|
||||
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
|
||||
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.strong, fontWeight: '700' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
|
||||
{ tag: tags.url, color: '#0969da' },
|
||||
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
|
||||
{ tag: tags.list, color: '#c9383e' },
|
||||
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
|
||||
{ tag: tags.separator, color: '#636c76' },
|
||||
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
|
||||
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
|
||||
])
|
||||
|
||||
export function markdownLanguage(): Extension {
|
||||
return [markdown(), syntaxHighlighting(markdownHighlightStyle)]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
|
||||
import { markdownLanguage } from '../extensions/markdownHighlight'
|
||||
import { zoomCursorFix } from '../extensions/zoomCursorFix'
|
||||
|
||||
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -109,6 +110,7 @@ export function useCodeMirror(
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
buildSaveKeymap(callbacksRef),
|
||||
buildBaseTheme(),
|
||||
markdownLanguage(),
|
||||
frontmatterHighlightTheme(),
|
||||
frontmatterHighlightPlugin,
|
||||
zoomCursorFix(),
|
||||
|
||||
Reference in New Issue
Block a user