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

+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
}
}