feat(web): 新增 Markdown 即时渲染装饰层(Typora 式)

- web/src/editor/:syntax(lezer 语法树 → 装饰描述,纯函数)、reveal(行粒度展开 +
  合并区间二分)、widgets(圆点/引用竖线/水平线/图片/围栏语言标签/任务框,全部
  createElement + textContent、零 innerHTML)、decorations(Spec → DecorationSet +
  原子区间 + 图片 src 白名单 + 可见区间补解析)、livePreview(ViewPlugin + atomicRanges +
  Compartment 源码开关 + view.composing 期间冻结装饰)
- Editor.tsx:挂载装饰层,新增可选 sourceMode prop
- index.css:抽出 --md-* 排版契约变量(阅读态 .pn-note-body 与编辑态 .cm-md-* 共用,
  杜绝两份样式漂移)、.cm-md-* 装饰样式(对齐阅读态字号/衬线标题/引用弱化色/代码底色)
- 数据层零改动:onChange 拿到的仍是 Markdown 原文,公开页渲染管线与 sanitize 防线不动;
  运行时零新增 npm 依赖

P3 加固(长文档惰性解析补解析、Cmd+A 全选退化、光标行在视口外也装饰)包含在
decorations.ts / livePreview.ts 内,回归用例见后续 test 提交,方案与实测见
docs/editor-live-rendering.md §12.5。
This commit is contained in:
2026-09-11 01:03:02 +08:00
parent 9b0aff6f9f
commit 5c5ab3dfc3
7 changed files with 1062 additions and 16 deletions
+24 -4
View File
@@ -1,14 +1,17 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import { useTheme } from '../lib/theme' import { useTheme } from '../lib/theme'
import { livePreview, livePreviewCompartment } from '../editor/livePreview'
/** /**
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。 * Editor(编辑态,§8.2):CodeMirror 6 即时渲染(Typora 式)+ 图片粘贴/拖拽上传。
* 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入 * 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入
* `![描述](/api/images/{id})`。 * `![描述](/api/images/{id})`,插入后由装饰层立刻渲染为图片。
*
* sourceMode=true 时卸载全部装饰,退回纯源码编辑(逃生舱,方案 §4.7)。
*/ */
interface UploadResult { interface UploadResult {
@@ -34,24 +37,40 @@ export default function Editor({
onChange, onChange,
onUploadStart, onUploadStart,
onUploadEnd, onUploadEnd,
sourceMode = false,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
onUploadStart?: (name: string) => void onUploadStart?: (name: string) => void
onUploadEnd?: (err: string | null) => void onUploadEnd?: (err: string | null) => void
sourceMode?: boolean
}) { }) {
const viewRef = useRef<{ view?: EditorView } | null>(null) const viewRef = useRef<{ view?: EditorView } | null>(null)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const { mode } = useTheme() const { mode } = useTheme()
const appliedSourceMode = useRef<boolean | null>(null)
const extensions = useMemo( const extensions = useMemo(
() => [ () => [
markdown({ base: markdownLanguage }), markdown({ base: markdownLanguage }),
EditorView.lineWrapping, EditorView.lineWrapping,
// 初始值即决定首次挂载是否带装饰;后续切换走 useEffect 的 reconfigure
livePreviewCompartment.of(sourceMode ? [] : livePreview()),
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps
[], [],
) )
// 源码模式 O(1) 切换:整体挂载/卸载装饰扩展(方案 §4.7)
useEffect(() => {
const view = viewRef.current?.view
if (!view || appliedSourceMode.current === null || appliedSourceMode.current === sourceMode) return
appliedSourceMode.current = sourceMode
view.dispatch({
effects: livePreviewCompartment.reconfigure(sourceMode ? [] : livePreview()),
})
}, [sourceMode])
const insertAtCursor = useCallback( const insertAtCursor = useCallback(
(text: string) => { (text: string) => {
const wrapper = viewRef.current const wrapper = viewRef.current
@@ -89,7 +108,7 @@ export default function Editor({
return ( return (
<div <div
className={dragOver ? 'pn-cm-drag' : undefined} className={[sourceMode ? '' : 'pn-cm-live', dragOver ? 'pn-cm-drag' : ''].filter(Boolean).join(' ') || undefined}
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }} style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault() e.preventDefault()
@@ -112,6 +131,7 @@ export default function Editor({
style={{ height: '100%', minHeight: 420 }} style={{ height: '100%', minHeight: 420 }}
onCreateEditor={(view) => { onCreateEditor={(view) => {
viewRef.current = { view } viewRef.current = { view }
appliedSourceMode.current = sourceMode
}} }}
onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => { onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => {
const files = event.clipboardData?.files const files = event.clipboardData?.files
+175
View File
@@ -0,0 +1,175 @@
// Spec → CodeMirror 装饰集(方案 §4.2 / §4.3 / §4.4)。
//
// 与 livePreview.ts 分离:本模块只做「纯转换」,输入是 EditorState + 可见区间,
// 输出 DecorationSet / 原子区间集,便于单测(不需要真实 view)。
import { RangeSet } from '@codemirror/state'
import type { EditorState, Range } from '@codemirror/state'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import type { Tree } from '@lezer/common'
import { Decoration } from '@codemirror/view'
import type { DecorationSet } from '@codemirror/view'
import { collectSpecs, type Spec } from './syntax'
import { activeLineRanges, applyReveal, selectionEndpointLines } from './reveal'
import {
BulletWidget,
FenceLangWidget,
HrWidget,
ImageWidget,
QuoteBarWidget,
TaskWidget,
} from './widgets'
/**
* 图片 src 白名单(方案 §6):仅允许站内图片接口与内联 data:image。
* 不匹配则整条 image spec 被丢弃 → 退回显示源码文本,不产出 <img>。
*/
export const IMAGE_SRC_ALLOW = /^(?:\/api\/images\/|data:image\/(?:png|jpe?g|gif|webp|avif);base64,)/i
export interface LiveDecorations {
decorations: DecorationSet
atomic: RangeSet<Decoration>
}
function specKey(s: Spec): string {
return [s.kind, s.from, s.to, 'line' in s ? s.line : '', 'cls' in s ? s.cls : ''].join('|')
}
/** 强制补解析可见区间所需时间的上限(毫秒)。 */
export const PARSE_BUDGET_MS = 20
/**
* 取一棵至少覆盖 `ranges` 末端的语法树。
*
* 关键点:lezer 是**惰性增量**解析 —— `syntaxTree(state)` 只给出已完成的部分,
* 长文档(实测 3000 行 ≈ 15 万字符时首次仅解析约 3000 字符)未覆盖的区间若直接
* 返回空装饰,用户会看到裸 Markdown。这里用 `ensureSyntaxTree` 在有限预算内
* 把可见区间补齐;预算内没补完就退回已有树,其余部分保持源码态并等解析推进后
* 由 `livePreview` 的「树对象变化」触发自动补装饰(不会永久缺失)。
*/
export function visibleSyntaxTree(
state: EditorState,
ranges: readonly { from: number; to: number }[],
timeoutMs = PARSE_BUDGET_MS,
): Tree {
let upto = 0
for (const r of ranges) if (r.to > upto) upto = r.to
if (upto <= 0) return syntaxTree(state)
return ensureSyntaxTree(state, upto, timeoutMs) ?? syntaxTree(state)
}
/**
* 采集可见区间内的语法 → 应用 reveal → 生成装饰集。
* 只遍历 visibleRanges(外加光标所在行),不整篇扫描(方案 §7.4)。
*
* 为什么额外加选区端点行:CodeMirror 会把选区所在的块单独渲染出来(哪怕它
* 在视口之外,用 `cm-gap` 占位)。若只装饰 visibleRanges,光标行在远距离滚动
* 后会以裸源码出现。补上这几行成本可忽略,却让「凡渲染出来的行都有装饰」成立。
* 注意只取**端点**行 —— 选区跨越多行时 CM 也只渲染端点块,全量取会让 Cmd+A
* 在长文档上退化成全文档装饰构建。
*
* @param reveal 编辑器是否处于聚焦态;未聚焦时整篇按渲染态呈现(决策 E11)
*/
export function buildDecorations(
state: EditorState,
visibleRanges: readonly { from: number; to: number }[],
reveal: boolean,
tree?: Tree,
): LiveDecorations {
const selectionLines = activeLineRanges(state)
const collectRanges: { from: number; to: number }[] = [...visibleRanges]
for (const line of selectionEndpointLines(state)) {
if (!collectRanges.some((r) => r.from <= line.from && r.to >= line.to)) collectRanges.push(line)
}
const syntax = tree ?? visibleSyntaxTree(state, collectRanges)
const raw: Spec[] = []
const seen = new Set<string>()
for (const range of collectRanges) {
// 语法树可能只覆盖文档前段:越界部分不采集(保持源码态,等解析推进后补齐)
const to = Math.min(range.to, syntax.length)
if (to <= range.from) continue
for (const spec of collectSpecs(syntax, state.doc, range.from, to)) {
const key = specKey(spec)
if (seen.has(key)) continue
seen.add(key)
raw.push(spec)
}
}
const specs = reveal ? applyReveal(raw, selectionLines) : raw
const ranges: Range<Decoration>[] = []
const atomic: Range<Decoration>[] = []
for (const spec of specs) {
switch (spec.kind) {
case 'line':
ranges.push(Decoration.line({ class: spec.cls }).range(spec.from))
break
case 'mark':
ranges.push(Decoration.mark({ class: spec.cls }).range(spec.from, spec.to))
break
case 'hide': {
const deco = Decoration.replace({})
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'bullet': {
const deco = Decoration.replace({ widget: new BulletWidget(spec.depth) })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'quote': {
const deco = Decoration.replace({ widget: new QuoteBarWidget(spec.depth) })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'hr': {
const deco = Decoration.replace({ widget: new HrWidget() })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'image': {
if (!IMAGE_SRC_ALLOW.test(spec.src)) break // 非白名单 → 退回源码文本
const deco = Decoration.replace({ widget: new ImageWidget(spec.src, spec.alt) })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'task': {
const deco = Decoration.replace({ widget: new TaskWidget(spec.from, spec.to, spec.checked) })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'fenceLang': {
const deco = Decoration.replace({ widget: new FenceLangWidget(spec.lang) })
const r = deco.range(spec.from, spec.to)
ranges.push(r)
atomic.push(r)
break
}
case 'tableMark': {
ranges.push(Decoration.mark({ class: 'cm-md-table-mark' }).range(spec.from, spec.to))
if (spec.delimiter) ranges.push(Decoration.mark({ class: 'cm-md-table-delim' }).range(spec.from, spec.to))
break
}
}
}
return {
decorations: Decoration.set(ranges, true),
atomic: RangeSet.of(atomic, true),
}
}
+70
View File
@@ -0,0 +1,70 @@
// 即时渲染扩展入口(方案 §3.1 / §4.7)。
//
// - ViewPlugin:装饰计算只跑可见区间;selection 变化即重算(行粒度 reveal);
// - EditorView.atomicRanges:隐藏的纯标记区间原子化,方向键整段跳过;
// - livePreviewCompartment:源码模式开关整体挂载/卸载,O(1) 且无残留装饰。
//
// IME(方案 §7.1):视图处于组合输入(view.composing)且文档变化时,
// 不做重算,只把旧装饰按变更映射 —— 组合范围内绝不替换 DOM。
import { Compartment, RangeSet } from '@codemirror/state'
import type { Extension } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
import type { DecorationSet, PluginValue, ViewUpdate } from '@codemirror/view'
import { buildDecorations } from './decorations'
class LivePreviewPlugin implements PluginValue {
decorations: DecorationSet = Decoration.none
atomic: RangeSet<Decoration> = RangeSet.empty
constructor(view: EditorView) {
this.build(view)
}
update(update: ViewUpdate): void {
if (update.docChanged && update.view.composing) {
// 组合输入期间冻结:只映射位置,不重建装饰(§7.1)
this.decorations = this.decorations.map(update.changes)
this.atomic = this.atomic.map(update.changes)
return
}
// 语法树对象换代 = 后台解析推进:必须重建,否则未解析区间的装饰会一直缺失
const treeAdvanced = syntaxTree(update.startState) !== syntaxTree(update.state)
if (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
update.focusChanged ||
treeAdvanced
) {
this.build(update.view)
}
}
private build(view: EditorView): void {
// 未聚焦时整篇按渲染态呈现(决策 E11):编辑器失焦 = 只读观感,
// 避免「首行因光标停在行首而永远显示 #」的观感缺陷。
const { decorations, atomic } = buildDecorations(
view.state,
view.visibleRanges,
view.hasFocus,
)
this.decorations = decorations
this.atomic = atomic
}
}
export const livePreviewPlugin = ViewPlugin.fromClass(LivePreviewPlugin, {
decorations: (value) => value.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? RangeSet.empty),
})
/** 源码模式开关的 compartment;`empty` 表示回到纯源码编辑。 */
export const livePreviewCompartment = new Compartment()
/** 组装即时渲染扩展集。 */
export function livePreview(): Extension {
return [livePreviewPlugin]
}
+106
View File
@@ -0,0 +1,106 @@
// 光标处展开(方案 §4.5)。
//
// 实现为纯函数:装饰集 = f(语法树, selection)。v1 取行粒度(决策 E6):
// 光标/选区触及的行,其语法标记整体展开为源码。行粒度对 IME 最安全 ——
// 组字所在行永远是展开态,组合过程中不会被 replace 掉 DOM。
//
// span 级展开(Typora 的「光标进入行内节点才展开」)列入方案 §10.2。
import type { EditorState } from '@codemirror/state'
import type { Spec } from './syntax'
export interface Range {
from: number
to: number
}
/** 选区(全部 range,非仅 main)所触及的整行区间集合(升序、互不相交)。 */
export function activeLineRanges(state: EditorState): Range[] {
const out: Range[] = []
const seen = new Set<number>()
for (const r of state.selection.ranges) {
const start = state.doc.lineAt(r.from)
const end = state.doc.lineAt(r.to)
for (let n = start.number; n <= end.number; n++) {
if (seen.has(n)) continue
seen.add(n)
const line = state.doc.line(n)
out.push({ from: line.from, to: line.to })
}
}
return out
}
/**
* 选区**端点**所在的整行(CM 只把选区端点所在的块渲染在视口之外,
* 不会渲染整个选区跨越的每一行)。用于决定「除可见区间外还要装饰哪些行」,
* 避免 Cmd+A 这种整篇选区触发全文档装饰构建。
*/
export function selectionEndpointLines(state: EditorState): Range[] {
const out: Range[] = []
const seen = new Set<number>()
for (const r of state.selection.ranges) {
for (const pos of [r.from, r.to]) {
const line = state.doc.lineAt(pos)
if (seen.has(line.number)) continue
seen.add(line.number)
out.push({ from: line.from, to: line.to })
}
}
return out
}
/** [from, to) 是否与任一展开区间相交。零长区间不与任何行相交。 */
export function intersectsAny(ranges: readonly Range[], from: number, to: number): boolean {
if (to <= from) return false
for (const r of ranges) {
if (from <= r.to && r.from <= to) return true
}
return false
}
/** 把区间排序并合并,得到互不相交的升序区间,供二分查询使用。 */
function mergeRanges(ranges: readonly Range[]): Range[] {
if (ranges.length < 2) return ranges.map((r) => ({ from: r.from, to: r.to }))
const sorted = ranges
.map((r) => ({ from: r.from, to: r.to }))
.sort((a, b) => a.from - b.from || a.to - b.to)
const out: Range[] = [sorted[0]]
for (let i = 1; i < sorted.length; i++) {
const last = out[out.length - 1]
if (sorted[i].from <= last.to) last.to = Math.max(last.to, sorted[i].to)
else out.push(sorted[i])
}
return out
}
/**
* 消除 O(装饰数 × 展开区间数):展开区间先合并再二分。
* 整篇选区(Cmd+A)在长文档下会产生数千个展开区间,线性扫描会让单帧成本失控。
*/
export function intersectsMerged(sortedMerged: readonly Range[], from: number, to: number): boolean {
if (to <= from) return false
let lo = 0
let hi = sortedMerged.length - 1
let idx = -1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (sortedMerged[mid].from <= to) {
idx = mid
lo = mid + 1
} else {
hi = mid - 1
}
}
return idx >= 0 && sortedMerged[idx].to >= from
}
/**
* 丢掉落在展开区间内的「标记替代物」,保留样式类装饰。
* 结果仍是文档顺序(collectSpecs 输出即有序)。
*/
export function applyReveal(specs: readonly Spec[], ranges: readonly Range[]): Spec[] {
if (ranges.length === 0) return specs.slice()
const merged = mergeRanges(ranges)
return specs.filter((s) => !(s.reveal && intersectsMerged(merged, s.from, s.to)))
}
+317
View File
@@ -0,0 +1,317 @@
// 即时渲染(Typora 式)语法采集层(方案 §4.1)。
//
// 职责:把 lezer Markdown 语法树翻译成「与 CodeMirror 解耦」的装饰描述(Spec)。
// 纯函数、零 DOM、零 CM view 依赖,便于 vitest 直接断言区间。
//
// 设计约束:
// - 只认语法树节点,不写正则(方案 §4.1);
// - 采集结果不做 reveal 过滤(reveal 是 selection 的函数,见 reveal.ts),
// 由 decorations.ts 在构建阶段调用 applyReveal —— 这样「识别」与「展开」
// 可以分别单测;
// - 任何 from/to 都不跨越行边界(CM6 对 replace 跨行的硬性约束,方案 §4.2)。
import type { SyntaxNode, Tree } from '@lezer/common'
import type { Text } from '@codemirror/state'
/** 装饰描述类型(kind 决定 decorations.ts 里用哪种 Decoration)。 */
export type SpecKind =
| 'hide' // 纯定界符,replace 为空 + 原子
| 'mark' // 行内样式,Decoration.mark
| 'line' // 整行样式,Decoration.line
| 'bullet' // 无序列表圆点 widget(§5.1)
| 'quote' // 引用竖线 widget(§5.3)
| 'hr' // 水平线 widget(§5.4)
| 'image' // 图片 widget(§5.4)
| 'task' // 任务复选框 widget(§5.4)
| 'fenceLang' // 代码围栏语言标签 widget(§5.4)
| 'tableMark' // 表格定界符淡化(§4.4)
interface BaseSpec {
from: number
to: number
/**
* 是否属于「语法标记替代物」:为 true 时,光标所在行会撤销该装饰、展开源码
* (方案 §4.5 行粒度 reveal)。样式类(mark/line)恒为 false。
*/
reveal: boolean
}
export interface HideSpec extends BaseSpec {
kind: 'hide'
}
export interface MarkSpec extends BaseSpec {
kind: 'mark'
cls: string
}
export interface LineSpec extends BaseSpec {
kind: 'line'
line: number
cls: string
}
export interface BulletSpec extends BaseSpec {
kind: 'bullet'
depth: number
}
export interface QuoteSpec extends BaseSpec {
kind: 'quote'
depth: number
}
export interface HrSpec extends BaseSpec {
kind: 'hr'
}
export interface ImageSpec extends BaseSpec {
kind: 'image'
alt: string
src: string
}
export interface TaskSpec extends BaseSpec {
kind: 'task'
checked: boolean
}
export interface FenceLangSpec extends BaseSpec {
kind: 'fenceLang'
lang: string
}
export interface TableMarkSpec extends BaseSpec {
kind: 'tableMark'
/** 是否为分隔行(`| --- |`),用于「淡化」与「更淡」两级 */
delimiter: boolean
}
export type Spec =
| HideSpec
| MarkSpec
| LineSpec
| BulletSpec
| QuoteSpec
| HrSpec
| ImageSpec
| TaskSpec
| FenceLangSpec
| TableMarkSpec
const HEADING_RE = /^ATXHeading([1-6])$/
/** 行内样式类名(对齐 .pn-note-body 的阅读态,方案 §4.3)。 */
export const CLS_STRONG = 'cm-md-strong'
export const CLS_EM = 'cm-md-em'
export const CLS_DEL = 'cm-md-del'
export const CLS_CODE = 'cm-md-code'
export const CLS_LINK = 'cm-md-link'
export const CLS_TABLE = 'cm-md-table'
export const CLS_FENCED = 'cm-md-fenced'
export const CLS_QUOTE_LINE = 'cm-md-quote'
export const CLS_OL_MARK = 'cm-md-ol-mark'
/** 折行到下一个非空白位置(用于把 `#` / `-` 后的空格一并吃掉)。 */
function afterSpaces(doc: Text, pos: number, limit: number): number {
let p = pos
while (p < limit && doc.sliceString(p, p + 1) === ' ') p++
return p
}
/** 向上找最近的同名祖先。 */
function nearestAncestor(node: SyntaxNode, name: string): SyntaxNode | null {
for (let p = node.parent; p; p = p.parent) if (p.name === name) return p
return null
}
/** 列表嵌套深度 = 祖先链中 BulletList / OrderedList 的个数(方案 §5.1)。 */
function listDepth(node: SyntaxNode): number {
let d = 0
for (let p = node.parent; p; p = p.parent) {
if (p.name === 'BulletList' || p.name === 'OrderedList') d++
}
return d
}
/** 引用嵌套深度 = 祖先链中 Blockquote 的个数。 */
function quoteDepth(node: SyntaxNode): number {
let d = 0
for (let p = node.parent; p; p = p.parent) if (p.name === 'Blockquote') d++
return d
}
/** 该列表项是否为任务项(`- [ ] x`)—— 任务项不画圆点,只画复选框。 */
function isTaskItem(node: SyntaxNode): boolean {
const li = nearestAncestor(node, 'ListItem')
return !!li?.getChild('Task')
}
/**
* 采集 [from, to) 区间内所有装饰描述。区间通常取 `view.visibleRanges`,
* 保证只做视口内的工作(方案 §7.4)。
*/
export function collectSpecs(tree: Tree, doc: Text, from: number, to: number): Spec[] {
const specs: Spec[] = []
const push = (s: Spec) => {
if (s.kind === 'line' || s.to > s.from) specs.push(s)
}
const hide = (f: number, t: number) => push({ kind: 'hide', from: f, to: t, reveal: true })
const mark = (f: number, t: number, cls: string) => push({ kind: 'mark', from: f, to: t, cls, reveal: false })
tree.iterate({
from,
to,
enter: (node) => {
const name = node.name
const nodeFrom = node.from
const nodeTo = node.to
const heading = HEADING_RE.exec(name)
if (heading) {
const line = doc.lineAt(nodeFrom)
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: `cm-md-h${heading[1]}`, reveal: false })
const hm = node.node.getChild('HeaderMark')
if (hm) hide(hm.from, afterSpaces(doc, hm.to, line.to))
return
}
switch (name) {
case 'StrongEmphasis':
case 'Emphasis':
case 'Strikethrough': {
const markName = name === 'Strikethrough' ? 'StrikethroughMark' : 'EmphasisMark'
const marks = node.node.getChildren(markName)
if (marks.length >= 2) {
const open = marks[0]
const close = marks[marks.length - 1]
const cls = name === 'StrongEmphasis' ? CLS_STRONG : name === 'Emphasis' ? CLS_EM : CLS_DEL
hide(open.from, open.to)
hide(close.from, close.to)
mark(open.to, close.from, cls)
}
return
}
case 'InlineCode': {
// 行内代码内容为字面量,不再向下解析
const marks = node.node.getChildren('CodeMark')
if (marks.length >= 2) {
hide(marks[0].from, marks[0].to)
hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
mark(marks[0].to, marks[marks.length - 1].from, CLS_CODE)
}
return false
}
case 'Link': {
const marks = node.node.getChildren('LinkMark')
if (marks.length >= 2) {
hide(marks[0].from, marks[0].to)
mark(marks[0].to, marks[1].from, CLS_LINK)
// `](url)` 整段隐藏(URL 不产出 <a>,v1 只做文本样式,方案 §6)
hide(marks[1].from, nodeTo)
}
// 继续下钻:链接内可嵌图片
return
}
case 'Image': {
const url = node.node.getChild('URL')
const imgMarks = node.node.getChildren('LinkMark')
const alt = imgMarks.length >= 2 ? doc.sliceString(imgMarks[0].to, imgMarks[1].from) : ''
push({
kind: 'image',
from: nodeFrom,
to: nodeTo,
reveal: false,
alt,
src: url ? doc.sliceString(url.from, url.to) : '',
})
return false
}
case 'HorizontalRule':
push({ kind: 'hr', from: nodeFrom, to: nodeTo, reveal: true })
return false
case 'QuoteMark': {
const line = doc.lineAt(nodeFrom)
// 行级样式与竖线 widget 分工:widget 承接标记,行类把正文调成阅读态的弱化色
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: CLS_QUOTE_LINE, reveal: false })
push({ kind: 'quote', from: nodeFrom, to: nodeTo, reveal: true, depth: quoteDepth(node.node) })
return false
}
case 'ListMark': {
const list = nearestAncestor(node.node, 'BulletList') ?? nearestAncestor(node.node, 'OrderedList')
const line = doc.lineAt(nodeFrom)
const end = afterSpaces(doc, nodeTo, line.to)
if (list?.name === 'BulletList') {
if (isTaskItem(node.node)) {
hide(nodeFrom, end) // 任务项:标记让位给复选框
} else {
push({ kind: 'bullet', from: nodeFrom, to: end, reveal: true, depth: listDepth(node.node) })
}
} else {
// 有序列表:保留数字(携带序号语义,方案 §5.2 / 决策 E3),仅上样式
mark(nodeFrom, nodeTo, CLS_OL_MARK)
}
return false
}
case 'TaskMarker': {
// 复选框始终渲染(不参与 reveal),点击即改写源文本(方案 §5.4)
const checked = doc.sliceString(nodeFrom, nodeTo).toLowerCase().includes('x')
push({ kind: 'task', from: nodeFrom, to: nodeTo, reveal: false, checked })
return false
}
case 'FencedCode': {
const marks = node.node.getChildren('CodeMark')
const info = node.node.getChild('CodeInfo')
const startLine = doc.lineAt(nodeFrom)
const endLine = doc.lineAt(nodeTo)
if (marks.length >= 1) {
const open = marks[0]
const end = info ? info.to : open.to
if (info) {
push({ kind: 'fenceLang', from: open.from, to: end, reveal: true, lang: doc.sliceString(info.from, info.to) })
} else {
hide(open.from, end)
}
}
if (marks.length >= 2) hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
for (let n = startLine.number; n <= endLine.number; n++) {
const line = doc.line(n)
const cls = [
CLS_FENCED,
n === startLine.number ? 'cm-md-fenced-first' : '',
n === endLine.number ? 'cm-md-fenced-last' : '',
]
.filter(Boolean)
.join(' ')
push({ kind: 'line', from: line.from, to: line.to, line: n, cls, reveal: false })
}
return false // 代码块内不解析行内语法
}
case 'Table': {
const startLine = doc.lineAt(nodeFrom)
const endLine = doc.lineAt(nodeTo)
for (let n = startLine.number; n <= endLine.number; n++) {
const line = doc.line(n)
push({ kind: 'line', from: line.from, to: line.to, line: n, cls: CLS_TABLE, reveal: false })
}
return // 下钻以标记 TableDelimiter
}
case 'TableDelimiter': {
// 分隔行(`| --- |`)比普通竖线更淡,用于区分表头分隔
const text = doc.sliceString(nodeFrom, nodeTo)
push({ kind: 'tableMark', from: nodeFrom, to: nodeTo, reveal: false, delimiter: text.includes('-') })
return false
}
case 'TableCell':
// 表格为「源码对齐」呈现(决策 E5),单元格内不隐藏语法
return false
default:
return
}
},
})
return specs
}
+170
View File
@@ -0,0 +1,170 @@
// 即时渲染的 widget 集合(方案 §5)。
//
// 硬性约束(§5.5):
// - 一律 document.createElement / textContent / setAttribute,禁止 innerHTML;
// - widget DOM 不接受任何用户 HTML,只消费语法树里解析出的「文本/白名单 URL」;
// - 除任务复选框外均 aria-hidden="true"(它们是语法标记的视觉替代物);
// - widget 不改变文档内容与选区语义(任务框例外:它显式改写源文本)。
import { EditorView, WidgetType } from '@codemirror/view'
const BULLET_CHARS = ['•', '◦', '▪']
/** 无序列表圆点(§5.1):按嵌套深度取 • / ◦ / ▪。 */
export class BulletWidget extends WidgetType {
constructor(readonly depth: number) {
super()
}
eq(other: BulletWidget): boolean {
return other.depth === this.depth
}
toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-md-bullet'
span.setAttribute('aria-hidden', 'true')
span.textContent = BULLET_CHARS[Math.min(this.depth - 1, BULLET_CHARS.length - 1)] ?? BULLET_CHARS[0]
return span
}
ignoreEvent(): boolean {
return false
}
}
/** 引用竖线(§5.3):CSS 画 2px 竖线,宽度 = 原标记宽度,嵌套自然叠加。 */
export class QuoteBarWidget extends WidgetType {
constructor(readonly depth: number) {
super()
}
eq(other: QuoteBarWidget): boolean {
return other.depth === this.depth
}
toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-md-quote-bar'
span.setAttribute('aria-hidden', 'true')
span.dataset.depth = String(this.depth)
return span
}
ignoreEvent(): boolean {
return false
}
}
/** 水平线(§5.4):整行内容替换为一条贯穿的分隔线。 */
export class HrWidget extends WidgetType {
eq(): boolean {
return true
}
toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-md-hr'
span.setAttribute('aria-hidden', 'true')
return span
}
ignoreEvent(): boolean {
return false
}
}
/** 图片(§5.4):src 白名单在 decorations.ts 里已校验,此处只负责 DOM。 */
export class ImageWidget extends WidgetType {
constructor(
readonly src: string,
readonly alt: string,
) {
super()
}
eq(other: ImageWidget): boolean {
return other.src === this.src && other.alt === this.alt
}
toDOM(): HTMLElement {
const img = document.createElement('img')
img.className = 'cm-md-image'
img.src = this.src
img.alt = this.alt
img.loading = 'lazy'
// 图片加载失败(如已删除)时保持占位,不破坏行高
img.addEventListener('error', () => {
img.classList.add('cm-md-image-error')
})
return img
}
ignoreEvent(): boolean {
return false
}
}
/** 围栏语言标签(§5.4):` ```js ` → 语言名小标签。 */
export class FenceLangWidget extends WidgetType {
constructor(readonly lang: string) {
super()
}
eq(other: FenceLangWidget): boolean {
return other.lang === this.lang
}
toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-md-fence-lang'
span.setAttribute('aria-hidden', 'true')
span.textContent = this.lang
return span
}
ignoreEvent(): boolean {
return false
}
}
/**
* 任务复选框(§5.4):点击直接改写源文本 `[ ]` ↔ `[x]`,真相源仍是 Markdown。
* 该 widget 不参与 reveal(复选框始终可见可点,与 Typora 一致)。
*/
export class TaskWidget extends WidgetType {
constructor(
readonly from: number,
readonly to: number,
readonly checked: boolean,
) {
super()
}
eq(other: TaskWidget): boolean {
return other.from === this.from && other.to === this.to && other.checked === this.checked
}
toDOM(view: EditorView): HTMLElement {
const input = document.createElement('input')
input.type = 'checkbox'
input.className = 'cm-md-task'
input.checked = this.checked
input.setAttribute('aria-label', this.checked ? '标记为未完成' : '标记为已完成')
const onMouseDown = (event: MouseEvent) => {
// 阻止 CM 把焦点/光标移走,自己处理取反
event.preventDefault()
view.dispatch({
changes: { from: this.from, to: this.to, insert: this.checked ? '[ ]' : '[x]' },
})
}
input.addEventListener('mousedown', onMouseDown)
return input
}
/** 复选框要自己处理事件(默认 true 即「编辑器忽略」),无需覆写。 */
ignoreEvent(): boolean {
return true
}
}
+200 -12
View File
@@ -99,6 +99,20 @@
--font-serif-display: var(--font-serif); --font-serif-display: var(--font-serif);
--tracking-tight: -0.012em; --tracking-tight: -0.012em;
/* Markdown 排版契约(方案 §4.6):阅读态 .pn-note-body 与编辑态 .cm-md-* 共用,
杜绝两份样式漂移。改这里 = 同时改编辑态与公开页。 */
--md-body-size: 15px;
--md-body-lh: 1.8;
--md-h1-size: 26px;
--md-h2-size: 24px;
--md-h3-size: 19px;
--md-h4-size: 16px;
--md-heading-lh: 1.35;
--md-heading-font: var(--font-serif);
--md-code-size: 13px;
--md-table-size: 13.5px;
--md-img-radius: var(--radius-md);
/* 圆角 / 阴影 / 间距基数 */ /* 圆角 / 阴影 / 间距基数 */
--radius: 0.75rem; --radius: 0.75rem;
--radius-sm: 0.375rem; --radius-sm: 0.375rem;
@@ -804,8 +818,8 @@ button.pn-note-back:hover {
============================================================ */ ============================================================ */
.pn-note-body { .pn-note-body {
margin-top: 40px; margin-top: 40px;
font-size: 15px; font-size: var(--md-body-size);
line-height: 1.8; line-height: var(--md-body-lh);
color: var(--foreground); color: var(--foreground);
} }
.pn-note-body > :first-child { .pn-note-body > :first-child {
@@ -819,23 +833,23 @@ button.pn-note-back:hover {
.pn-note-body h3, .pn-note-body h3,
.pn-note-body h4 { .pn-note-body h4 {
margin: 40px 0 16px; margin: 40px 0 16px;
font-family: var(--font-serif); font-family: var(--md-heading-font);
font-weight: 400; font-weight: 400;
line-height: 1.35; line-height: var(--md-heading-lh);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
color: var(--foreground); color: var(--foreground);
} }
.pn-note-body h1 { .pn-note-body h1 {
font-size: 26px; font-size: var(--md-h1-size);
} }
.pn-note-body h2 { .pn-note-body h2 {
font-size: 24px; font-size: var(--md-h2-size);
} }
.pn-note-body h3 { .pn-note-body h3 {
font-size: 19px; font-size: var(--md-h3-size);
} }
.pn-note-body h4 { .pn-note-body h4 {
font-size: 16px; font-size: var(--md-h4-size);
} }
.pn-note-body blockquote { .pn-note-body blockquote {
margin: 32px 0; margin: 32px 0;
@@ -851,7 +865,7 @@ button.pn-note-back:hover {
} }
.pn-note-body code { .pn-note-body code {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: var(--md-code-size);
background: var(--muted); background: var(--muted);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 2px 6px; padding: 2px 6px;
@@ -870,7 +884,7 @@ button.pn-note-back:hover {
display: block; display: block;
background: transparent; background: transparent;
padding: 0; padding: 0;
font-size: 13px; font-size: var(--md-code-size);
line-height: 1.7; line-height: 1.7;
color: var(--foreground); color: var(--foreground);
white-space: pre; white-space: pre;
@@ -892,7 +906,7 @@ button.pn-note-back:hover {
} }
.pn-note-body img { .pn-note-body img {
max-width: 100%; max-width: 100%;
border-radius: var(--radius-md); border-radius: var(--md-img-radius);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.pn-note-body hr { .pn-note-body hr {
@@ -904,7 +918,7 @@ button.pn-note-back:hover {
width: 100%; width: 100%;
margin: 24px 0; margin: 24px 0;
border-collapse: collapse; border-collapse: collapse;
font-size: 13.5px; font-size: var(--md-table-size);
} }
.pn-note-body th, .pn-note-body th,
.pn-note-body td { .pn-note-body td {
@@ -1975,6 +1989,180 @@ button.pn-ae-tool svg {
pointer-events: none; pointer-events: none;
} }
/* ============================================================
即时渲染(Typora 式)装饰层 —— 与 .pn-note-body 共用 --md-* 契约
仅 .pn-cm-live 包裹时生效;源码模式下整块不生效(§4.7 逃生舱)
============================================================ */
.pn-cm-live .cm-scroller {
font-family: var(--font-sans);
}
.pn-cm-live .cm-content {
font-size: var(--md-body-size);
line-height: var(--md-body-lh);
padding: 8px 2px 40px;
}
.pn-cm-live .cm-editor {
font-size: var(--md-body-size);
}
/* Typora 无行号、无活动行高亮(§4.6-4) */
.pn-cm-live .cm-gutters {
display: none;
}
.pn-cm-live .cm-activeLine {
background: transparent !important;
}
/* 行内:对齐 .pn-note-body(§4.3) */
.cm-md-strong {
font-weight: 600;
}
.cm-md-em {
font-style: italic;
}
.cm-md-del {
color: var(--muted-foreground);
text-decoration: line-through;
}
.cm-md-code {
font-family: var(--font-mono);
font-size: var(--md-code-size);
background: var(--muted);
border-radius: var(--radius-sm);
padding: 2px 6px;
}
.cm-md-link {
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--border-strong);
}
/* 块级:标题(§4.4) */
.cm-md-h1,
.cm-md-h2,
.cm-md-h3,
.cm-md-h4,
.cm-md-h5,
.cm-md-h6 {
font-family: var(--md-heading-font);
font-weight: 400;
line-height: var(--md-heading-lh);
letter-spacing: var(--tracking-tight);
}
.cm-md-h1 {
font-size: var(--md-h1-size);
}
.cm-md-h2 {
font-size: var(--md-h2-size);
}
.cm-md-h3 {
font-size: var(--md-h3-size);
}
.cm-md-h4 {
font-size: var(--md-h4-size);
}
.cm-md-h5 {
font-size: var(--md-body-size);
font-weight: 600;
}
.cm-md-h6 {
font-size: var(--md-body-size);
font-weight: 600;
color: var(--muted-foreground);
}
/* 块级:引用行(竖线由 widget 承接,§5.3) */
.cm-md-quote {
color: var(--muted-foreground);
}
.cm-md-quote-bar {
display: inline-block;
width: 0.6em;
height: 1.05em;
margin-right: 8px;
border-left: 2px solid var(--border-strong);
vertical-align: -0.12em;
}
/* 块级:无序列表圆点(§5.1) */
.cm-md-bullet {
display: inline-block;
width: 1.15em;
text-align: center;
color: var(--muted-foreground);
}
.cm-md-ol-mark {
color: var(--muted-foreground);
font-weight: 600;
}
/* 块级:代码围栏(§4.4) */
.cm-md-fenced {
font-family: var(--font-mono);
font-size: var(--md-code-size);
line-height: 1.7;
background: var(--muted);
padding-left: 18px;
padding-right: 18px;
}
.cm-md-fenced-first {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
padding-top: 8px;
}
.cm-md-fenced-last {
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
padding-bottom: 10px;
}
.cm-md-fence-lang {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--muted-foreground);
}
/* 块级:表格(源码对齐,决策 E5) */
.cm-md-table {
font-family: var(--font-mono);
font-size: var(--md-table-size);
line-height: 1.6;
}
.cm-md-table-mark {
color: var(--muted-foreground);
opacity: 0.55;
}
.cm-md-table-delim {
opacity: 0.38;
}
/* 块级:图片 / 水平线 / 任务框(§5.4) */
.cm-md-image {
max-width: 100%;
border-radius: var(--md-img-radius);
border: 1px solid var(--border);
vertical-align: bottom;
}
.cm-md-image-error {
display: inline-block;
min-width: 140px;
min-height: 42px;
background: var(--muted);
}
.cm-md-hr {
display: inline-block;
width: 100%;
border-top: 1px solid var(--border);
vertical-align: middle;
}
.cm-md-task {
width: 14px;
height: 14px;
margin: 0 2px 0 0;
vertical-align: -2px;
accent-color: var(--primary);
cursor: pointer;
}
/* ── 动效偏好 ─────────────────────────────────────────── */ /* ── 动效偏好 ─────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,