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.
338 lines
11 KiB
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 };
|
|
}
|