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