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/selection-walkers.ts

338 lines
11 KiB

/**
* V2 selection-aware walkers + mark-extractors.
*
* Translated from V1's `selection.ts > getTextEntries` and
* `operations.ts > getSelectedTextSegments / getActiveMarksForSelection /
* remapSelectionByContainerOffsets`. Adapted for V2's shape:
*
* - list.items (renamed from list.children)
* - table.rows + row.cells (renamed)
* - callout blocks (new, recursive)
* - structured marks (boolean strings + parametric objects)
*
* These helpers are consumed by toggleMark + clearFormatting and any
* future op that needs to walk the selection over text entries.
*/
import {
normalizeRange,
type NormalizedWordsRange,
type WordsPoint,
type WordsSelection
} from '../selection';
import { comparePath, samePath, type WordsPath } from '../path';
import {
editingContainerPath,
pointFromInlineTextOffset,
textOffsetInInlineContainer
} from './helpers';
import { normalizeMarks } from './marks';
import type {
CalloutBlock,
CodeBlock,
WordsDocument,
ColumnsBlock,
HeadingBlock,
WordsInline,
WordsLink,
ListBlock,
ListItem,
WordsMark,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
TableRow,
WordsText
} from '../types';
// ── Text entry walker ────────────────────────────────────────────────────
export interface WordsTextEntry {
readonly path: WordsPath;
readonly text: WordsText;
}
/**
* Yields every text node in the document together with its absolute
* path. Recurses through callouts. Image / divider blocks contribute
* nothing (no text content).
*/
export function getTextEntries(doc: WordsDocument): readonly WordsTextEntry[] {
const out: WordsTextEntry[] = [];
for (let i = 0; i < doc.children.length; i++) {
collectBlockText(doc.children[i], [i], out);
}
return out;
}
function collectBlockText(
block: WordsDocument['children'][number],
path: WordsPath,
out: WordsTextEntry[]
): void {
switch (block.type) {
case 'paragraph':
case 'heading':
case 'quote': {
const b = block as ParagraphBlock | HeadingBlock | QuoteBlock;
collectInlineText(b.children, path, out);
return;
}
case 'code': {
const b = block as CodeBlock;
for (let i = 0; i < b.children.length; i++) {
out.push({ path: [...path, i], text: b.children[i] });
}
return;
}
case 'list': {
const b = block as ListBlock;
for (let i = 0; i < b.items.length; i++) {
collectListItemText(b.items[i], [...path, i], out);
}
return;
}
case 'table': {
const b = block as TableBlock;
for (let i = 0; i < b.rows.length; i++) {
collectTableRowText(b.rows[i], [...path, i], out);
}
return;
}
case 'callout': {
const b = block as CalloutBlock;
for (let i = 0; i < b.children.length; i++) {
collectBlockText(b.children[i], [...path, i], out);
}
return;
}
case 'columns': {
const b = block as ColumnsBlock;
for (let colIdx = 0; colIdx < b.columns.length; colIdx++) {
const col = b.columns[colIdx];
const colPath = [...path, colIdx];
for (let innerIdx = 0; innerIdx < col.children.length; innerIdx++) {
collectBlockText(col.children[innerIdx], [...colPath, innerIdx], out);
}
}
return;
}
// image, divider — no text.
default:
return;
}
}
function collectListItemText(item: ListItem, path: WordsPath, out: WordsTextEntry[]): void {
collectInlineText(item.children, path, out);
}
function collectTableRowText(row: TableRow, path: WordsPath, out: WordsTextEntry[]): void {
for (let i = 0; i < row.cells.length; i++) {
collectTableCellText(row.cells[i], [...path, i], out);
}
}
function collectTableCellText(cell: TableCell, path: WordsPath, out: WordsTextEntry[]): void {
// Cells hold blocks (like callout / column): recurse into each child block.
for (let i = 0; i < cell.children.length; i++) {
collectBlockText(cell.children[i], [...path, i], out);
}
}
function collectInlineText(
inlines: readonly WordsInline[],
parentPath: WordsPath,
out: WordsTextEntry[]
): void {
for (let i = 0; i < inlines.length; i++) {
const inline = inlines[i];
const path = [...parentPath, i];
if (inline.type === 'text') {
out.push({ path, text: inline });
} else if (inline.type === 'link') {
collectLinkText(inline, path, out);
}
}
}
function collectLinkText(link: WordsLink, parentPath: WordsPath, out: WordsTextEntry[]): void {
for (let i = 0; i < link.children.length; i++) {
out.push({ path: [...parentPath, i], text: link.children[i] });
}
}
// ── Selection-clipped text segments ──────────────────────────────────────
export interface WordsSelectedTextSegment {
readonly entry: WordsTextEntry;
readonly start: number;
readonly end: number;
}
/**
* Returns the text entries that intersect a non-collapsed range,
* with `start` / `end` clipped to the selection boundaries.
*/
export function getSelectedTextSegments(
doc: WordsDocument,
range: NormalizedWordsRange
): readonly WordsSelectedTextSegment[] {
const entries = getTextEntries(doc);
const out: WordsSelectedTextSegment[] = [];
for (const entry of entries) {
if (comparePath(entry.path, range.start.path) < 0) continue;
if (comparePath(entry.path, range.end.path) > 0) continue;
const start = samePath(entry.path, range.start.path) ? range.start.offset : 0;
const end = samePath(entry.path, range.end.path) ? range.end.offset : entry.text.text.length;
if (end <= start) continue;
out.push({ entry, start, end });
}
return out;
}
// ── Active marks for current selection ───────────────────────────────────
/**
* Returns the set of marks that apply to the entire selection. For
* collapsed selections, returns either `collapsedFallback` (if non-
* empty) or the marks of the text node at the caret. For ranged
* selections, returns the INTERSECTION of marks across all selected
* segments (only marks present in EVERY segment).
*/
export function getActiveMarksForSelection(
doc: WordsDocument,
selection: WordsSelection | null,
collapsedFallback: readonly WordsMark[] = []
): readonly WordsMark[] {
if (!selection) return [];
const range = normalizeRange(selection);
if (range.collapsed) {
if (collapsedFallback.length > 0) return normalizeMarks(collapsedFallback);
// Walk to the text node at the caret. We can't use the V1
// helper directly because of typing, so resolve manually
// via the entries walker — costs O(n) but correct.
const entries = getTextEntries(doc);
for (const e of entries) {
if (samePath(e.path, range.start.path)) {
return normalizeMarks(e.text.marks);
}
}
return [];
}
const segments = getSelectedTextSegments(doc, range);
if (segments.length === 0) return [];
const [first, ...rest] = segments;
let marks = [...normalizeMarks(first.entry.text.marks)];
for (const segment of rest) {
const current = normalizeMarks(segment.entry.text.marks);
marks = marks.filter((m) => current.some((c) => sameMarkValue(c, m)));
}
return marks;
}
function sameMarkValue(a: WordsMark, b: WordsMark): boolean {
if (typeof a === 'string' && typeof b === 'string') return a === b;
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
return a.type === b.type && a.value === b.value;
}
return false;
}
// ── setWordsSelection (state op) ─────────────────────────────────────────
import {
changed as _changedResult,
noOp as _noOpResult,
type WordsEditorState,
type WordsOperationResult
} from './types';
/**
* Updates `state.selection`. Clamps to text-content bounds and
* recomputes `activeMarks` ONLY when the selection actually moved.
*
* When the position is unchanged we preserve `activeMarks` verbatim:
* they are either already document-derived (a recompute would be a
* no-op) or pending toolbar marks set at a collapsed caret (e.g. the
* user clicked Bold, intending the NEXT typed character to be bold).
* Recomputing in that case would clobber the pending marks before the
* character is inserted — a redundant DOM-selection sync must not
* erase them.
*/
export function setWordsSelection(
state: WordsEditorState,
selection: WordsSelection | null
): WordsOperationResult {
const nextSelection = selection ? clampSelectionForSet(state.document, selection) : null;
if (sameSelection(state.selection, nextSelection)) return _noOpResult(state);
const nextMarks = getActiveMarksForSelection(state.document, nextSelection);
return _changedResult({
...state,
selection: nextSelection,
activeMarks: nextMarks
});
}
function clampSelectionForSet(doc: WordsDocument, selection: WordsSelection): WordsSelection {
// We can't import dom-helpers to avoid a circular dep — replicate
// the clamp logic inline (text-bound offset + first-text-node
// fallback). Cheap; the math fits in 10 lines.
const entries = getTextEntries(doc);
const fallbackPath = entries[0]?.path ?? [0, 0];
function clampPoint(p: WordsPoint): WordsPoint {
// Walk to find the text node at p.path. Reuse the entries scan
// so we get both clamp and fallback.
const found = entries.find((e) => samePath(e.path, p.path));
if (found) {
return { path: p.path, offset: Math.max(0, Math.min(p.offset, found.text.text.length)) };
}
return { path: fallbackPath, offset: 0 };
}
return { anchor: clampPoint(selection.anchor), focus: clampPoint(selection.focus) };
}
function sameSelection(a: WordsSelection | null, b: WordsSelection | null): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
samePath(a.anchor.path, b.anchor.path) &&
a.anchor.offset === b.anchor.offset &&
samePath(a.focus.path, b.focus.path) &&
a.focus.offset === b.focus.offset
);
}
// Silence unused — referenced for clarity.
type _StateHint = WordsEditorState;
// ── Remap selection across document mutation ─────────────────────────────
/**
* After an operation that REPLACED an inline container's contents
* (toggleMark / clearFormatting / setBlock-text-only), translates the
* range from before-document coordinates to after-document coordinates
* by way of the absolute text offset inside the (assumed-unchanged)
* container path.
*
* Returns undefined when start and end aren't in the same container —
* callers fall back to the original selection in that case.
*/
export function remapSelectionByContainerOffsets(
beforeDoc: WordsDocument,
afterDoc: WordsDocument,
range: NormalizedWordsRange
): WordsSelection | undefined {
const startContainer = editingContainerPath(beforeDoc, range.start.path);
const endContainer = editingContainerPath(beforeDoc, range.end.path);
if (!startContainer || !endContainer || !samePath(startContainer, endContainer)) {
return undefined;
}
const startOffset = textOffsetInInlineContainer(beforeDoc, startContainer, range.start);
const endOffset = textOffsetInInlineContainer(beforeDoc, endContainer, range.end);
const start: WordsPoint = pointFromInlineTextOffset(afterDoc, startContainer, startOffset);
const end: WordsPoint = pointFromInlineTextOffset(afterDoc, endContainer, endOffset);
return { anchor: start, focus: end };
}

Powered by TurnKey Linux.