You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
611 lines
20 KiB
611 lines
20 KiB
/**
|
|
* V2 path / inline / selection walkers.
|
|
*
|
|
* Pure functions that walk a `WordsDocument` tree by path. Mirror of
|
|
* V1's `path.ts` algorithms, adjusted for V2-specific differences:
|
|
*
|
|
* - `list.children` → `list.items`
|
|
* - `table.children` → `table.rows`
|
|
* - `table-row.children` → `table-row.cells`
|
|
* - new types `divider` (no children) and `callout` (block children)
|
|
*
|
|
* These helpers are the foundation for every V2 text-editing operation.
|
|
* They don't mutate, don't normalize, don't validate — just walk and
|
|
* (immutably) reshape.
|
|
*/
|
|
|
|
import type { WordsPath } from '../path';
|
|
import type { WordsPoint } from '../selection';
|
|
import type {
|
|
WordsBlock,
|
|
CalloutBlock,
|
|
CodeBlock,
|
|
Column,
|
|
ColumnsBlock,
|
|
WordsDocument,
|
|
HeadingBlock,
|
|
WordsInline,
|
|
WordsLink,
|
|
ListBlock,
|
|
ListItem,
|
|
ParagraphBlock,
|
|
QuoteBlock,
|
|
TableBlock,
|
|
TableCell,
|
|
TableRow,
|
|
WordsText
|
|
} from '../types';
|
|
|
|
// ── Node union (V2-flavoured) ─────────────────────────────────────────────
|
|
|
|
export type WordsNode =
|
|
| WordsDocument
|
|
| WordsBlock
|
|
| Column
|
|
| ListItem
|
|
| TableRow
|
|
| TableCell
|
|
| WordsInline;
|
|
|
|
// ── Type guards ───────────────────────────────────────────────────────────
|
|
|
|
function isDocument(node: unknown): node is WordsDocument {
|
|
return typeof node === 'object' && node !== null && 'version' in node && 'children' in node;
|
|
}
|
|
|
|
function isInlineContainerBlock(
|
|
node: WordsNode
|
|
): node is
|
|
| ParagraphBlock
|
|
| HeadingBlock
|
|
| QuoteBlock
|
|
| CodeBlock
|
|
| ListItem
|
|
| TableCell {
|
|
if (!('type' in node)) return false;
|
|
return (
|
|
node.type === 'paragraph' ||
|
|
node.type === 'heading' ||
|
|
node.type === 'quote' ||
|
|
node.type === 'code' ||
|
|
// list-item is shape-typed; doesn't carry a `type` discriminator
|
|
// in the V2 model. Detect by children + checked / indent absence.
|
|
(!('type' in node) && 'children' in node) ||
|
|
// table-cell — also lacks discriminator; detect by colspan/rowspan
|
|
// fields. Since both list-item and table-cell are children-bearing
|
|
// without a `type` field, we identify them positionally (callers
|
|
// know the parent type).
|
|
false
|
|
);
|
|
}
|
|
|
|
// ── Path traversal ────────────────────────────────────────────────────────
|
|
|
|
export function pathParent(path: WordsPath): WordsPath {
|
|
return path.slice(0, -1);
|
|
}
|
|
|
|
export function pathLast(path: WordsPath): number {
|
|
const last = path.at(-1);
|
|
if (last === undefined) {
|
|
throw new Error('words:v2:path:empty');
|
|
}
|
|
return last;
|
|
}
|
|
|
|
export function replaceAt<T>(items: readonly T[], index: number, value: T): readonly T[] {
|
|
return [...items.slice(0, index), value, ...items.slice(index + 1)];
|
|
}
|
|
|
|
/**
|
|
* Walks `path` from the document root and returns the node at that
|
|
* position, or `undefined` if any path segment is out of bounds /
|
|
* invalid for the visited node type.
|
|
*
|
|
* Path semantics per node type:
|
|
* - document → children[i] → block
|
|
* - paragraph/heading/quote/code → children[i] → inline
|
|
* - list → items[i] → list-item
|
|
* - list-item → children[i] → inline
|
|
* - table → rows[i] → table-row
|
|
* - table-row → cells[i] → table-cell
|
|
* - table-cell → children[i] → inline
|
|
* - callout → children[i] → block (recursive)
|
|
* - columns → columns[i] → column
|
|
* - column → children[i] → block (recursive)
|
|
* - link → children[i] → text inline
|
|
* - image / divider → (no children)
|
|
*/
|
|
export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode | undefined {
|
|
if (path.length === 0) return doc;
|
|
let node: WordsNode = doc;
|
|
|
|
for (const index of path) {
|
|
if (!Number.isInteger(index) || index < 0) return undefined;
|
|
|
|
if (isDocument(node)) {
|
|
const child: WordsBlock | undefined = node.children[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
// All other node types have a `type` discriminator EXCEPT
|
|
// list-item and table-cell + table-row (V2 dropped explicit
|
|
// type tags on those). We disambiguate using the field shape.
|
|
const t = 'type' in node ? (node.type as string) : undefined;
|
|
|
|
if (t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code') {
|
|
const child: WordsInline | undefined =
|
|
(node as { children: readonly WordsInline[] }).children[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if (t === 'list') {
|
|
const child: ListItem | undefined = (node as ListBlock).items[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if (t === 'table') {
|
|
const child: TableRow | undefined = (node as TableBlock).rows[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if (t === 'callout') {
|
|
const child: WordsBlock | undefined = (node as CalloutBlock).children[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if (t === 'columns') {
|
|
const child: Column | undefined = (node as ColumnsBlock).columns[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if (t === 'link') {
|
|
const child: WordsText | undefined = (node as WordsLink).children[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
// No `type` discriminator → list-item, table-row, table-cell, or column.
|
|
// They use either `children` or `cells`.
|
|
if ('cells' in node) {
|
|
const child: TableCell | undefined = (node as TableRow).cells[index];
|
|
if (!child) return undefined;
|
|
node = child;
|
|
continue;
|
|
}
|
|
|
|
if ('children' in node) {
|
|
// list-item / table-cell carry `children: readonly WordsInline[]`;
|
|
// column carries `children: readonly WordsBlock[]`. Both indices
|
|
// are accessed positionally — the actual element type is what
|
|
// matters to downstream callers, not the static union.
|
|
const child = (
|
|
node as { children: readonly (WordsInline | WordsBlock)[] }
|
|
).children[index];
|
|
if (!child) return undefined;
|
|
node = child as WordsNode;
|
|
continue;
|
|
}
|
|
|
|
// image / divider / unknown → no descendants
|
|
return undefined;
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Returns the text inline at `path` (when the node is one). Otherwise
|
|
* undefined.
|
|
*/
|
|
export function resolveTextNode(
|
|
doc: WordsDocument,
|
|
path: WordsPath
|
|
): WordsText | undefined {
|
|
const node = getNodeAtPath(doc, path);
|
|
if (!node || !('type' in node) || node.type !== 'text') return undefined;
|
|
return node as WordsText;
|
|
}
|
|
|
|
/**
|
|
* Returns the inlines hosted at `path`'s node, when the node is an
|
|
* inline container (paragraph / heading / quote / code / list-item /
|
|
* table-cell / link). Otherwise undefined.
|
|
*/
|
|
export function getInlineChildrenAtPath(
|
|
doc: WordsDocument,
|
|
path: WordsPath
|
|
): readonly WordsInline[] | undefined {
|
|
const node = getNodeAtPath(doc, path);
|
|
if (!node || isDocument(node)) return undefined;
|
|
if ('type' in node) {
|
|
const t = node.type;
|
|
if (
|
|
t === 'paragraph' ||
|
|
t === 'heading' ||
|
|
t === 'quote' ||
|
|
t === 'code' ||
|
|
t === 'link'
|
|
) {
|
|
return (node as { children: readonly WordsInline[] }).children;
|
|
}
|
|
return undefined;
|
|
}
|
|
// list-item or table-cell (no `type` discriminator in V2)
|
|
if ('cells' in node) return undefined; // table-row
|
|
return (node as { children: readonly WordsInline[] }).children;
|
|
}
|
|
|
|
// ── Immutable updates ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Applies `updater` to the node at `path`. Other nodes are preserved
|
|
* structurally (cheap reference equality where possible). The returned
|
|
* document is a NEW reference if anything changed; same reference if
|
|
* the updater returned the same node by reference.
|
|
*/
|
|
export function updateNodeAtPath(
|
|
doc: WordsDocument,
|
|
path: WordsPath,
|
|
updater: (node: WordsNode) => WordsNode
|
|
): WordsDocument {
|
|
const updated = updateNode(doc, path, 0, updater);
|
|
return isDocument(updated) ? updated : doc;
|
|
}
|
|
|
|
function updateNode(
|
|
node: WordsNode,
|
|
path: WordsPath,
|
|
depth: number,
|
|
updater: (node: WordsNode) => WordsNode
|
|
): WordsNode {
|
|
if (depth === path.length) return updater(node);
|
|
const index = path[depth];
|
|
if (!Number.isInteger(index) || index < 0) return node;
|
|
|
|
if (isDocument(node)) {
|
|
const child: WordsBlock | undefined = node.children[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return { ...node, children: replaceAt(node.children, index, newChild as WordsBlock) };
|
|
}
|
|
|
|
if ('type' in node) {
|
|
const t = node.type;
|
|
if (t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code') {
|
|
const block = node as { children: readonly WordsInline[] };
|
|
const child: WordsInline | undefined = block.children[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return {
|
|
...(node as object),
|
|
children: replaceAt(block.children, index, newChild as WordsInline)
|
|
} as WordsNode;
|
|
}
|
|
if (t === 'list') {
|
|
const list = node as ListBlock;
|
|
const child: ListItem | undefined = list.items[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return { ...list, items: replaceAt(list.items, index, newChild as ListItem) };
|
|
}
|
|
if (t === 'table') {
|
|
const table = node as TableBlock;
|
|
const child: TableRow | undefined = table.rows[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return { ...table, rows: replaceAt(table.rows, index, newChild as TableRow) };
|
|
}
|
|
if (t === 'callout') {
|
|
const callout = node as CalloutBlock;
|
|
const child: WordsBlock | undefined = callout.children[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return {
|
|
...callout,
|
|
children: replaceAt(callout.children, index, newChild as WordsBlock)
|
|
};
|
|
}
|
|
if (t === 'columns') {
|
|
const columns = node as ColumnsBlock;
|
|
const child: Column | undefined = columns.columns[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return {
|
|
...columns,
|
|
columns: replaceAt(columns.columns, index, newChild as Column)
|
|
};
|
|
}
|
|
if (t === 'link') {
|
|
const link = node as WordsLink;
|
|
const child: WordsText | undefined = link.children[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return {
|
|
...link,
|
|
children: replaceAt(link.children, index, newChild as WordsText)
|
|
};
|
|
}
|
|
// image, divider, text → no descendants
|
|
return node;
|
|
}
|
|
|
|
// No `type` discriminator: list-item / table-row / table-cell
|
|
if ('cells' in node) {
|
|
// table-row
|
|
const row = node as TableRow;
|
|
const child: TableCell | undefined = row.cells[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return { ...row, cells: replaceAt(row.cells, index, newChild as TableCell) };
|
|
}
|
|
if ('children' in node) {
|
|
// list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
|
|
// Both are accessed positionally — the children array element type is
|
|
// what the caller already knows from the path it walked.
|
|
const container = node as { children: readonly (WordsInline | WordsBlock)[] };
|
|
const child = container.children[index];
|
|
if (!child) return node;
|
|
const newChild = updateNode(child as WordsNode, path, depth + 1, updater);
|
|
if (newChild === child) return node;
|
|
return {
|
|
...container,
|
|
children: replaceAt(container.children, index, newChild as WordsInline | WordsBlock)
|
|
} as WordsNode;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
export function setInlineChildrenAtPath(
|
|
doc: WordsDocument,
|
|
path: WordsPath,
|
|
children: readonly WordsInline[]
|
|
): WordsDocument {
|
|
return updateNodeAtPath(doc, path, (node) => {
|
|
if (isDocument(node)) return node;
|
|
if ('type' in node) {
|
|
const t = node.type;
|
|
if (t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code') {
|
|
return { ...(node as object), children } as WordsNode;
|
|
}
|
|
if (t === 'link') {
|
|
// link children must be WordsText[]
|
|
const textsOnly = children.filter((c): c is WordsText => c.type === 'text');
|
|
return { ...(node as WordsLink), children: textsOnly };
|
|
}
|
|
return node;
|
|
}
|
|
// list-item or table-cell
|
|
if ('cells' in node) return node;
|
|
return { ...(node as object), children } as WordsNode;
|
|
});
|
|
}
|
|
|
|
export function replaceInlineAtPath(
|
|
doc: WordsDocument,
|
|
path: WordsPath,
|
|
replacement: readonly WordsInline[]
|
|
): WordsDocument {
|
|
const parent = pathParent(path);
|
|
const index = pathLast(path);
|
|
const children = getInlineChildrenAtPath(doc, parent);
|
|
if (!children || index < 0 || index >= children.length) return doc;
|
|
return setInlineChildrenAtPath(doc, parent, [
|
|
...children.slice(0, index),
|
|
...replacement,
|
|
...children.slice(index + 1)
|
|
]);
|
|
}
|
|
|
|
// ── Inline math (offsets within inline container) ────────────────────────
|
|
|
|
export function inlineTextLength(inlines: readonly WordsInline[]): number {
|
|
let total = 0;
|
|
for (const inline of inlines) {
|
|
if (inline.type === 'text') {
|
|
total += inline.text.length;
|
|
} else if (inline.type === 'link') {
|
|
total += inlineTextLength(inline.children);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Given a `point` (path + offset) inside an inline container, returns
|
|
* the absolute character offset within the container's flattened
|
|
* text. Used to translate selection positions across structural
|
|
* changes.
|
|
*/
|
|
export function textOffsetInInlineContainer(
|
|
doc: WordsDocument,
|
|
containerPath: WordsPath,
|
|
point: WordsPoint
|
|
): number {
|
|
const children = getInlineChildrenAtPath(doc, containerPath) ?? [];
|
|
let offset = 0;
|
|
// Walk inline children up to the point's parent inline.
|
|
const inlineIndex = point.path[containerPath.length];
|
|
if (inlineIndex === undefined) return 0;
|
|
for (let i = 0; i < inlineIndex; i++) {
|
|
const inline = children[i];
|
|
if (!inline) break;
|
|
if (inline.type === 'text') offset += inline.text.length;
|
|
else if (inline.type === 'link') offset += inlineTextLength(inline.children);
|
|
}
|
|
// Add offset within the point's own inline (or its link descendant).
|
|
const own = children[inlineIndex];
|
|
if (!own) return offset;
|
|
if (own.type === 'text') {
|
|
return offset + Math.min(point.offset, own.text.length);
|
|
}
|
|
if (own.type === 'link') {
|
|
// point.path goes deeper into the link; descend.
|
|
const linkChildIndex = point.path[containerPath.length + 1] ?? 0;
|
|
for (let j = 0; j < linkChildIndex; j++) {
|
|
const lc = own.children[j];
|
|
if (lc?.type === 'text') offset += lc.text.length;
|
|
}
|
|
const lastChild = own.children[linkChildIndex];
|
|
if (lastChild?.type === 'text') {
|
|
return offset + Math.min(point.offset, lastChild.text.length);
|
|
}
|
|
return offset;
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
/**
|
|
* Given an absolute character offset within an inline container,
|
|
* returns the corresponding `(path, offset)` point. Walks left-to-
|
|
* right consuming character counts.
|
|
*/
|
|
export function pointFromInlineTextOffset(
|
|
doc: WordsDocument,
|
|
containerPath: WordsPath,
|
|
targetOffset: number
|
|
): WordsPoint {
|
|
const children = getInlineChildrenAtPath(doc, containerPath) ?? [];
|
|
let remaining = Math.max(0, targetOffset);
|
|
for (let i = 0; i < children.length; i++) {
|
|
const inline = children[i];
|
|
if (inline.type === 'text') {
|
|
if (remaining <= inline.text.length) {
|
|
return { path: [...containerPath, i], offset: remaining };
|
|
}
|
|
remaining -= inline.text.length;
|
|
} else if (inline.type === 'link') {
|
|
const linkLen = inlineTextLength(inline.children);
|
|
if (remaining <= linkLen) {
|
|
// Descend into the link.
|
|
for (let j = 0; j < inline.children.length; j++) {
|
|
const lc = inline.children[j];
|
|
if (lc.type !== 'text') continue;
|
|
if (remaining <= lc.text.length) {
|
|
return { path: [...containerPath, i, j], offset: remaining };
|
|
}
|
|
remaining -= lc.text.length;
|
|
}
|
|
return { path: [...containerPath, i], offset: linkLen };
|
|
}
|
|
remaining -= linkLen;
|
|
}
|
|
}
|
|
// Past the end → clamp to end of container.
|
|
if (children.length === 0) {
|
|
return { path: [...containerPath, 0], offset: 0 };
|
|
}
|
|
const lastIndex = children.length - 1;
|
|
const last = children[lastIndex];
|
|
if (last.type === 'text') {
|
|
return { path: [...containerPath, lastIndex], offset: last.text.length };
|
|
}
|
|
if (last.type === 'link') {
|
|
const lastChildIndex = last.children.length - 1;
|
|
const lastChild = last.children[lastChildIndex];
|
|
if (lastChild?.type === 'text') {
|
|
return {
|
|
path: [...containerPath, lastIndex, lastChildIndex],
|
|
offset: lastChild.text.length
|
|
};
|
|
}
|
|
}
|
|
return { path: [...containerPath, lastIndex], offset: 0 };
|
|
}
|
|
|
|
/**
|
|
* Returns the innermost inline-container path that contains the given
|
|
* `point`. Used by ops that need to find the "edit context" of the
|
|
* caret (the paragraph or list-item or table-cell holding it).
|
|
*/
|
|
export function editingContainerPath(
|
|
doc: WordsDocument,
|
|
pointPath: WordsPath
|
|
): WordsPath | undefined {
|
|
// Walk down from doc; record the deepest inline container encountered.
|
|
let bestSoFar: WordsPath | undefined = undefined;
|
|
let node: WordsNode = doc;
|
|
for (let depth = 0; depth < pointPath.length; depth++) {
|
|
// Before descending, check whether current node is an inline
|
|
// container.
|
|
if (!isDocument(node) && nodeIsInlineContainer(node)) {
|
|
bestSoFar = pointPath.slice(0, depth);
|
|
}
|
|
const index = pointPath[depth];
|
|
const next = stepInto(node, index);
|
|
if (!next) return bestSoFar;
|
|
node = next;
|
|
}
|
|
if (!isDocument(node) && nodeIsInlineContainer(node)) {
|
|
return pointPath;
|
|
}
|
|
return bestSoFar;
|
|
}
|
|
|
|
function nodeIsInlineContainer(node: WordsNode): boolean {
|
|
if (isDocument(node)) return false;
|
|
if ('type' in node) {
|
|
const t = node.type;
|
|
return (
|
|
t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code' || t === 'link'
|
|
);
|
|
}
|
|
// list-item, table-cell, table-row, column all lack a `type` discriminator.
|
|
// table-row owns `cells`; column owns `children: WordsBlock[]`; list-item /
|
|
// table-cell own `children: WordsInline[]`. Disambiguate by sniffing the
|
|
// first child — inline (`type: 'text' | 'link'`) means inline-container;
|
|
// anything else (paragraph/heading/etc., or empty) is not.
|
|
if ('cells' in node) return false; // table-row
|
|
if ('children' in node) {
|
|
const first = (node as { children: readonly { type?: string }[] }).children[0];
|
|
if (!first) return false;
|
|
return first.type === 'text' || first.type === 'link';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stepInto(node: WordsNode, index: number): WordsNode | undefined {
|
|
if (isDocument(node)) return node.children[index];
|
|
if ('type' in node) {
|
|
const t = node.type;
|
|
if (t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code') {
|
|
return (node as { children: readonly WordsInline[] }).children[index];
|
|
}
|
|
if (t === 'list') return (node as ListBlock).items[index];
|
|
if (t === 'table') return (node as TableBlock).rows[index];
|
|
if (t === 'callout') return (node as CalloutBlock).children[index];
|
|
if (t === 'columns') return (node as ColumnsBlock).columns[index];
|
|
if (t === 'link') return (node as WordsLink).children[index];
|
|
return undefined;
|
|
}
|
|
if ('cells' in node) return (node as TableRow).cells[index];
|
|
if ('children' in node) {
|
|
// list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
|
|
return (node as { children: readonly (WordsInline | WordsBlock)[] }).children[
|
|
index
|
|
] as WordsNode | undefined;
|
|
}
|
|
return undefined;
|
|
}
|