// 光标处展开(方案 §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() 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() 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))) }