import { Node } from "prosemirror-model"; import { StepMap } from "prosemirror-transform"; export interface MappableText { text: string; map: (from: number, to: number) => [number, number]; } const enum Split { // Ordered by "size". Smaller sized splits are absorbed into larger ones when // they are adjacent. LEAF = 1, BLOCK = 2 } export interface Delimiters { readonly block: string; readonly leaf: string; } export function mappableTextBetween( node: Node, from: number, to: number, /** * Text delimiters to use when joining sequential blocks / leafs together. * When inverse mapping at edges of separators, the mapped positions will be * biased forwards. */ delimiters: Delimiters = { block: "\n\n", leaf: " " } ): MappableText { class Scanner { private readonly textSpans: string[] = []; private readonly ranges: number[] = []; private cursor = 0; private activeSplit: Split | null = null; public deleteTo(pos: number): void { if (pos > this.cursor) { const oldSize = pos - this.cursor; this.ranges.push(this.cursor, oldSize, 0); this.cursor += oldSize; } } public copy(text: string): void { if (this.activeSplit !== null) { const splitText = this.getSplitText(this.activeSplit); this.textSpans.push(splitText); this.ranges.push(this.cursor, 0, splitText.length); this.activeSplit = null; } this.textSpans.push(text); this.cursor += text.length; } public split(split: Split) { if (this.textSpans.length > 0) { this.activeSplit = this.activeSplit !== null ? Math.max(split, this.activeSplit) : split; } } public getText() { return this.textSpans.join(""); } public getRanges() { return this.ranges; } private getSplitText(split: Split): string { switch (split) { case Split.BLOCK: return delimiters.block; case Split.LEAF: return delimiters.leaf; } } } const scanner = new Scanner(); node.nodesBetween(from, to, (node, pos) => { // It's possible to traverse starting from the end of a node that's outside // the range we want to copy. In this case we want to ensure a split is // included, so we copy an empty string. if (node.isBlock && from === pos + node.nodeSize - 1) { scanner.copy(""); } if (node.isText) { const text = node.text!; const start = Math.max(from, pos); const end = Math.min(pos + text.length, to); scanner.deleteTo(start); scanner.copy(text.slice(start - pos, end - pos)); } else if (node.isLeaf) { scanner.split(Split.LEAF); } else if (node.isBlock) { scanner.split(Split.BLOCK); } }); // Cause any trailing splits to be included. scanner.copy(""); scanner.deleteTo(node.content.size); const invertedStepMap = new StepMap(scanner.getRanges()).invert(); return { text: scanner.getText(), map: (from, to) => { const mappedFrom = invertedStepMap.map(from, 1); return [mappedFrom, to > from ? invertedStepMap.map(to, -1) : mappedFrom]; } }; }