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.
svelte-kit-vice/src/uix/soma/components/words/engine/operations/helpers.ts

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;
}

Powered by TurnKey Linux.