/** * Where the visual engine's content sits in its Markdown (spec R-SRC-1–5, * R-SRC-11–13): positions in the editor and offsets, or (row, column), in the * host's text, both ways. * * The map is built from the baseline: each top-level node the editor still * shows unchanged has its block's exact source and place in the text. Inside * a block, the node's inline content is aligned with the block's source in * order: every text run and atom is found in the source after the previous * one, so the Markdown syntax around them (markers, delimiters, escapes, * container prefixes) is skipped rather than modelled. The one exception is * a link's closing syntax (`](destination "title")`, an autolink's `>`), * stepped over after its text so the next run is not found inside the * destination. Anything that cannot be aligned is left out, so a lookup * there yields null: a position is omitted, never misplaced (R-SRC-3). A * lookup costs one block, never the document (R-SRC-13). * * Tables are aligned by grid instead: a source row and cell index name a * visual cell. The delimiter row anchors in its header cell, and a cell an * explicit layout merges maps to the merged cell's origin (R-SRC-4). * * Offsets are in the host's text: after a byte-order mark, and with CRLF line * endings when the file has them. Rows and columns count lines of that text, * without the byte-order mark. * * Clean implementation for Lattice; spec: docs/visual-editor-spec.md. */import type { Mark, Node as PmNode } from "@tiptap/pm/model";import { TableMap } from "@tiptap/pm/tables";import { decodeNamedCharacterReference } from "decode-named-character-reference";import type { MarkdownBaseline } from "./markdown-document";import { escapeRegExp } from "../../../lib/escape-regexp"; /** How an offset that falls between aligned content resolves: only exactly, or to the next or previous aligned position. */export type Snap = "exact" | "forward" | "backward"; /** At a position where formatting changes: the next run's side, or the previous run's. */export type Bias = "after" | "before"; /** * One aligned piece of a block: editor positions `[pmFrom, pmTo]` against * source offsets `[from, to]`, block-relative. An exact piece maps every * position; `offsets`, when present, gives the source offset of each position * of a run written with escapes; otherwise only the two ends map. */type Segment = { pmFrom: number; pmTo: number; from: number; to: number; exact: boolean; offsets?: number[] }; type Block = { /** Index of the top-level node. */ child: number; /** Document position before the node. */ pos: number; node: PmNode; source: string; /** Offset of the block in the body (LF, no byte-order mark). */ bodyFrom: number; /** Offset of the block in the host's text. */ textFrom: number; textTo: number; /** Row of the block's first line, and the column it starts at. */ row: number; column: number;}; const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/;const ATX_OPEN = /^ {0,3}#{1,6}(?:[ \t]+|$)/;const FOOTNOTE_OPEN = /^ {0,3}\[\^[^\]\n]*\]:[ \t]*/; const countNewlines = (text: string, from = 0, to = text.length) => { let count = 0; for (let index = text.indexOf("\n", from); index >= 0 && index < to; index = text.indexOf("\n", index + 1)) count += 1; return count;}; const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff; const segmentCache = new WeakMap<PmNode, { source: string; paperSpans: boolean; segments: Segment[] }>(); export type Placement = Pick<Block, "bodyFrom" | "textFrom" | "textTo" | "row" | "column">;const placementCache = new WeakMap<MarkdownBaseline, Placement[]>(); /** Where each baseline block sits in the text; computed once per baseline, so edits in between cost nothing. */export function placements(baseline: MarkdownBaseline): Placement[] { const cached = placementCache.get(baseline); if (cached) return cached; const bom = baseline.envelope.bom ? 1 : 0; const { crlf } = baseline.envelope; const result: Placement[] = []; let body = 0; let row = 0; let column = 0; for (const entry of baseline.entries) { const gapLines = countNewlines(entry.gapBefore); row += gapLines; column = gapLines ? entry.gapBefore.length - entry.gapBefore.lastIndexOf("\n") - 1 : column + entry.gapBefore.length; body += entry.gapBefore.length; const lines = countNewlines(entry.source); const textFrom = bom + body + (crlf ? row : 0); result.push({ bodyFrom: body, textFrom, textTo: textFrom + entry.source.length + (crlf ? lines : 0), row, column }); body += entry.source.length; column = lines ? entry.source.length - entry.source.lastIndexOf("\n") - 1 : column + entry.source.length; row += lines; } placementCache.set(baseline, result); return result;} export class SourceMap { private readonly blocks: Block[]; private readonly byChild = new Map<number, Block>(); private readonly crlf: boolean; constructor(readonly doc: PmNode, readonly baseline: MarkdownBaseline, readonly text: string) { this.crlf = baseline.envelope.crlf; this.blocks = this.pair(); for (const block of this.blocks) this.byChild.set(block.child, block); } /** Pair each top-level node with the baseline block it still equals; place those blocks in the text. */ private pair(): Block[] { const { entries } = this.baseline; const places = placements(this.baseline); const identity = new Map<PmNode, number>(); entries.forEach((entry, index) => identity.set(entry.node, index)); const blocks: Block[] = []; let next = 0; this.doc.forEach((child, offset, index) => { let found = identity.get(child) ?? -1; if (found < next) found = next < entries.length && child.eq(entries[next]!.node) ? next : -1; if (found < next) return; next = found + 1; const place = places[found]!; blocks.push({ child: index, pos: offset, node: child, source: entries[found]!.source, ...place }); }); return blocks; } /** The block holding document position `pos`, when that top-level node is mapped. */ private blockAtPosition(pos: number): Block | null { if (pos < 0 || pos > this.doc.content.size) return null; const $pos = this.doc.resolve(pos); if ($pos.depth > 0) return this.byChild.get($pos.index(0)) ?? null; // Between top-level nodes: the start of the next one, else the end of the previous one. return this.byChild.get($pos.index(0)) ?? this.byChild.get($pos.index(0) - 1) ?? null; } /** The block whose text range holds `offset` (end-inclusive). */ private blockAtOffset(offset: number): Block | null { let low = 0; let high = this.blocks.length - 1; while (low <= high) { const middle = (low + high) >> 1; const block = this.blocks[middle]!; if (offset < block.textFrom) high = middle - 1; else if (offset > block.textTo) low = middle + 1; else return block; } return null; } /** The block holding source row `row`. */ private blockAtRow(row: number): Block | null { let low = 0; let high = this.blocks.length - 1; while (low <= high) { const middle = (low + high) >> 1; const block = this.blocks[middle]!; const lastRow = block.row + countNewlines(block.source); if (row < block.row) high = middle - 1; else if (row > lastRow) low = middle + 1; else return block; } return null; } private segments(block: Block): Segment[] { const paperSpans = Boolean(this.baseline.options.paperSpans); const cached = segmentCache.get(block.node); if (cached && cached.source === block.source && cached.paperSpans === paperSpans) return cached.segments; const segments = block.node.type.spec.tableRole === "table" ? [] : alignBlock(block.node, block.source); segmentCache.set(block.node, { source: block.source, paperSpans, segments }); return segments; } /** A body offset inside `block` as an offset of the host's text. */ private textOffset(block: Block, relative: number): number { return block.textFrom + relative + (this.crlf ? countNewlines(block.source, 0, relative) : 0); } /** A text offset inside `block` as an offset into its source; mid-CRLF resolves to the line end. */ private relativeOffset(block: Block, offset: number): number { const delta = offset - block.textFrom; if (!this.crlf) return delta; let text = 0; let relative = 0; while (text < delta && relative < block.source.length) { text += block.source.charCodeAt(relative) === 10 ? 2 : 1; if (text > delta) break; relative += 1; } return relative; } /** * The text offset of document position `pos`, or null where it has no * exact place in the Markdown. Where formatting changes at `pos`, the offset * is before the next run's markup (`after`, for a caret or a range start) * or after the previous run's text (`before`, for a range end). */ positionToOffset(pos: number, bias: Bias = "after"): number | null { const block = this.blockAtPosition(pos); if (!block) return null; const relative = pos - block.pos; if (relative === 0) return block.textFrom; if (relative === block.node.nodeSize) return block.textTo; const source = block.node.type.spec.tableRole === "table" ? tablePositionToSource(block.node, block.source, relative) : positionInSegments(this.segments(block), relative, bias); return source == null ? null : this.textOffset(block, source); } /** The (row, column) of document position `pos`, or null where it has none. */ positionToRowColumn(pos: number): [number, number] | null { const block = this.blockAtPosition(pos); if (!block) return null; const relative = pos - block.pos; const source = relative === 0 ? 0 : relative === block.node.nodeSize ? block.source.length : block.node.type.spec.tableRole === "table" ? tablePositionToSource(block.node, block.source, relative) : positionInSegments(this.segments(block), relative); if (source == null) return null; const lineStart = block.source.lastIndexOf("\n", source - 1); const row = block.row + countNewlines(block.source, 0, source); return [row, lineStart >= 0 ? source - lineStart - 1 : block.column + source]; } /** The document position at text offset `offset`, or null where nothing shown sits there. */ offsetToPosition(offset: number, snap: Snap = "exact"): number | null { const block = this.blockAtOffset(offset); if (!block) return null; return this.relativeToPosition(block, this.relativeOffset(block, offset), snap); } /** The document position at source (row, column), or null where nothing shown sits there. */ rowColumnToPosition(row: number, column: number): number | null { const block = this.blockAtRow(row); if (!block) return null; let lineStart = 0; for (let line = block.row; line < row; line += 1) lineStart = block.source.indexOf("\n", lineStart) + 1; const relative = lineStart + column - (row === block.row ? block.column : 0); const lineEnd = block.source.indexOf("\n", lineStart); if (relative < lineStart || relative > (lineEnd < 0 ? block.source.length : lineEnd)) return null; return this.relativeToPosition(block, relative, "exact"); } private relativeToPosition(block: Block, relative: number, snap: Snap): number | null { // Never between the halves of a surrogate pair. const safe = relative > 0 && isHighSurrogate(block.source.charCodeAt(relative - 1)) ? relative - 1 : relative; const inside = block.node.type.spec.tableRole === "table" ? tableSourceToPosition(block.node, block.source, safe, block.node.attrs.layout === "explicit", snap) : sourceInSegments(this.segments(block), safe, snap); return inside == null ? null : block.pos + inside; } /** The text range of the top-level node at `child`, when it is mapped. */ blockRange(child: number): { from: number; to: number; row: number } | null { const block = this.byChild.get(child); return block ? { from: block.textFrom, to: block.textTo, row: block.row } : null; } /** Every mapped top-level node, with its 1-based source line and text range (R-SRC-11). */ labels(): { pos: number; line: number; from: number; to: number }[] { return this.blocks.map((block) => ({ pos: block.pos, line: block.row + 1, from: block.textFrom, to: block.textTo })); }} /** Where each inline piece of `node` sits in `source`, block-relative on both sides. */function alignBlock(node: PmNode, source: string): Segment[] { const segments: Segment[] = []; let cursor = startCursor(node, source); const find = (piece: string, pmFrom: number, pmTo: number, exact: boolean) => { if (!piece) return; const at = source.indexOf(piece, cursor); if (at < 0) { // Inline code wrapped across lines reads its line breaks as spaces. if (exact && piece.includes(" ")) { const flexible = wrappedOffsets(piece, source, cursor); if (flexible) { segments.push({ pmFrom, pmTo, from: flexible[0]!, to: flexible[flexible.length - 1]!, exact: false, offsets: flexible }); cursor = flexible[flexible.length - 1]!; } } return; } segments.push({ pmFrom, pmTo, from: at, to: at + piece.length, exact }); cursor = at + piece.length; }; // The link the previous piece was in: its closing syntax comes before the next piece outside it. let link: Mark | null = null; const visit = (parent: PmNode, base: number) => { parent.forEach((child, offset) => { const pos = base + offset; const inLink = child.marks.find((mark) => mark.type.name === "link") ?? null; if (link && !(inLink && link.eq(inLink))) cursor = linkEnd(source, cursor, link.attrs.autolink); link = inLink; if (child.isText) { const text = child.text ?? ""; const authored = child.marks.find((mark) => mark.type.name === "latticeSource")?.attrs.source as string | undefined; if (authored != null && authored !== text) { const before = segments.length; find(authored, pos, pos + text.length, false); const segment = segments.length > before ? segments[segments.length - 1]! : null; if (segment) segment.offsets = escapedOffsets(text, authored)?.map((offset) => segment.from + offset); } else if (text.includes("\n")) { // Code and raw source: line by line, so indentation and container prefixes between lines are skipped. let lineStart = 0; for (const line of text.split("\n")) { find(line, pos + lineStart, pos + lineStart + line.length, true); lineStart += line.length + 1; } } else { find(text, pos, pos + text.length, true); } return; } if (child.isLeaf || child.isAtom) { const piece = leafSource(child); if (piece != null) find(piece, pos, pos + child.nodeSize, false); return; } // A nested block: its own syntax comes first. if (child.isBlock) cursor = Math.max(cursor, nestedStart(child, source, cursor)); visit(child, pos + 1); }); }; visit(node, 1); return segments;} /** * Where a link's source ends, from `cursor` after its text: past `>` for an * angle autolink, nowhere further for a literal one, and past * `](destination "title")` for an inline link. The end of `source` when that * syntax cannot be read, so nothing after it is misplaced. */function linkEnd(source: string, cursor: number, autolink: unknown): number { if (autolink === "literal") return cursor; if (autolink === "angle") { const close = source.indexOf(">", cursor); return close < 0 ? source.length : close + 1; } const open = source.indexOf("](", cursor); if (open < 0) return source.length; let at = open + 2; // Spaces, and a line break with the container prefix of the next line. const skipSpace = () => { while (at < source.length && /[ \t\n]/.test(source[at]!)) { at += 1; if (source[at - 1] === "\n") while (at < source.length && /[ \t>]/.test(source[at]!)) at += 1; } }; // Up to `close`, stepping over backslash escapes. const skipTo = (close: string) => { while (at < source.length && source[at] !== close) at += source[at] === "\\" ? 2 : 1; at += 1; }; skipSpace(); if (source[at] === "<") { at += 1; skipTo(">"); } else { for (let depth = 0; at < source.length; at += 1) { const char = source[at]!; if (char === "\\") at += 1; else if (/\s/.test(char) || (char === ")" && depth === 0)) break; else if (char === "(") depth += 1; else if (char === ")") depth -= 1; } } skipSpace(); const closer = ({ '"': '"', "'": "'", "(": ")" } as Record<string, string>)[source[at] ?? ""]; if (closer) { at += 1; skipTo(closer); skipSpace(); } return source[at] === ")" ? at + 1 : source.length;} /** * For a text run and the source it was written as (backslash escapes and * character references), the source offset of each position of the text; * null when the two do not line up that way. */function escapedOffsets(text: string, source: string): number[] | null { const offsets: number[] = []; let at = 0; for (let index = 0; index < text.length;) { offsets.push(at); const char = text[index]!; const reference = source[at] === "&" ? referenceAt(source, at) : null; if (reference && text.startsWith(reference.value, index)) { // A reference stands for its characters; positions inside them have no source of their own. for (let extra = 1; extra < reference.value.length; extra += 1) offsets.push(at); index += reference.value.length; at = reference.end; } else if (source[at] === char) { index += 1; at += 1; } else if (source[at] === "\\" && source[at + 1] === char) { index += 1; at += 2; } else { return null; } } offsets.push(at); return at === source.length ? offsets : null;} /** * For text whose spaces the source may write as a line break and indentation * (a wrapped code span), the source offset of each position of the text, * found from `cursor`; null when it is not there that way. */function wrappedOffsets(text: string, source: string, cursor: number): number[] | null { const pattern = text.split(" ").map(escapeRegExp).join("\\s+"); const match = new RegExp(pattern, "g"); match.lastIndex = cursor; const found = match.exec(source); if (!found) return null; const offsets: number[] = []; let at = found.index; for (const char of text) { for (let unit = 0; unit < char.length; unit += 1) offsets.push(at + unit); if (char === " ") while (/\s/.test(source[at + 1] ?? "") && at + 1 < found.index + found[0].length) at += 1; at += char.length; } offsets.push(at); return offsets;} /** The character reference (`&`, `#`, `😀`) starting at `at`, with what it stands for. */function referenceAt(source: string, at: number): { value: string; end: number } | null { const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z][A-Za-z0-9]{1,31}));/.exec(source.slice(at, at + 40)); if (!match) return null; const [whole, decimal, hex, name] = match; let value: string | false; if (name) { value = decodeNamedCharacterReference(name); } else { const code = decimal ? Number(decimal) : Number.parseInt(hex!, 16); value = code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\uFFFD"; } return value ? { value, end: at + whole.length } : null;} /** Where a top-level block's content can start in its source: after its own opening syntax. */function startCursor(node: PmNode, source: string): number { switch (node.type.name) { case "heading": return node.attrs.setext ? 0 : (ATX_OPEN.exec(source)?.[0].length ?? 0); case "codeBlock": return CODE_FENCE.test(source) ? source.indexOf("\n") + 1 || source.length : 0; case "latticeComponent": return typeof node.attrs.openTag === "string" && source.startsWith(node.attrs.openTag) ? node.attrs.openTag.length : 0; case "latticeFootnote": return FOOTNOTE_OPEN.exec(source)?.[0].length ?? 0; default: return 0; }} /** A nested block's content start, searching from `cursor`. */function nestedStart(node: PmNode, source: string, cursor: number): number { if (node.type.name === "codeBlock") { const fence = /(^|\n)[ \t>]*(`{3,}|~{3,})[^\n]*\n/g; fence.lastIndex = cursor; const match = fence.exec(source); return match && source.slice(cursor, match.index).trim() === "" ? match.index + match[0].length : cursor; } if (node.type.name === "heading" && !node.attrs.setext) { const marker = /#{1,6}(?:[ \t]+|$)/g; marker.lastIndex = cursor; const match = marker.exec(source); return match ? match.index + match[0].length : cursor; } if (node.type.name === "latticeComponent" && typeof node.attrs.openTag === "string") { const at = source.indexOf(node.attrs.openTag, cursor); return at >= 0 ? at + node.attrs.openTag.length : cursor; } return cursor;} /** The Markdown an inline atom was written as, when the engine knows it. */function leafSource(node: PmNode): string | null { const attrs = node.attrs as Record<string, unknown>; if (typeof attrs.source === "string" && attrs.source) return attrs.source; switch (node.type.name) { case "latticeSoftBreak":Lines 1–500 of 659. Load more
/** * Where the visual engine's content sits in its Markdown (spec R-SRC-1–5, * R-SRC-11–13): positions in the editor and offsets, or (row, column), in the * host's text, both ways. * * The map is built from the baseline: each top-level node the editor still * shows unchanged has its block's exact source and place in the text. Inside * a block, the node's inline content is aligned with the block's source in * order: every text run and atom is found in the source after the previous * one, so the Markdown syntax around them (markers, delimiters, escapes, * container prefixes) is skipped rather than modelled. The one exception is * a link's closing syntax (`](destination "title")`, an autolink's `>`), * stepped over after its text so the next run is not found inside the * destination. Anything that cannot be aligned is left out, so a lookup * there yields null: a position is omitted, never misplaced (R-SRC-3). A * lookup costs one block, never the document (R-SRC-13). * * Tables are aligned by grid instead: a source row and cell index name a * visual cell. The delimiter row anchors in its header cell, and a cell an * explicit layout merges maps to the merged cell's origin (R-SRC-4). * * Offsets are in the host's text: after a byte-order mark, and with CRLF line * endings when the file has them. Rows and columns count lines of that text, * without the byte-order mark. * * Clean implementation for Lattice; spec: docs/visual-editor-spec.md. */ import type { Mark, Node as PmNode } from "@tiptap/pm/model"; import { TableMap } from "@tiptap/pm/tables"; import { decodeNamedCharacterReference } from "decode-named-character-reference"; import type { MarkdownBaseline } from "./markdown-document"; import { escapeRegExp } from "../../../lib/escape-regexp"; /** How an offset that falls between aligned content resolves: only exactly, or to the next or previous aligned position. */ export type Snap = "exact" | "forward" | "backward"; /** At a position where formatting changes: the next run's side, or the previous run's. */ export type Bias = "after" | "before"; /** * One aligned piece of a block: editor positions `[pmFrom, pmTo]` against * source offsets `[from, to]`, block-relative. An exact piece maps every * position; `offsets`, when present, gives the source offset of each position * of a run written with escapes; otherwise only the two ends map. */ type Segment = { pmFrom: number; pmTo: number; from: number; to: number; exact: boolean; offsets?: number[] }; type Block = { /** Index of the top-level node. */ child: number; /** Document position before the node. */ pos: number; node: PmNode; source: string; /** Offset of the block in the body (LF, no byte-order mark). */ bodyFrom: number; /** Offset of the block in the host's text. */ textFrom: number; textTo: number; /** Row of the block's first line, and the column it starts at. */ row: number; column: number; }; const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/; const ATX_OPEN = /^ {0,3}#{1,6}(?:[ \t]+|$)/; const FOOTNOTE_OPEN = /^ {0,3}\[\^[^\]\n]*\]:[ \t]*/; const countNewlines = (text: string, from = 0, to = text.length) => { let count = 0; for (let index = text.indexOf("\n", from); index >= 0 && index < to; index = text.indexOf("\n", index + 1)) count += 1; return count; }; const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff; const segmentCache = new WeakMap<PmNode, { source: string; paperSpans: boolean; segments: Segment[] }>(); export type Placement = Pick<Block, "bodyFrom" | "textFrom" | "textTo" | "row" | "column">; const placementCache = new WeakMap<MarkdownBaseline, Placement[]>(); /** Where each baseline block sits in the text; computed once per baseline, so edits in between cost nothing. */ export function placements(baseline: MarkdownBaseline): Placement[] { const cached = placementCache.get(baseline); if (cached) return cached; const bom = baseline.envelope.bom ? 1 : 0; const { crlf } = baseline.envelope; const result: Placement[] = []; let body = 0; let row = 0; let column = 0; for (const entry of baseline.entries) { const gapLines = countNewlines(entry.gapBefore); row += gapLines; column = gapLines ? entry.gapBefore.length - entry.gapBefore.lastIndexOf("\n") - 1 : column + entry.gapBefore.length; body += entry.gapBefore.length; const lines = countNewlines(entry.source); const textFrom = bom + body + (crlf ? row : 0); result.push({ bodyFrom: body, textFrom, textTo: textFrom + entry.source.length + (crlf ? lines : 0), row, column }); body += entry.source.length; column = lines ? entry.source.length - entry.source.lastIndexOf("\n") - 1 : column + entry.source.length; row += lines; } placementCache.set(baseline, result); return result; } export class SourceMap { private readonly blocks: Block[]; private readonly byChild = new Map<number, Block>(); private readonly crlf: boolean; constructor(readonly doc: PmNode, readonly baseline: MarkdownBaseline, readonly text: string) { this.crlf = baseline.envelope.crlf; this.blocks = this.pair(); for (const block of this.blocks) this.byChild.set(block.child, block); } /** Pair each top-level node with the baseline block it still equals; place those blocks in the text. */ private pair(): Block[] { const { entries } = this.baseline; const places = placements(this.baseline); const identity = new Map<PmNode, number>(); entries.forEach((entry, index) => identity.set(entry.node, index)); const blocks: Block[] = []; let next = 0; this.doc.forEach((child, offset, index) => { let found = identity.get(child) ?? -1; if (found < next) found = next < entries.length && child.eq(entries[next]!.node) ? next : -1; if (found < next) return; next = found + 1; const place = places[found]!; blocks.push({ child: index, pos: offset, node: child, source: entries[found]!.source, ...place }); }); return blocks; } /** The block holding document position `pos`, when that top-level node is mapped. */ private blockAtPosition(pos: number): Block | null { if (pos < 0 || pos > this.doc.content.size) return null; const $pos = this.doc.resolve(pos); if ($pos.depth > 0) return this.byChild.get($pos.index(0)) ?? null; // Between top-level nodes: the start of the next one, else the end of the previous one. return this.byChild.get($pos.index(0)) ?? this.byChild.get($pos.index(0) - 1) ?? null; } /** The block whose text range holds `offset` (end-inclusive). */ private blockAtOffset(offset: number): Block | null { let low = 0; let high = this.blocks.length - 1; while (low <= high) { const middle = (low + high) >> 1; const block = this.blocks[middle]!; if (offset < block.textFrom) high = middle - 1; else if (offset > block.textTo) low = middle + 1; else return block; } return null; } /** The block holding source row `row`. */ private blockAtRow(row: number): Block | null { let low = 0; let high = this.blocks.length - 1; while (low <= high) { const middle = (low + high) >> 1; const block = this.blocks[middle]!; const lastRow = block.row + countNewlines(block.source); if (row < block.row) high = middle - 1; else if (row > lastRow) low = middle + 1; else return block; } return null; } private segments(block: Block): Segment[] { const paperSpans = Boolean(this.baseline.options.paperSpans); const cached = segmentCache.get(block.node); if (cached && cached.source === block.source && cached.paperSpans === paperSpans) return cached.segments; const segments = block.node.type.spec.tableRole === "table" ? [] : alignBlock(block.node, block.source); segmentCache.set(block.node, { source: block.source, paperSpans, segments }); return segments; } /** A body offset inside `block` as an offset of the host's text. */ private textOffset(block: Block, relative: number): number { return block.textFrom + relative + (this.crlf ? countNewlines(block.source, 0, relative) : 0); } /** A text offset inside `block` as an offset into its source; mid-CRLF resolves to the line end. */ private relativeOffset(block: Block, offset: number): number { const delta = offset - block.textFrom; if (!this.crlf) return delta; let text = 0; let relative = 0; while (text < delta && relative < block.source.length) { text += block.source.charCodeAt(relative) === 10 ? 2 : 1; if (text > delta) break; relative += 1; } return relative; } /** * The text offset of document position `pos`, or null where it has no * exact place in the Markdown. Where formatting changes at `pos`, the offset * is before the next run's markup (`after`, for a caret or a range start) * or after the previous run's text (`before`, for a range end). */ positionToOffset(pos: number, bias: Bias = "after"): number | null { const block = this.blockAtPosition(pos); if (!block) return null; const relative = pos - block.pos; if (relative === 0) return block.textFrom; if (relative === block.node.nodeSize) return block.textTo; const source = block.node.type.spec.tableRole === "table" ? tablePositionToSource(block.node, block.source, relative) : positionInSegments(this.segments(block), relative, bias); return source == null ? null : this.textOffset(block, source); } /** The (row, column) of document position `pos`, or null where it has none. */ positionToRowColumn(pos: number): [number, number] | null { const block = this.blockAtPosition(pos); if (!block) return null; const relative = pos - block.pos; const source = relative === 0 ? 0 : relative === block.node.nodeSize ? block.source.length : block.node.type.spec.tableRole === "table" ? tablePositionToSource(block.node, block.source, relative) : positionInSegments(this.segments(block), relative); if (source == null) return null; const lineStart = block.source.lastIndexOf("\n", source - 1); const row = block.row + countNewlines(block.source, 0, source); return [row, lineStart >= 0 ? source - lineStart - 1 : block.column + source]; } /** The document position at text offset `offset`, or null where nothing shown sits there. */ offsetToPosition(offset: number, snap: Snap = "exact"): number | null { const block = this.blockAtOffset(offset); if (!block) return null; return this.relativeToPosition(block, this.relativeOffset(block, offset), snap); } /** The document position at source (row, column), or null where nothing shown sits there. */ rowColumnToPosition(row: number, column: number): number | null { const block = this.blockAtRow(row); if (!block) return null; let lineStart = 0; for (let line = block.row; line < row; line += 1) lineStart = block.source.indexOf("\n", lineStart) + 1; const relative = lineStart + column - (row === block.row ? block.column : 0); const lineEnd = block.source.indexOf("\n", lineStart); if (relative < lineStart || relative > (lineEnd < 0 ? block.source.length : lineEnd)) return null; return this.relativeToPosition(block, relative, "exact"); } private relativeToPosition(block: Block, relative: number, snap: Snap): number | null { // Never between the halves of a surrogate pair. const safe = relative > 0 && isHighSurrogate(block.source.charCodeAt(relative - 1)) ? relative - 1 : relative; const inside = block.node.type.spec.tableRole === "table" ? tableSourceToPosition(block.node, block.source, safe, block.node.attrs.layout === "explicit", snap) : sourceInSegments(this.segments(block), safe, snap); return inside == null ? null : block.pos + inside; } /** The text range of the top-level node at `child`, when it is mapped. */ blockRange(child: number): { from: number; to: number; row: number } | null { const block = this.byChild.get(child); return block ? { from: block.textFrom, to: block.textTo, row: block.row } : null; } /** Every mapped top-level node, with its 1-based source line and text range (R-SRC-11). */ labels(): { pos: number; line: number; from: number; to: number }[] { return this.blocks.map((block) => ({ pos: block.pos, line: block.row + 1, from: block.textFrom, to: block.textTo })); } } /** Where each inline piece of `node` sits in `source`, block-relative on both sides. */ function alignBlock(node: PmNode, source: string): Segment[] { const segments: Segment[] = []; let cursor = startCursor(node, source); const find = (piece: string, pmFrom: number, pmTo: number, exact: boolean) => { if (!piece) return; const at = source.indexOf(piece, cursor); if (at < 0) { // Inline code wrapped across lines reads its line breaks as spaces. if (exact && piece.includes(" ")) { const flexible = wrappedOffsets(piece, source, cursor); if (flexible) { segments.push({ pmFrom, pmTo, from: flexible[0]!, to: flexible[flexible.length - 1]!, exact: false, offsets: flexible }); cursor = flexible[flexible.length - 1]!; } } return; } segments.push({ pmFrom, pmTo, from: at, to: at + piece.length, exact }); cursor = at + piece.length; }; // The link the previous piece was in: its closing syntax comes before the next piece outside it. let link: Mark | null = null; const visit = (parent: PmNode, base: number) => { parent.forEach((child, offset) => { const pos = base + offset; const inLink = child.marks.find((mark) => mark.type.name === "link") ?? null; if (link && !(inLink && link.eq(inLink))) cursor = linkEnd(source, cursor, link.attrs.autolink); link = inLink; if (child.isText) { const text = child.text ?? ""; const authored = child.marks.find((mark) => mark.type.name === "latticeSource")?.attrs.source as string | undefined; if (authored != null && authored !== text) { const before = segments.length; find(authored, pos, pos + text.length, false); const segment = segments.length > before ? segments[segments.length - 1]! : null; if (segment) segment.offsets = escapedOffsets(text, authored)?.map((offset) => segment.from + offset); } else if (text.includes("\n")) { // Code and raw source: line by line, so indentation and container prefixes between lines are skipped. let lineStart = 0; for (const line of text.split("\n")) { find(line, pos + lineStart, pos + lineStart + line.length, true); lineStart += line.length + 1; } } else { find(text, pos, pos + text.length, true); } return; } if (child.isLeaf || child.isAtom) { const piece = leafSource(child); if (piece != null) find(piece, pos, pos + child.nodeSize, false); return; } // A nested block: its own syntax comes first. if (child.isBlock) cursor = Math.max(cursor, nestedStart(child, source, cursor)); visit(child, pos + 1); }); }; visit(node, 1); return segments; } /** * Where a link's source ends, from `cursor` after its text: past `>` for an * angle autolink, nowhere further for a literal one, and past * `](destination "title")` for an inline link. The end of `source` when that * syntax cannot be read, so nothing after it is misplaced. */ function linkEnd(source: string, cursor: number, autolink: unknown): number { if (autolink === "literal") return cursor; if (autolink === "angle") { const close = source.indexOf(">", cursor); return close < 0 ? source.length : close + 1; } const open = source.indexOf("](", cursor); if (open < 0) return source.length; let at = open + 2; // Spaces, and a line break with the container prefix of the next line. const skipSpace = () => { while (at < source.length && /[ \t\n]/.test(source[at]!)) { at += 1; if (source[at - 1] === "\n") while (at < source.length && /[ \t>]/.test(source[at]!)) at += 1; } }; // Up to `close`, stepping over backslash escapes. const skipTo = (close: string) => { while (at < source.length && source[at] !== close) at += source[at] === "\\" ? 2 : 1; at += 1; }; skipSpace(); if (source[at] === "<") { at += 1; skipTo(">"); } else { for (let depth = 0; at < source.length; at += 1) { const char = source[at]!; if (char === "\\") at += 1; else if (/\s/.test(char) || (char === ")" && depth === 0)) break; else if (char === "(") depth += 1; else if (char === ")") depth -= 1; } } skipSpace(); const closer = ({ '"': '"', "'": "'", "(": ")" } as Record<string, string>)[source[at] ?? ""]; if (closer) { at += 1; skipTo(closer); skipSpace(); } return source[at] === ")" ? at + 1 : source.length; } /** * For a text run and the source it was written as (backslash escapes and * character references), the source offset of each position of the text; * null when the two do not line up that way. */ function escapedOffsets(text: string, source: string): number[] | null { const offsets: number[] = []; let at = 0; for (let index = 0; index < text.length;) { offsets.push(at); const char = text[index]!; const reference = source[at] === "&" ? referenceAt(source, at) : null; if (reference && text.startsWith(reference.value, index)) { // A reference stands for its characters; positions inside them have no source of their own. for (let extra = 1; extra < reference.value.length; extra += 1) offsets.push(at); index += reference.value.length; at = reference.end; } else if (source[at] === char) { index += 1; at += 1; } else if (source[at] === "\\" && source[at + 1] === char) { index += 1; at += 2; } else { return null; } } offsets.push(at); return at === source.length ? offsets : null; } /** * For text whose spaces the source may write as a line break and indentation * (a wrapped code span), the source offset of each position of the text, * found from `cursor`; null when it is not there that way. */ function wrappedOffsets(text: string, source: string, cursor: number): number[] | null { const pattern = text.split(" ").map(escapeRegExp).join("\\s+"); const match = new RegExp(pattern, "g"); match.lastIndex = cursor; const found = match.exec(source); if (!found) return null; const offsets: number[] = []; let at = found.index; for (const char of text) { for (let unit = 0; unit < char.length; unit += 1) offsets.push(at + unit); if (char === " ") while (/\s/.test(source[at + 1] ?? "") && at + 1 < found.index + found[0].length) at += 1; at += char.length; } offsets.push(at); return offsets; } /** The character reference (`&`, `#`, `😀`) starting at `at`, with what it stands for. */ function referenceAt(source: string, at: number): { value: string; end: number } | null { const match = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z][A-Za-z0-9]{1,31}));/.exec(source.slice(at, at + 40)); if (!match) return null; const [whole, decimal, hex, name] = match; let value: string | false; if (name) { value = decodeNamedCharacterReference(name); } else { const code = decimal ? Number(decimal) : Number.parseInt(hex!, 16); value = code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\uFFFD"; } return value ? { value, end: at + whole.length } : null; } /** Where a top-level block's content can start in its source: after its own opening syntax. */ function startCursor(node: PmNode, source: string): number { switch (node.type.name) { case "heading": return node.attrs.setext ? 0 : (ATX_OPEN.exec(source)?.[0].length ?? 0); case "codeBlock": return CODE_FENCE.test(source) ? source.indexOf("\n") + 1 || source.length : 0; case "latticeComponent": return typeof node.attrs.openTag === "string" && source.startsWith(node.attrs.openTag) ? node.attrs.openTag.length : 0; case "latticeFootnote": return FOOTNOTE_OPEN.exec(source)?.[0].length ?? 0; default: return 0; } } /** A nested block's content start, searching from `cursor`. */ function nestedStart(node: PmNode, source: string, cursor: number): number { if (node.type.name === "codeBlock") { const fence = /(^|\n)[ \t>]*(`{3,}|~{3,})[^\n]*\n/g; fence.lastIndex = cursor; const match = fence.exec(source); return match && source.slice(cursor, match.index).trim() === "" ? match.index + match[0].length : cursor; } if (node.type.name === "heading" && !node.attrs.setext) { const marker = /#{1,6}(?:[ \t]+|$)/g; marker.lastIndex = cursor; const match = marker.exec(source); return match ? match.index + match[0].length : cursor; } if (node.type.name === "latticeComponent" && typeof node.attrs.openTag === "string") { const at = source.indexOf(node.attrs.openTag, cursor); return at >= 0 ? at + node.attrs.openTag.length : cursor; } return cursor; } /** The Markdown an inline atom was written as, when the engine knows it. */ function leafSource(node: PmNode): string | null { const attrs = node.attrs as Record<string, unknown>; if (typeof attrs.source === "string" && attrs.source) return attrs.source; switch (node.type.name) { case "latticeSoftBreak":