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:
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user