feat(web): add M0 live preview editor prototype

- CodeMirror 6 editor with Obsidian/Typora-style live preview: block
  markup revealed per line, inline markup revealed per element
- Fenced code highlighting via Shiki (github-dark, JS regex engine) in a
  Web Worker, languages loaded on demand
- Lezer syntax tree to HTML renderer for share pages, checked against
  the CommonMark 0.31.2 spec (640/652; 12 known parser deviations)
- Use markdownLanguage directly to avoid bundling lang-html/css/js
This commit is contained in:
eezd committed 2026-10-16 17:23:58 +08:00
1 parent 4636649576
commit a1597697e5
19 files changed
+3870

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# RawMark 原型
这是 **M0 原型**,用来验证 *Live Preview* 的手感。光标所在的行显示 Markdown 源码,其余行显示渲染效果。
## 行内语法
支持 **粗体**、*斜体*、~~删除线~~、`行内代码`、[链接](https://vuejs.org/guide/introduction.html) 和自动链接 <https://example.com>。
按住 Ctrl/Cmd 点击链接可以打开。转义字符 \*不会变成斜体\*。
## 列表
- 无序列表第一项
- 第二项,包含 `code`
- 嵌套项
1. 有序列表
2. 第二项
- [ ] 待办事项(点击复选框切换)
- [x] 已完成事项
> 引用块:纯粹的 Markdown,不做格式转换。
> 第二行引用
---
## 代码块
```ts
import { ref, computed } from 'vue'
// 计数器
const count = ref(0)
const double = computed(() => count.value * 2)
export function increment(step: number = 1): void {
count.value += step
}
```
```python
def fib(n: int) -> int:
"""返回第 n 个斐波那契数"""
return n if n < 2 else fib(n - 1) + fib(n - 2)
```
```go
func main() {
fmt.Println("hello, rawmark")
}
```
## 图片
![示例图片](https://vuejs.org/images/logo.png)
## 表格(M2 实现可视化编辑)
| 功能 | 状态 |
| --- | --- |
| Live Preview | 原型 |
| 表格 | M2 |
+142
View File
@@ -0,0 +1,142 @@
import { syntaxTree } from '@codemirror/language'
import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { cachedTokens, requestTokens, type TokenRun } from '../highlight/client'
interface CodeBlock {
// 代码内容(不含围栏行)在文档中的范围
from: number
to: number
lang: string
code: string
}
const setBlockTokens = StateEffect.define<{ from: number; to: number; decorations: Range<Decoration>[] }>()
// 高亮结果作为 StateField 保存并随编辑映射位置:Worker 返回新结果前沿用旧颜色,避免闪烁
const codeTokenField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
value = value.map(tr.changes)
for (const e of tr.effects) {
if (!e.is(setBlockTokens)) continue
value = value.update({
filterFrom: e.value.from,
filterTo: e.value.to,
filter: () => false,
add: e.value.decorations,
sort: true,
})
}
return value
},
provide: (f) => EditorView.decorations.from(f),
})
const markCache = new Map<string, Decoration>()
function tokenMark(color: string, fontStyle: number): Decoration {
const key = color + '|' + fontStyle
let deco = markCache.get(key)
if (!deco) {
let style = color ? `color:${color}` : ''
if (fontStyle & 1) style += ';font-style:italic'
if (fontStyle & 2) style += ';font-weight:bold'
if (fontStyle & 4) style += ';text-decoration:underline'
deco = Decoration.mark({ attributes: { style } })
markCache.set(key, deco)
}
return deco
}
function tokensToDecorations(block: CodeBlock, run: TokenRun | null): Range<Decoration>[] {
const out: Range<Decoration>[] = []
if (!run) return out
for (let i = 0; i < run.length; i += 4) {
const from = block.from + (run[i] as number)
out.push(tokenMark(run[i + 2] as string, run[i + 3] as number).range(from, from + (run[i + 1] as number)))
}
return out
}
export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: number): string {
return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase()
}
function visibleCodeBlocks(view: EditorView): CodeBlock[] {
const { state } = view
const blocks: CodeBlock[] = []
for (const { from, to } of view.visibleRanges) {
syntaxTree(state).iterate({
from,
to,
enter(node) {
if (node.name !== 'FencedCode') return
const info = node.node.getChild('CodeInfo')
if (info) {
const open = state.doc.lineAt(node.from)
const close = state.doc.lineAt(node.to)
const marks = node.node.getChildren('CodeMark')
// 有闭合围栏时内容到闭合行之前为止,否则到块尾
const end = marks.length > 1 ? close.from - 1 : node.to
const start = Math.min(open.to + 1, end)
if (end > start) {
blocks.push({ from: start, to: end, lang: fenceLanguage(state, info.from, info.to), code: state.sliceDoc(start, end) })
}
}
return false
},
})
}
return blocks
}
const codeHighlighter = ViewPlugin.fromClass(
class {
// 已应用到各代码块的结果键,按块起点记录
applied = new Map<number, string>()
destroyed = false
constructor(readonly view: EditorView) {
this.schedule()
}
update(u: ViewUpdate) {
if (u.docChanged) {
const mapped = new Map<number, string>()
for (const [pos, key] of this.applied) mapped.set(u.changes.mapPos(pos), key)
this.applied = mapped
}
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) this.schedule()
}
// 不能在 update 中同步 dispatch,推迟到微任务
schedule() {
queueMicrotask(() => this.sync())
}
destroy() {
this.destroyed = true
}
sync() {
if (this.destroyed) return
const effects: StateEffect<unknown>[] = []
for (const block of visibleCodeBlocks(this.view)) {
const key = block.lang + '\0' + block.code
if (this.applied.get(block.from) === key) continue
const cached = cachedTokens(block.lang, block.code)
if (cached !== undefined) {
this.applied.set(block.from, key)
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
} else {
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
requestTokens(block.lang, block.code).then(() => this.schedule())
}
}
if (effects.length) this.view.dispatch({ effects })
}
},
)
export const codeBlockHighlight = [codeTokenField, codeHighlighter]
+39
View File
@@ -0,0 +1,39 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { EditorState, Prec, type Extension } from '@codemirror/state'
import { drawSelection, EditorView, keymap } from '@codemirror/view'
import { codeBlockHighlight } from './codeblocks'
import { livePreview, resolveImageUrl } from './livepreview'
import { markdownTheme } from './theme'
export interface EditorOptions {
parent: HTMLElement
doc: string
resolveImage?: (src: string) => string
onChange?: (state: EditorState) => void
extensions?: Extension[]
}
export function createEditor(opts: EditorOptions): EditorView {
const extensions: Extension[] = [
history(),
drawSelection(),
EditorView.lineWrapping,
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
markdownLanguage,
Prec.high(keymap.of(markdownKeymap)),
markdownTheme,
livePreview,
codeBlockHighlight,
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }),
]
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
if (opts.onChange) {
const onChange = opts.onChange
extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u.state)))
}
if (opts.extensions) extensions.push(...opts.extensions)
return new EditorView({ parent: opts.parent, state: EditorState.create({ doc: opts.doc, extensions }) })
}
+263
View File
@@ -0,0 +1,263 @@
import { syntaxTree } from '@codemirror/language'
import { Facet, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { fenceLanguage } from './codeblocks'
import { BulletWidget, CheckboxWidget, CodeLangWidget, HorizontalRuleWidget, ImageWidget } from './widgets'
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
combine: (values) => values[0] ?? ((src) => src),
})
const hidden = Decoration.replace({})
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
const linkText = Decoration.mark({ class: 'cm-rm-link' })
const lineDecos = new Map<string, Decoration>()
function lineDeco(cls: string): Decoration {
let deco = lineDecos.get(cls)
if (!deco) lineDecos.set(cls, (deco = Decoration.line({ class: cls })))
return deco
}
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
function activeLines(view: EditorView): Set<number> {
const lines = new Set<number>()
if (!view.hasFocus) return lines
const { doc } = view.state
for (const r of view.state.selection.ranges) {
for (let n = doc.lineAt(r.from).number, end = doc.lineAt(r.to).number; n <= end; n++) lines.add(n)
}
return lines
}
function buildDecorations(view: EditorView): DecorationSet {
const { state } = view
const { doc } = state
const active = activeLines(view)
const resolveUrl = state.facet(resolveImageUrl)
const out: Range<Decoration>[] = []
const lineOf = (pos: number) => doc.lineAt(pos).number
const isActive = (from: number, to = from) => {
for (let n = lineOf(from), end = lineOf(to); n <= end; n++) if (active.has(n)) return true
return false
}
const ranges = view.hasFocus ? state.selection.ranges : []
const touches = (node: SyntaxNode) => ranges.some((r) => r.from <= node.to && r.to >= node.from)
// 视图插件提供的替换装饰不能跨越换行
const hide = (from: number, to: number) => {
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
}
const classLines = (from: number, to: number, cls: (line: number, first: number, last: number) => string) => {
const first = lineOf(from)
const last = lineOf(to)
for (const vr of view.visibleRanges) {
for (let n = Math.max(first, lineOf(vr.from)), end = Math.min(last, lineOf(vr.to)); n <= end; n++) {
out.push(lineDeco(cls(n, first, last)).range(doc.line(n).from))
}
}
}
const spacesAfter = (pos: number) => {
let end = pos
while (end < doc.length && state.sliceDoc(end, end + 1) === ' ') end++
return end
}
for (const { from, to } of view.visibleRanges) {
syntaxTree(state).iterate({
from,
to,
enter(ref) {
const node = ref.node
const name = node.name
if (name.startsWith('ATXHeading') || name.startsWith('SetextHeading')) {
out.push(lineDeco(`cm-rm-h${name.slice(-1)}`).range(doc.lineAt(node.from).from))
return
}
switch (name) {
case 'HeaderMark': {
if (isActive(node.from)) return
const parent = node.parent!
if (parent.name.startsWith('SetextHeading')) hide(node.from, node.to)
else if (node.from === parent.from) hide(node.from, spacesAfter(node.to))
else hide(trailingSpaceStart(state, node.from), node.to)
return
}
case 'EmphasisMark':
case 'StrikethroughMark':
case 'SuperscriptMark':
case 'SubscriptMark':
if (!touches(node.parent!)) hide(node.from, node.to)
return
case 'InlineCode':
out.push(inlineCode.range(node.from, node.to))
return
case 'CodeMark':
if (node.parent?.name === 'InlineCode' && !touches(node.parent)) hide(node.from, node.to)
return
case 'Link':
decorateLink(node)
return
case 'Autolink':
if (!touches(node)) {
hide(node.from, node.from + 1)
hide(node.to - 1, node.to)
}
return
case 'Image':
decorateImage(node)
return false
case 'Escape':
if (!touches(node)) hide(node.from, node.from + 1)
return
case 'HorizontalRule':
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new HorizontalRuleWidget() }).range(node.from, node.to))
return
case 'Blockquote':
classLines(node.from, node.to, () => 'cm-rm-quote')
return
case 'QuoteMark':
if (!isActive(node.from)) hide(node.from, Math.min(node.to + 1, spacesAfter(node.to)))
return
case 'ListMark':
decorateListMark(node)
return
case 'TaskMarker':
if (!isActive(node.from)) {
const checked = state.sliceDoc(node.from + 1, node.from + 2) !== ' '
out.push(Decoration.replace({ widget: new CheckboxWidget(checked) }).range(node.from, node.to))
}
return
case 'FencedCode':
decorateFencedCode(node)
return false
case 'CodeBlock':
classLines(node.from, node.to, () => 'cm-rm-code')
return false
case 'Table':
// 表格可视化编辑在 M2 实现,目前以等宽源码显示,内部语法不做隐藏以免列对不齐
classLines(node.from, node.to, () => 'cm-rm-table')
return false
case 'HTMLBlock':
case 'CommentBlock':
return false
}
},
})
}
function decorateLink(node: SyntaxNode) {
const marks = node.getChildren('LinkMark')
if (marks.length < 2) return
out.push(linkText.range(marks[0].to, marks[1].from))
if (touches(node)) return
hide(marks[0].from, marks[0].to)
hide(marks[1].from, node.to)
}
function decorateImage(node: SyntaxNode) {
const url = node.getChild('URL')
const marks = node.getChildren('LinkMark')
if (!url || marks.length < 2) return
const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, ''))
const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from))
if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to))
else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to))
}
function decorateListMark(node: SyntaxNode) {
if (isActive(node.from)) return
const item = node.parent!
if (item.getChild('Task')) hide(node.from, spacesAfter(node.to))
else if (item.parent?.name === 'BulletList') out.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to))
}
function decorateFencedCode(node: SyntaxNode) {
const marks = node.getChildren('CodeMark')
const closed = marks.length > 1
classLines(node.from, node.to, (n, first, last) =>
n === first ? 'cm-rm-code cm-rm-code-begin' : n === last && closed ? 'cm-rm-code cm-rm-code-end' : 'cm-rm-code',
)
if (isActive(node.from, node.to)) return
const info = node.getChild('CodeInfo')
const openLine = doc.lineAt(node.from)
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
out.push(lineDeco('cm-rm-code-fence').range(openLine.from))
out.push(Decoration.replace({ widget: new CodeLangWidget(lang) }).range(marks[0].from, openLine.to))
if (closed) {
const closeMark = marks[marks.length - 1]
out.push(lineDeco('cm-rm-code-fence').range(doc.lineAt(closeMark.from).from))
hide(closeMark.from, closeMark.to)
}
}
return Decoration.set(out, true)
}
function trailingSpaceStart(state: EditorState, pos: number): number {
let start = pos
while (start > 0 && state.sliceDoc(start - 1, start) === ' ') start--
return start
}
// Ctrl/Cmd + 点击打开链接
function linkAt(state: EditorState, pos: number): string | null {
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
if (node.name === 'Link' || node.name === 'Autolink') {
const url = node.getChild('URL')
return url ? state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '') : null
}
}
return null
}
const livePreviewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
stale = false
constructor(view: EditorView) {
this.decorations = buildDecorations(view)
}
update(u: ViewUpdate) {
if (u.view.composing) {
this.decorations = this.decorations.map(u.changes)
this.stale = true
return
}
if (
this.stale ||
u.docChanged ||
u.viewportChanged ||
u.selectionSet ||
u.focusChanged ||
syntaxTree(u.startState) !== syntaxTree(u.state)
) {
this.stale = false
this.decorations = buildDecorations(u.view)
}
}
},
{
decorations: (v) => v.decorations,
eventHandlers: {
mousedown(e, view) {
if (!(e.ctrlKey || e.metaKey)) return false
const pos = view.posAtCoords(e)
const href = pos === null ? null : linkAt(view.state, pos)
if (!href) return false
e.preventDefault()
window.open(href, '_blank', 'noopener')
return true
},
},
},
)
export const livePreview = [livePreviewPlugin]
+16
View File
@@ -0,0 +1,16 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
// 编辑器排版样式在 themes/base.css 中以 CSS 变量定义,这里只做语法元素到类名的映射
const markdownHighlight = HighlightStyle.define([
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: '600' },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], fontWeight: '600' },
{ tag: t.processingInstruction, class: 'cm-rm-markup' },
{ tag: [t.url, t.link], class: 'cm-rm-url' },
{ tag: t.quote, class: 'cm-rm-quote-text' },
{ tag: t.monospace, class: 'cm-rm-mono' },
])
export const markdownTheme = syntaxHighlighting(markdownHighlight)
+98
View File
@@ -0,0 +1,98 @@
import { WidgetType, type EditorView } from '@codemirror/view'
export class BulletWidget extends WidgetType {
eq() {
return true
}
toDOM() {
const el = document.createElement('span')
el.className = 'cm-rm-bullet'
el.textContent = '•'
return el
}
}
export class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) {
super()
}
eq(other: CheckboxWidget) {
return other.checked === this.checked
}
toDOM(view: EditorView) {
const el = document.createElement('input')
el.type = 'checkbox'
el.className = 'cm-rm-task'
el.checked = this.checked
// 用 mousedown 并阻止默认行为:否则编辑器先移动光标,行被展开为源码,点击落空
el.addEventListener('mousedown', (e) => {
e.preventDefault()
const pos = view.posAtDOM(el)
const marker = view.state.sliceDoc(pos, pos + 3)
if (!/^\[[ xX]\]$/.test(marker)) return
view.dispatch({ changes: { from: pos + 1, to: pos + 2, insert: this.checked ? ' ' : 'x' } })
})
return el
}
ignoreEvent() {
return true
}
}
export class HorizontalRuleWidget extends WidgetType {
eq() {
return true
}
toDOM() {
const el = document.createElement('span')
el.className = 'cm-rm-hr'
return el
}
}
export class CodeLangWidget extends WidgetType {
constructor(readonly lang: string) {
super()
}
eq(other: CodeLangWidget) {
return other.lang === this.lang
}
toDOM() {
const el = document.createElement('span')
el.className = 'cm-rm-code-lang'
el.textContent = this.lang
return el
}
}
export class ImageWidget extends WidgetType {
constructor(
readonly src: string,
readonly alt: string,
) {
super()
}
eq(other: ImageWidget) {
return other.src === this.src && other.alt === this.alt
}
toDOM(view: EditorView) {
const wrap = document.createElement('span')
wrap.className = 'cm-rm-image'
const img = document.createElement('img')
img.src = this.src
img.alt = this.alt
img.loading = 'lazy'
img.decoding = 'async'
// 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位
img.addEventListener('load', () => view.requestMeasure())
img.addEventListener('error', () => {
wrap.classList.add('cm-rm-image-error')
wrap.textContent = this.alt || this.src
})
wrap.append(img)
return wrap
}
get estimatedHeight() {
return 200
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
import HighlightWorker from './worker?worker'
export type { TokenRun }
const CACHE_LIMIT = 500
let worker: Worker | undefined
let nextId = 1
const pending = new Map<number, (tokens: TokenRun | null) => void>()
// 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目
const cache = new Map<string, TokenRun | null>()
function getWorker(): Worker {
if (!worker) {
worker = new HighlightWorker()
worker.addEventListener('message', (e: MessageEvent<HighlightResponse>) => {
const resolve = pending.get(e.data.id)
pending.delete(e.data.id)
resolve?.(e.data.tokens)
})
}
return worker
}
export function cachedTokens(lang: string, code: string): TokenRun | null | undefined {
return cache.get(lang + '\0' + code)
}
export function requestTokens(lang: string, code: string): Promise<TokenRun | null> {
const key = lang + '\0' + code
if (cache.has(key)) return Promise.resolve(cache.get(key)!)
const id = nextId++
const req: HighlightRequest = { id, lang, code }
return new Promise((resolve) => {
pending.set(id, (tokens) => {
cache.set(key, tokens)
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
resolve(tokens)
})
getWorker().postMessage(req)
})
}
+16
View File
@@ -0,0 +1,16 @@
// 主线程与高亮 Worker 之间的消息格式
export interface HighlightRequest {
id: number
lang: string
code: string
}
// 扁平数组:[offset, length, color, fontStyle, offset, length, ...],避免大量小对象跨线程拷贝
export type TokenRun = (number | string)[]
export interface HighlightResponse {
id: number
// 语言不受支持时为 null,调用方按纯文本显示
tokens: TokenRun | null
}
+46
View File
@@ -0,0 +1,46 @@
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import { bundledLanguages } from 'shiki/langs'
import githubDark from 'shiki/themes/github-dark.mjs'
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
const THEME = 'github-dark'
const highlighterReady: Promise<HighlighterCore> = createHighlighterCore({
themes: [githubDark],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
// 同一语言的并发请求共用一次加载
const langLoads = new Map<string, Promise<boolean>>()
function ensureLanguage(hl: HighlighterCore, lang: string): Promise<boolean> {
let load = langLoads.get(lang)
if (!load) {
const importer = bundledLanguages[lang as keyof typeof bundledLanguages]
load = importer
? importer().then((mod) => hl.loadLanguage(mod.default)).then(() => true)
: Promise.resolve(false)
langLoads.set(lang, load)
}
return load
}
async function highlight(req: HighlightRequest): Promise<TokenRun | null> {
const hl = await highlighterReady
if (!(await ensureLanguage(hl, req.lang))) return null
const run: TokenRun = []
for (const line of hl.codeToTokensBase(req.code, { lang: req.lang, theme: THEME })) {
for (const t of line) {
if (!t.content.trim()) continue
run.push(t.offset, t.content.length, t.color ?? '', t.fontStyle ?? 0)
}
}
return run
}
self.addEventListener('message', async (e: MessageEvent<HighlightRequest>) => {
const res: HighlightResponse = { id: e.data.id, tokens: await highlight(e.data) }
;(self as unknown as Worker).postMessage(res)
})
+11
View File
@@ -0,0 +1,11 @@
// M0 原型入口:只挂载编辑器,M1 替换为 Vue 外壳
import { createEditor } from './editor'
import sample from './demo/sample.md?raw'
import './themes/base.css'
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
const doc = Array.from({ length: Math.max(1, repeat) }, () => sample).join('\n')
const view = createEditor({ parent: document.getElementById('app')!, doc })
;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view
+53
View File
@@ -0,0 +1,53 @@
import { tests as specTests } from 'commonmark-spec'
import { Window } from 'happy-dom'
import { describe, expect, it } from 'vitest'
import { commonmarkParser, renderMarkdown } from './html'
const window = new Window()
const document = window.document
// 规范化 HTML:解析为 DOM 后重新序列化,消除实体写法、标签间空白和 URL 编码差异
function normalize(html: string): string {
const root = document.createElement('div')
root.innerHTML = html
for (const el of root.querySelectorAll('[href],[src]')) {
for (const attr of ['href', 'src']) {
const v = el.getAttribute(attr)
if (v === null) continue
try {
el.setAttribute(attr, decodeURI(v))
} catch {
// 非法的百分号序列保持原样比较
}
}
}
const walk = (node: Node) => {
for (const child of [...node.childNodes]) {
if (child.nodeType === 3 && !(child.parentNode as Element).closest?.('pre') && !child.textContent!.trim()) {
child.parentNode!.removeChild(child)
} else walk(child)
}
}
walk(root as unknown as Node)
return root.innerHTML
}
const examples = specTests.map((t) => ({
...t,
markdown: t.markdown.replace(/→/g, '\t'),
html: t.html.replace(/→/g, '\t'),
}))
// @lezer/markdown 1.8.0 解析结果本身偏离规范的用例(已逐个核对语法树,非渲染器问题):
// 制表符 ATX 标题、空列表项后的段落、<textarea> HTML 块、方括号嵌套与链接优先级、非法 HTML 标签/注释。
// 用 it.fails 标记:上游修复后这些用例会转为通过并报错,提醒移出此列表。
const PARSER_DEVIATIONS = new Set([10, 171, 280, 493, 512, 523, 528, 569, 571, 621, 625, 626])
describe('CommonMark spec', () => {
for (const e of examples) {
const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it
test(`#${e.number} ${e.section}`, () => {
expect(normalize(renderMarkdown(e.markdown, commonmarkParser))).toBe(normalize(e.html))
})
}
})
+462
View File
@@ -0,0 +1,462 @@
import type { SyntaxNode } from '@lezer/common'
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
// 与编辑器(lang-markdown 的 markdownLanguage)一致的语法集
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji])
export { commonmarkParser }
interface Line {
rest: string
col: number
}
interface LinkDefinition {
href: string
title: string | null
}
const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem'])
export function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;'))
}
// 属性值中保留实体引用(交给浏览器解码),只转义其余的特殊字符
function escapeAttr(s: string): string {
return s.replace(/&(?!(?:#\d{1,7}|#[xX][\da-fA-F]{1,6}|[A-Za-z][A-Za-z\d]{1,31});)|[<>"]/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;',
)
}
function unescapeBackslashes(s: string): string {
return s.replace(/\\([!-/:-@[-`{-~])/g, '$1')
}
// 对非法 URL 字符做百分号编码,已有的 %XX 保留
function normalizeUrl(s: string): string {
return s.replace(/%(?![\da-fA-F]{2})|[^\w\-.~:/?#[\]@!$&'()*+,;=%]/g, (c) => encodeURIComponent(c))
}
function normalizeLabel(s: string): string {
return s.slice(1, -1).trim().replace(/\s+/g, ' ').toLowerCase().toUpperCase()
}
function tabWidth(col: number): number {
return 4 - (col % 4)
}
export function renderMarkdown(src: string, parser: MarkdownParser = markdownParser): string {
const tree = parser.parse(src)
const text = (from: number, to: number) => src.slice(from, to)
const defs = new Map<string, LinkDefinition>()
tree.iterate({
enter(ref) {
if (ref.name !== 'LinkReference') return
const node = ref.node
const label = node.getChild('LinkLabel')
const url = node.getChild('URL')
if (!label || !url) return false
const key = normalizeLabel(text(label.from, label.to))
if (!defs.has(key)) defs.set(key, { href: destination(url), title: linkTitle(node.getChild('LinkTitle')) })
return false
},
})
function destination(url: SyntaxNode): string {
let s = text(url.from, url.to)
if (s.startsWith('<') && s.endsWith('>')) s = s.slice(1, -1)
return normalizeUrl(unescapeBackslashes(s))
}
function linkTitle(node: SyntaxNode | null): string | null {
return node ? unescapeBackslashes(text(node.from + 1, node.to - 1)) : null
}
// ---- 容器前缀剥离:代码块、HTML 块需要按 CommonMark 的列规则去掉引用符与列表缩进 ----
function containers(node: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = []
for (let p = node.parent; p; p = p.parent) if (BLOCK_CONTAINERS.has(p.name)) out.unshift(p)
return out
}
function lineStart(pos: number): number {
return src.lastIndexOf('\n', pos - 1) + 1
}
function lineEnd(pos: number): number {
const i = src.indexOf('\n', pos)
return i < 0 ? src.length : i
}
function columnAt(pos: number): number {
let col = 0
for (let i = lineStart(pos); i < pos; i++) col += src[i] === '\t' ? tabWidth(col) : 1
return col
}
// 列表项内容的起始列(绝对列号)
function itemContentColumn(item: SyntaxNode): number {
const mark = item.getChild('ListMark')!
const markEnd = columnAt(mark.to)
let col = markEnd
let i = mark.to
const end = lineEnd(mark.to)
while (i < end && (src[i] === ' ' || src[i] === '\t')) col += src[i++] === '\t' ? tabWidth(col) : 1
if (i === end || col - markEnd > 4) return markEnd + 1
return col
}
// 去掉一行开头属于容器的部分,返回剩余文本(被拆开的制表符以空格补齐)
function stripContainers(lineFrom: number, cs: SyntaxNode[]): { rest: string; col: number } {
const end = lineEnd(lineFrom)
let i = lineFrom
let col = 0
let pad = 0
const eatColumns = (target: number) => {
while (col + pad < target && i < end && (src[i] === ' ' || src[i] === '\t')) {
const w = src[i] === '\t' ? tabWidth(col) : 1
if (col + w > target) {
pad = col + w - target
col = target
i++
return
}
col += w
i++
}
}
for (const c of cs) {
if (c.name === 'Blockquote') {
const base = col
eatColumns(base + 3)
if (src[i] === '>') {
i++
col++
pad = 0
if (src[i] === ' ') {
i++
col++
} else if (src[i] === '\t') {
const w = tabWidth(col)
i++
col++
pad = w - 1
}
}
} else {
const target = itemContentColumn(c)
if (lineStart(c.from) === lineFrom) {
// 列表项首行:跳过列表标记本身
const mark = c.getChild('ListMark')!
col = columnAt(mark.to)
i = mark.to
pad = 0
}
if (pad) {
const use = Math.min(pad, target - col)
pad -= use
col += use
}
eatColumns(target)
}
}
// pad 个空格(被拆开的制表符剩余部分)从第 col 列开始
return { rest: ' '.repeat(pad) + src.slice(i, end), col }
}
// 去掉至多 n 列的前导空白;startCol 为该行文本起始的绝对列,用于计算制表符宽度
function stripIndent({ rest: line, col: startCol }: Line, n: number): string {
let col = 0
let i = 0
while (col < n && i < line.length && (line[i] === ' ' || line[i] === '\t')) {
const w = line[i] === '\t' ? tabWidth(startCol + col) : 1
if (col + w > n) return ' '.repeat(col + w - n) + line.slice(i + 1)
col += w
i++
}
return line.slice(i)
}
function contentLines(node: SyntaxNode, skipFirst: boolean, skipLast: boolean): Line[] {
const cs = containers(node)
const lines: Line[] = []
let pos = lineStart(node.from)
// 未闭合的块会延伸到文末换行之后,那一行不属于内容
const last = lineStart(node.to > node.from && src[node.to - 1] === '\n' ? node.to - 1 : node.to)
for (let first = true; pos <= last; first = false) {
const isLast = pos === last
if (!(first && skipFirst) && !(isLast && skipLast)) lines.push(stripContainers(pos, cs))
if (pos >= src.length) break
pos = lineEnd(pos) + 1
}
return lines
}
// ---- 行内 ----
function inline(parent: SyntaxNode, from: number, to: number): string {
let out = ''
let pos = from
// 引用符和硬换行之后的行首空白不属于内容
let afterQuote = false
for (let ch = parent.firstChild; ch; ch = ch.nextSibling) {
if (ch.to <= from || ch.from >= to) continue
if (ch.from > pos) out += textSegment(text(pos, ch.from), afterQuote)
afterQuote = ch.name === 'QuoteMark' || ch.name === 'HardBreak'
out += inlineNode(ch)
pos = ch.to
}
if (pos < to) out += textSegment(text(pos, to), afterQuote)
return out
}
function textSegment(s: string, afterQuote: boolean): string {
s = s.replace(/[ \t]*\n[ \t]*/g, '\n')
if (afterQuote) s = s.replace(/^[ \t]+/, '')
return escapeHtml(s)
}
function innerOf(node: SyntaxNode, markName: string): string {
const marks = node.getChildren(markName)
if (marks.length < 2) return inline(node, node.from, node.to)
return inline(node, marks[0].to, marks[marks.length - 1].from)
}
function plainText(html: string): string {
return html.replace(/<img [^>]*?alt="([^"]*)"[^>]*>/g, '$1').replace(/<[^>]*>/g, '')
}
function inlineNode(node: SyntaxNode): string {
switch (node.name) {
case 'QuoteMark':
case 'HeaderMark':
return ''
case 'Escape':
return escapeHtml(text(node.from + 1, node.to))
case 'Entity': {
const ent = text(node.from, node.to)
return /^&#(\d{8,}|[xX][\da-fA-F]{7,});$/.test(ent) ? escapeHtml(ent) : ent
}
case 'HardBreak':
return '<br />\n'
case 'Emphasis':
return `<em>${innerOf(node, 'EmphasisMark')}</em>`
case 'StrongEmphasis':
return `<strong>${innerOf(node, 'EmphasisMark')}</strong>`
case 'Strikethrough':
return `<del>${innerOf(node, 'StrikethroughMark')}</del>`
case 'Superscript':
return `<sup>${innerOf(node, 'SuperscriptMark')}</sup>`
case 'Subscript':
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
case 'InlineCode': {
const marks = node.getChildren('CodeMark')
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ')
if (/^ .*[^ ].* $/s.test(code)) code = code.slice(1, -1)
return `<code>${escapeHtml(code)}</code>`
}
case 'HTMLTag':
case 'Comment':
case 'ProcessingInstruction':
return text(node.from, node.to)
case 'Autolink': {
const url = node.getChild('URL')
if (!url) return escapeHtml(text(node.from, node.to))
const raw = text(url.from, url.to)
const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw
return `<a href="${escapeAttr(normalizeUrl(href))}">${escapeHtml(raw)}</a>`
}
case 'URL': {
// GFM 裸链接
const raw = text(node.from, node.to)
const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw
return `<a href="${escapeAttr(normalizeUrl(href))}">${escapeHtml(raw)}</a>`
}
case 'Link':
case 'Image':
return linkOrImage(node)
default:
return escapeHtml(text(node.from, node.to))
}
}
function linkOrImage(node: SyntaxNode): string {
const isImage = node.name === 'Image'
const marks = node.getChildren('LinkMark')
const labelFrom = node.from + (isImage ? 2 : 1)
const closeBracket = marks.find((m) => m.from >= labelFrom && text(m.from, m.to) === ']')
if (!closeBracket) return escapeHtml(text(node.from, node.to))
const content = inline(node, labelFrom, closeBracket.from)
let def: LinkDefinition | undefined
const url = node.getChild('URL')
if (marks.some((m) => text(m.from, m.to) === '(')) {
def = { href: url ? destination(url) : '', title: linkTitle(node.getChild('LinkTitle')) }
} else {
const label = node.getChild('LinkLabel')
const key = label && text(label.from, label.to) !== '[]' ? text(label.from, label.to) : text(labelFrom - 1, closeBracket.to)
def = defs.get(normalizeLabel(key))
if (!def) {
// 引用未定义:按字面文本输出
const tail = label ? escapeHtml(unescapeBackslashes(text(label.from, label.to))) : ''
return (isImage ? '!' : '') + '[' + content + ']' + tail
}
}
const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : ''
if (isImage) return `<img src="${escapeAttr(def.href)}" alt="${escapeAttr(plainText(content))}"${title} />`
return `<a href="${escapeAttr(def.href)}"${title}>${content}</a>`
}
// ---- 块级 ----
function isBlank(line: string): boolean {
return /^[ \t>]*$/.test(line)
}
function hasBlankLineBetween(a: SyntaxNode, b: SyntaxNode): boolean {
const lines = text(a.to, b.from).split('\n')
return lines.slice(1, -1).some(isBlank)
}
function blockChildren(node: SyntaxNode, withDefinitions = false): SyntaxNode[] {
const out: SyntaxNode[] = []
for (let ch = node.firstChild; ch; ch = ch.nextSibling) {
if (/Mark$/.test(ch.name) || ch.name === 'Task') continue
if (ch.name !== 'LinkReference' || withDefinitions) out.push(ch)
}
return out
}
function isLoose(list: SyntaxNode): boolean {
const items = list.getChildren('ListItem')
for (let i = 0; i + 1 < items.length; i++) if (hasBlankLineBetween(items[i], items[i + 1])) return true
for (const item of items) {
const kids = blockChildren(item, true)
for (let i = 0; i + 1 < kids.length; i++) if (hasBlankLineBetween(kids[i], kids[i + 1])) return true
}
return false
}
function blocks(node: SyntaxNode, tight = false): string {
let out = ''
for (let ch = node.firstChild; ch; ch = ch.nextSibling) out += block(ch, tight)
return out
}
function block(node: SyntaxNode, tight: boolean): string {
const name = node.name
if (name.startsWith('ATXHeading')) {
const level = name.slice(-1)
const marks = node.getChildren('HeaderMark')
const from = marks.length ? marks[0].to : node.from
const to = marks.length > 1 ? marks[marks.length - 1].from : node.to
return `<h${level}>${inline(node, from, to).trim()}</h${level}>\n`
}
if (name.startsWith('SetextHeading')) {
const level = name.slice(-1)
const mark = node.getChildren('HeaderMark').pop()!
return `<h${level}>${inline(node, node.from, mark.from).trim()}</h${level}>\n`
}
switch (name) {
case 'Paragraph': {
const body = inline(node, node.from, node.to).trim()
return tight ? body + '\n' : `<p>${body}</p>\n`
}
case 'Blockquote':
return `<blockquote>\n${blocks(node)}</blockquote>\n`
case 'BulletList':
case 'OrderedList': {
const loose = isLoose(node)
const tag = name === 'BulletList' ? 'ul' : 'ol'
let attrs = ''
if (tag === 'ol') {
const first = node.getChild('ListItem')!.getChild('ListMark')!
const start = parseInt(text(first.from, first.to), 10)
if (start !== 1) attrs = ` start="${start}"`
}
let items = ''
for (const item of node.getChildren('ListItem')) items += listItem(item, !loose)
return `<${tag}${attrs}>\n${items}</${tag}>\n`
}
case 'HorizontalRule':
return '<hr />\n'
case 'FencedCode': {
const marks = node.getChildren('CodeMark')
const closed = marks.length > 1
const info = node.getChild('CodeInfo')
const lang = info ? unescapeBackslashes(text(info.from, info.to).trim().split(/\s/)[0]) : ''
const cs = containers(node)
const fenceIndent = columnAt(marks[0].from) - stripContainers(lineStart(marks[0].from), cs).col
const lines = contentLines(node, true, closed).map((l) => stripIndent(l, fenceIndent))
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : ''
const code = lines.length ? lines.join('\n') + '\n' : ''
return `<pre><code${cls}>${escapeHtml(code)}</code></pre>\n`
}
case 'CodeBlock': {
const lines = contentLines(node, false, false).map((l) => stripIndent(l, 4))
while (lines.length && !lines[lines.length - 1].trim()) lines.pop()
return `<pre><code>${escapeHtml(lines.join('\n') + '\n')}</code></pre>\n`
}
case 'HTMLBlock':
case 'CommentBlock':
case 'ProcessingInstructionBlock':
return contentLines(node, false, false).map((l) => l.rest).join('\n') + '\n'
case 'Table':
return table(node)
default:
return ''
}
}
function listItem(item: SyntaxNode, tight: boolean): string {
const kids = blockChildren(item)
const task = item.getChild('Task')
if (task) {
const marker = task.getChild('TaskMarker')!
const checked = text(marker.from + 1, marker.from + 2) !== ' ' ? ' checked=""' : ''
const body = inline(task, marker.to, task.to).trim()
return `<li><input type="checkbox" disabled=""${checked} /> ${body}</li>\n`
}
if (!kids.length) return '<li></li>\n'
let body = ''
for (const k of kids) body += block(k, tight)
const startsInline = tight && kids[0].name === 'Paragraph'
const endsInline = tight && kids[kids.length - 1].name === 'Paragraph'
if (!startsInline) body = '\n' + body
if (endsInline) body = body.replace(/\n$/, '')
return `<li>${body}</li>\n`
}
function table(node: SyntaxNode): string {
const delimiter = node.getChild('TableDelimiter')
const aligns = delimiter
? text(delimiter.from, delimiter.to)
.replace(/^\s*\|?|\|?\s*$/g, '')
.split('|')
.map((c) => {
const s = c.trim()
return s.startsWith(':') && s.endsWith(':') ? 'center' : s.endsWith(':') ? 'right' : s.startsWith(':') ? 'left' : ''
})
: []
const row = (r: SyntaxNode, tag: string) => {
const cells = r.getChildren('TableCell')
let out = '<tr>\n'
for (let i = 0; i < aligns.length; i++) {
const align = aligns[i] ? ` align="${aligns[i]}"` : ''
const cell = cells[i]
out += `<${tag}${align}>${cell ? inline(cell, cell.from, cell.to).trim() : ''}</${tag}>\n`
}
return out + '</tr>\n'
}
const header = node.getChild('TableHeader')
let out = '<table>\n'
if (header) out += `<thead>\n${row(header, 'th')}</thead>\n`
const rows = node.getChildren('TableRow')
if (rows.length) out += `<tbody>\n${rows.map((r) => row(r, 'td')).join('')}</tbody>\n`
return out + '</table>\n'
}
return blocks(tree.topNode)
}
+149
View File
@@ -0,0 +1,149 @@
/*
* 基础主题。--rm-* 变量即主题 API,第三方主题只需覆盖这些变量。
* 代码块与行内代码配色取自 vuejs.org(@vue/theme + Shiki github-dark)。
*/
:root {
--rm-font-text: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Noto Sans CJK SC', 'Helvetica Neue', sans-serif;
--rm-font-mono: ui-monospace, 'SF Mono', 'SFMono-Regular', Menlo, Monaco, 'Cascadia Mono', Consolas,
'Liberation Mono', 'Noto Sans Mono', 'DejaVu Sans Mono', 'Roboto Mono', 'Courier New', monospace;
--rm-font-size: 16px;
--rm-line-height: 1.7;
--rm-content-width: 760px;
--rm-bg: #ffffff;
--rm-text: #213547;
--rm-text-muted: rgba(60, 60, 60, 0.7);
--rm-divider: rgba(60, 60, 60, 0.12);
--rm-accent: #42b883;
--rm-accent-strong: #33a06f;
--rm-selection: rgba(66, 184, 131, 0.22);
--rm-inline-code-bg: #f1f1f1;
--rm-inline-code-text: #476582;
--rm-code-bg: #292d3e;
--rm-code-text: #e1e4e8;
--rm-code-font-size: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--rm-bg: #1a1a1a;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #aac8e4;
--rm-code-bg: #242424;
}
}
html,
body {
margin: 0;
background: var(--rm-bg);
color: var(--rm-text);
font-family: var(--rm-font-text);
}
.cm-editor {
font-size: var(--rm-font-size);
line-height: var(--rm-line-height);
background: var(--rm-bg);
color: var(--rm-text);
}
.cm-editor.cm-focused {
outline: none;
}
.cm-editor .cm-scroller {
font-family: var(--rm-font-text);
}
.cm-editor .cm-content {
max-width: var(--rm-content-width);
margin: 0 auto;
padding: 32px 24px 40vh;
caret-color: var(--rm-accent-strong);
}
.cm-editor .cm-cursor {
border-left: 2px solid var(--rm-accent-strong);
}
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground {
background: var(--rm-selection) !important;
}
/* 标题 */
.cm-line.cm-rm-h1 { font-size: 2em; line-height: 1.3; padding-top: 0.6em; }
.cm-line.cm-rm-h2 { font-size: 1.6em; line-height: 1.35; padding-top: 0.5em; }
.cm-line.cm-rm-h3 { font-size: 1.3em; line-height: 1.4; padding-top: 0.4em; }
.cm-line.cm-rm-h4 { font-size: 1.15em; padding-top: 0.3em; }
.cm-line.cm-rm-h5,
.cm-line.cm-rm-h6 { font-size: 1em; padding-top: 0.2em; }
.cm-line.cm-rm-h6 { color: var(--rm-text-muted); }
/* Markdown 标记(激活行显示源码时) */
.cm-rm-markup,
.cm-rm-url { color: var(--rm-text-muted); }
.cm-rm-link,
.cm-rm-link .cm-rm-url { color: var(--rm-accent-strong); text-decoration: none; cursor: text; }
/* 行内代码 */
.cm-rm-inline-code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.15em 0.4em;
border-radius: 4px;
}
/* 代码块 */
.cm-line.cm-rm-code {
font-family: var(--rm-font-mono);
font-size: var(--rm-code-font-size);
line-height: 1.5;
background: var(--rm-code-bg);
color: var(--rm-code-text);
padding: 0 24px;
tab-size: 4;
}
.cm-line.cm-rm-code .cm-rm-mono { font-family: inherit; }
.cm-line.cm-rm-code-begin { border-radius: 8px 8px 0 0; padding-top: 10px; }
.cm-line.cm-rm-code-end { border-radius: 0 0 8px 8px; padding-bottom: 10px; }
.cm-line.cm-rm-code-begin.cm-rm-code-end { border-radius: 8px; }
/* 未激活时的围栏行:压低行高,只显示右上角语言标签 */
.cm-line.cm-rm-code-fence { font-size: 12px; line-height: 1.2; text-align: right; }
.cm-rm-code-lang {
color: rgba(235, 235, 235, 0.38);
user-select: none;
}
.cm-rm-code .cm-rm-markup { color: rgba(235, 235, 235, 0.38); }
/* 引用 */
.cm-line.cm-rm-quote {
border-left: 3px solid var(--rm-divider);
padding-left: 16px;
color: var(--rm-text-muted);
}
/* 列表 */
.cm-rm-bullet { color: var(--rm-text-muted); display: inline-block; width: 1ch; }
.cm-rm-task { margin: 0 0.4em 0 0; vertical-align: -0.1em; accent-color: var(--rm-accent); cursor: pointer; }
/* 分隔线 */
.cm-rm-hr { display: inline-block; width: 100%; border-top: 1px solid var(--rm-divider); vertical-align: middle; }
/* 图片 */
.cm-rm-image { display: block; }
.cm-rm-image img { max-width: 100%; border-radius: 4px; }
.cm-rm-image-error { color: var(--rm-text-muted); font-style: italic; }
/* 表格(M2 前以等宽源码显示) */
.cm-line.cm-rm-table { font-family: var(--rm-font-mono); font-size: 0.9em; }
@media (max-width: 640px) {
.cm-editor .cm-content { padding: 16px 16px 40vh; }
.cm-line.cm-rm-code { padding: 0 12px; }
}
+3
View File
@@ -0,0 +1,3 @@
declare module 'commonmark-spec' {
export const tests: { markdown: string; html: string; section: string; number: number }[]
}