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.ts

1335 lines
43 KiB

import {
createHeading,
createLink,
createList,
createListItem,
createParagraph,
createQuote,
createText,
getInlineText,
normalizeMarks,
sameMarks,
type WordsBlock,
type WordsBlockType,
type WordsDocument,
type WordsHeadingLevel,
type WordsInline,
type WordsListItem,
type WordsListKind,
type WordsMark
} from './document';
import { normalizeDocument, sanitizeWordsUrl } from './normalize';
import {
comparePath,
getInlineChildrenAtPath,
getNodeAtPath,
pathParent,
replaceAt,
replaceInlineAtPath,
setInlineChildrenAtPath,
samePath,
type WordsPath
} from './path';
import {
clampSelection,
clampPoint,
createCollapsedSelection,
getTextEntries,
normalizeRange,
resolveTextNode,
type NormalizedWordsRange,
type WordsPoint,
type WordsSelection,
type WordsTextEntry
} from './selection';
export interface WordsEditorState {
readonly document: WordsDocument;
readonly selection: WordsSelection | null;
readonly activeMarks: readonly WordsMark[];
}
export interface WordsOperationResult {
readonly state: WordsEditorState;
readonly changed: boolean;
readonly exportedContent?: string;
}
interface DocumentRangeResult {
readonly document: WordsDocument;
readonly point: WordsPoint;
readonly changed: boolean;
}
interface InlineSplit {
readonly before: readonly WordsInline[];
readonly after: readonly WordsInline[];
}
interface SelectedTextSegment {
readonly entry: WordsTextEntry;
readonly start: number;
readonly end: number;
}
export function createWordsState(
document: unknown,
selection: WordsSelection | null = null
): WordsEditorState {
const normalized = normalizeDocument(document).document;
const nextSelection = selection ? clampSelection(normalized, selection) : null;
return {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
};
}
export function setWordsSelection(
state: WordsEditorState,
selection: WordsSelection | null
): WordsOperationResult {
const nextSelection = selection ? clampSelection(state.document, selection) : null;
const nextActiveMarks = getActiveMarksForSelection(state.document, nextSelection);
const changed =
!sameSelection(state.selection, nextSelection) ||
!sameMarks(state.activeMarks, nextActiveMarks);
return {
state: {
...state,
selection: nextSelection,
activeMarks: nextActiveMarks
},
changed
};
}
export function replaceDocument(state: WordsEditorState, document: unknown): WordsOperationResult {
const normalized = normalizeDocument(document).document;
return {
state: {
document: normalized,
selection: null,
activeMarks: []
},
changed: normalized !== state.document
};
}
export function insertText(state: WordsEditorState, text: string): WordsOperationResult {
if (text.length === 0) return { state, changed: false };
const marks = state.activeMarks.length ? state.activeMarks : undefined;
return insertInlinesAtRange(state, [createText(text, marks)], marks);
}
export function insertInlinesAtRange(
state: WordsEditorState,
inlines: readonly WordsInline[],
activeMarks: readonly WordsMark[] | undefined = undefined
): WordsOperationResult {
const selection = state.selection;
if (!selection || inlines.length === 0) return { state, changed: false };
let document = state.document;
let range = normalizedSelectionRange(document, selection);
let point = range.start;
if (!range.collapsed) {
const deleted = deleteRangeFromDocument(document, range);
if (!deleted.changed) return { state, changed: false };
document = deleted.document;
point = deleted.point;
range = { start: point, end: point, collapsed: true };
}
const text = resolveTextNode(document, point.path);
if (!text) return { state, changed: false };
const parentPath = pathParent(point.path);
const offsetInParent = textOffsetInInlineContainer(document, parentPath, point);
const before = text.text.slice(0, point.offset);
const after = text.text.slice(point.offset);
const insertedLength = inlineTextLength(inlines);
const nextDocument = replaceInlineAtPath(document, point.path, [
...(before ? [createText(before, text.marks)] : []),
...inlines,
...(after ? [createText(after, text.marks)] : [])
]);
const normalized = normalizeDocument(nextDocument).document;
const nextPoint = pointFromInlineTextOffset(
normalized,
parentPath,
offsetInParent + insertedLength
);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
const nextActiveMarks = activeMarks
? normalizeMarks(activeMarks)
: getActiveMarksForSelection(normalized, nextSelection);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: nextActiveMarks
},
changed: true
};
}
export function deleteRange(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (range.collapsed) return { state, changed: false };
const deleted = deleteRangeFromDocument(state.document, range);
if (!deleted.changed) return { state, changed: false };
const nextSelection = createCollapsedSelection(deleted.point.path, deleted.point.offset);
return {
state: {
document: deleted.document,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(deleted.document, nextSelection)
},
changed: true
};
}
export function toggleMark(state: WordsEditorState, mark: WordsMark): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (range.collapsed) {
const text = resolveTextNode(state.document, range.start.path);
const baseMarks = state.activeMarks.length ? state.activeMarks : normalizeMarks(text?.marks);
const activeMarks = toggleMarkSet(baseMarks, mark);
return {
state: {
...state,
activeMarks
},
changed: !sameMarks(state.activeMarks, activeMarks)
};
}
const segments = getSelectedTextSegments(state.document, range);
if (segments.length === 0) return { state, changed: false };
const removeMark = segments.every(({ entry }) => normalizeMarks(entry.text.marks).includes(mark));
let document = state.document;
for (const segment of [...segments].sort((a, b) => -comparePath(a.entry.path, b.entry.path))) {
const current = resolveTextNode(document, segment.entry.path);
if (!current) continue;
const marks = normalizeMarks(current.marks);
const selectedMarks = removeMark
? marks.filter((item) => item !== mark)
: normalizeMarks([...marks, mark]);
document = replaceInlineAtPath(document, segment.entry.path, [
...(segment.start > 0 ? [createText(current.text.slice(0, segment.start), marks)] : []),
createText(current.text.slice(segment.start, segment.end), selectedMarks),
...(segment.end < current.text.length
? [createText(current.text.slice(segment.end), marks)]
: [])
]);
}
const normalized = normalizeDocument(document).document;
const nextSelection =
remapSelectionByContainerOffsets(state.document, normalized, range) ?? selection;
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function clearFormatting(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (range.collapsed) {
return {
state: {
...state,
activeMarks: []
},
changed: state.activeMarks.length > 0
};
}
const segments = getSelectedTextSegments(state.document, range);
if (segments.length === 0) return { state, changed: false };
const hasFormatting = segments.some(({ entry }) => Boolean(entry.text.marks?.length));
if (!hasFormatting) return { state, changed: false };
let document = state.document;
for (const segment of [...segments].sort((a, b) => -comparePath(a.entry.path, b.entry.path))) {
const current = resolveTextNode(document, segment.entry.path);
if (!current) continue;
document = replaceInlineAtPath(document, segment.entry.path, [
...(segment.start > 0
? [createText(current.text.slice(0, segment.start), current.marks)]
: []),
createText(current.text.slice(segment.start, segment.end)),
...(segment.end < current.text.length
? [createText(current.text.slice(segment.end), current.marks)]
: [])
]);
}
const normalized = normalizeDocument(document).document;
const nextSelection =
remapSelectionByContainerOffsets(state.document, normalized, range) ?? selection;
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function setBlock(
state: WordsEditorState,
type: Exclude<WordsBlockType, 'list'>,
options: { level?: WordsHeadingLevel } = {}
): WordsOperationResult {
const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;
const startIndex = range?.start.path[0] ?? 0;
const endIndex = range?.end.path[0] ?? startIndex;
let changed = false;
const blockIndexMap = new Map<number, number>();
const listItemIndexMap = new Map<string, number>();
const children: WordsBlock[] = [];
state.document.children.forEach((block, index) => {
if (index < startIndex || index > endIndex) {
blockIndexMap.set(index, children.length);
children.push(block);
return;
}
if (block.type === 'list') {
changed = true;
for (const [itemIndex, item] of block.children.entries()) {
listItemIndexMap.set(listItemMapKey(index, itemIndex), children.length);
children.push(blockWithChildren(type, item.children, options.level));
}
return;
}
const nextBlock = blockWithChildren(type, block.children, options.level);
blockIndexMap.set(index, children.length);
changed = changed || !sameBlockShape(block, nextBlock);
children.push(nextBlock);
});
if (!changed) return { state, changed: false };
const normalized = normalizeDocument({ ...state.document, children }).document;
const nextSelection = selection
? remapSelectionAfterSetBlock(
state.document,
normalized,
selection,
blockIndexMap,
listItemIndexMap
)
: null;
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsOperationResult {
const selection = state.selection;
const range = selection ? normalizedSelectionRange(state.document, selection) : undefined;
const startIndex = range?.start.path[0] ?? 0;
const endIndex = range?.end.path[0] ?? startIndex;
const selected = state.document.children.slice(startIndex, endIndex + 1);
if (selected.length === 0) return { state, changed: false };
const allSameList = selected.every((block) => block.type === 'list' && block.kind === kind);
const replacement: readonly WordsBlock[] = allSameList
? selected.flatMap((block) =>
block.type === 'list'
? block.children.map((item) => createParagraph(item.children))
: [block]
)
: [
createList(
kind,
selected.flatMap((block) =>
block.type === 'list'
? block.children
: [createListItem(block.children, kind === 'check')]
)
)
];
const normalized = normalizeDocument({
...state.document,
children: [
...state.document.children.slice(0, startIndex),
...replacement,
...state.document.children.slice(endIndex + 1)
]
}).document;
const nextSelection = selection ? clampSelection(normalized, selection) : null;
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function insertLink(
state: WordsEditorState,
href: string,
title?: string
): WordsOperationResult {
const safeHref = sanitizeWordsUrl(href);
const selection = state.selection;
if (!selection || !safeHref) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (
findAncestorPath(state.document, range.start.path, 'link') ||
findAncestorPath(state.document, range.end.path, 'link')
) {
return { state, changed: false };
}
const selectedText = range.collapsed ? safeHref : selectedRangeText(state.document, range);
const marks = getActiveMarksForSelection(state.document, selection, state.activeMarks);
return insertInlinesAtRange(state, [
createLink(safeHref, [createText(selectedText, marks)], title)
]);
}
export function unlink(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const anchor = clampSelection(state.document, selection).anchor;
const linkPath = findAncestorPath(state.document, anchor.path, 'link');
if (!linkPath) return { state, changed: false };
const link = getNodeAtPath(state.document, linkPath);
if (!link || !('type' in link) || link.type !== 'link') return { state, changed: false };
const parentPath = pathParent(linkPath);
const offset = textOffsetInInlineContainer(state.document, parentPath, anchor);
const document = replaceInlineAtPath(state.document, linkPath, link.children);
const normalized = normalizeDocument(document).document;
const point = pointFromInlineTextOffset(normalized, parentPath, offset);
const nextSelection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export interface WordsSelectedLink {
readonly href: string;
readonly title?: string;
readonly path: WordsPath;
}
export function getLinkAtSelection(state: WordsEditorState): WordsSelectedLink | null {
const selection = state.selection;
if (!selection) return null;
const clamped = clampSelection(state.document, selection);
const anchorPath = findAncestorPath(state.document, clamped.anchor.path, 'link');
const focusPath = findAncestorPath(state.document, clamped.focus.path, 'link');
const linkPath = anchorPath ?? focusPath;
if (!linkPath) return null;
const node = getNodeAtPath(state.document, linkPath);
if (!node || !('type' in node) || node.type !== 'link') return null;
return {
href: node.href,
...(node.title ? { title: node.title } : {}),
path: linkPath
};
}
export function insertParagraph(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
let workingState = state;
let range = normalizedSelectionRange(workingState.document, selection);
if (!range.collapsed) {
const deleted = deleteRange(workingState);
if (!deleted.changed || !deleted.state.selection) return deleted;
workingState = deleted.state;
const nextSelection = workingState.selection;
if (!nextSelection) return { state, changed: false };
range = normalizedSelectionRange(workingState.document, nextSelection);
}
const point = range.start;
const containerPath = editingContainerPath(workingState.document, point.path);
if (!containerPath) return { state, changed: false };
const split = splitContainerAtPoint(workingState.document, containerPath, point);
const blockIndex = containerPath[0] ?? -1;
const block = workingState.document.children[blockIndex];
if (!block) return { state, changed: false };
if (block.type === 'list') {
const itemIndex = containerPath[1] ?? -1;
const item = block.children[itemIndex];
if (!item) return { state, changed: false };
if (
range.collapsed &&
inlineTextLength(item.children) === 0 &&
inlineTextLength(split.before) === 0 &&
inlineTextLength(split.after) === 0
) {
const beforeItems = block.children.slice(0, itemIndex);
const afterItems = block.children.slice(itemIndex + 1);
const replacement: WordsBlock[] = [
...(beforeItems.length ? [createList(block.kind, beforeItems)] : []),
createParagraph(),
...(afterItems.length ? [createList(block.kind, afterItems)] : [])
];
const paragraphIndex = blockIndex + (beforeItems.length ? 1 : 0);
const normalized = normalizeDocument({
...workingState.document,
children: [
...workingState.document.children.slice(0, blockIndex),
...replacement,
...workingState.document.children.slice(blockIndex + 1)
]
}).document;
const nextPoint = pointFromInlineTextOffset(normalized, [paragraphIndex], 0);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
const nextItems = [
...block.children.slice(0, itemIndex),
createListItem(ensureInlineChildren(split.before), item.checked === true),
createListItem(ensureInlineChildren(split.after), block.kind === 'check' ? false : undefined),
...block.children.slice(itemIndex + 1)
];
const normalized = normalizeDocument({
...workingState.document,
children: replaceAt(
workingState.document.children,
blockIndex,
createList(block.kind, nextItems)
)
}).document;
const nextPoint = pointFromInlineTextOffset(normalized, [blockIndex, itemIndex + 1], 0);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
const current = withInlineChildren(block, ensureInlineChildren(split.before));
const next = createParagraph(ensureInlineChildren(split.after));
const normalized = normalizeDocument({
...workingState.document,
children: [
...workingState.document.children.slice(0, blockIndex),
current,
next,
...workingState.document.children.slice(blockIndex + 1)
]
}).document;
const nextPoint = pointFromInlineTextOffset(normalized, [blockIndex + 1], 0);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function insertLineBreak(state: WordsEditorState): WordsOperationResult {
return insertText(state, '\n');
}
export function deleteBackward(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (!range.collapsed) return deleteRange(state);
const containerPath = editingContainerPath(state.document, range.start.path);
if (!containerPath) return { state, changed: false };
const offset = textOffsetInInlineContainer(state.document, containerPath, range.start);
if (offset > 0) {
const start = pointFromInlineTextOffset(state.document, containerPath, offset - 1);
return deleteRange({
...state,
selection: { anchor: start, focus: range.start }
});
}
return mergeWithPreviousContainer(state, containerPath);
}
export function deleteForward(state: WordsEditorState): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (!range.collapsed) return deleteRange(state);
const containerPath = editingContainerPath(state.document, range.start.path);
if (!containerPath) return { state, changed: false };
const length = inlineTextLength(getInlineChildrenAtPath(state.document, containerPath) ?? []);
const offset = textOffsetInInlineContainer(state.document, containerPath, range.start);
if (offset < length) {
const end = pointFromInlineTextOffset(state.document, containerPath, offset + 1);
return deleteRange({
...state,
selection: { anchor: range.start, focus: end }
});
}
return mergeWithNextContainer(state, containerPath);
}
export function deleteWordBackward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'backward', 'word');
}
export function deleteWordForward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'forward', 'word');
}
export function deleteLineBackward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'backward', 'line');
}
export function deleteLineForward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'forward', 'line');
}
function deleteTextBoundary(
state: WordsEditorState,
direction: 'backward' | 'forward',
unit: 'word' | 'line'
): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (!range.collapsed) return deleteRange(state);
const containerPath = editingContainerPath(state.document, range.start.path);
if (!containerPath) return { state, changed: false };
const text = inlineChildrenText(getInlineChildrenAtPath(state.document, containerPath) ?? []);
const offset = textOffsetInInlineContainer(state.document, containerPath, range.start);
const targetOffset =
unit === 'line'
? direction === 'backward'
? 0
: text.length
: direction === 'backward'
? previousWordBoundary(text, offset)
: nextWordBoundary(text, offset);
if (targetOffset === offset) {
return direction === 'backward'
? mergeWithPreviousContainer(state, containerPath)
: mergeWithNextContainer(state, containerPath);
}
const startOffset = Math.min(offset, targetOffset);
const endOffset = Math.max(offset, targetOffset);
const start = pointFromInlineTextOffset(state.document, containerPath, startOffset);
const end = pointFromInlineTextOffset(state.document, containerPath, endOffset);
return deleteRange({
...state,
selection: { anchor: start, focus: end }
});
}
function previousWordBoundary(text: string, offset: number): number {
let index = Math.max(0, Math.min(offset, text.length));
while (index > 0 && isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
while (index > 0 && !isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
return index;
}
function nextWordBoundary(text: string, offset: number): number {
let index = Math.max(0, Math.min(offset, text.length));
while (index < text.length && isWordBoundarySpacer(text[index] ?? '')) index += 1;
while (index < text.length && !isWordBoundarySpacer(text[index] ?? '')) index += 1;
return index;
}
function isWordBoundarySpacer(char: string): boolean {
return /\s/u.test(char);
}
function deleteRangeFromDocument(
document: WordsDocument,
range: NormalizedWordsRange
): DocumentRangeResult {
if (range.collapsed) return { document, point: range.start, changed: false };
const startContainer = editingContainerPath(document, range.start.path);
const endContainer = editingContainerPath(document, range.end.path);
if (!startContainer || !endContainer) return { document, point: range.start, changed: false };
if (samePath(startContainer, endContainer)) {
const before = splitContainerAtPoint(document, startContainer, range.start).before;
const after = splitContainerAtPoint(document, endContainer, range.end).after;
const offset = inlineTextLength(before);
const nextDocument = setInlineChildrenAtPath(
document,
startContainer,
ensureInlineChildren([...before, ...after])
);
const normalized = normalizeDocument(nextDocument).document;
const point = pointFromInlineTextOffset(normalized, startContainer, offset);
return { document: normalized, point, changed: true };
}
if (startContainer[0] === endContainer[0]) {
return deleteRangeInsideList(document, startContainer, endContainer, range);
}
return deleteRangeAcrossBlocks(document, startContainer, endContainer, range);
}
function deleteRangeInsideList(
document: WordsDocument,
startContainer: WordsPath,
endContainer: WordsPath,
range: NormalizedWordsRange
): DocumentRangeResult {
const blockIndex = startContainer[0] ?? -1;
const block = document.children[blockIndex];
if (!block || block.type !== 'list') return { document, point: range.start, changed: false };
const startItem = startContainer[1] ?? -1;
const endItem = endContainer[1] ?? -1;
if (startItem < 0 || endItem < startItem) {
return { document, point: range.start, changed: false };
}
const before = splitContainerAtPoint(document, startContainer, range.start).before;
const after = splitContainerAtPoint(document, endContainer, range.end).after;
const offset = inlineTextLength(before);
const current = block.children[startItem];
const nextItem = createListItem(
ensureInlineChildren([...before, ...after]),
current?.checked === true
);
const nextList = createList(block.kind, [
...block.children.slice(0, startItem),
nextItem,
...block.children.slice(endItem + 1)
]);
const normalized = normalizeDocument({
...document,
children: replaceAt(document.children, blockIndex, nextList)
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex, startItem], offset);
return { document: normalized, point, changed: true };
}
function deleteRangeAcrossBlocks(
document: WordsDocument,
startContainer: WordsPath,
endContainer: WordsPath,
range: NormalizedWordsRange
): DocumentRangeResult {
const startBlockIndex = startContainer[0] ?? -1;
const endBlockIndex = endContainer[0] ?? -1;
const startBlock = document.children[startBlockIndex];
const endBlock = document.children[endBlockIndex];
if (!startBlock || !endBlock || endBlockIndex < startBlockIndex) {
return { document, point: range.start, changed: false };
}
const before = splitContainerAtPoint(document, startContainer, range.start).before;
const after = splitContainerAtPoint(document, endContainer, range.end).after;
const offset = inlineTextLength(before);
const merged = ensureInlineChildren([...before, ...after]);
const nextStartBlock =
startBlock.type === 'list'
? truncateListAtItem(startBlock, startContainer[1] ?? 0, merged)
: withInlineChildren(startBlock, merged);
const normalized = normalizeDocument({
...document,
children: [
...document.children.slice(0, startBlockIndex),
nextStartBlock,
...document.children.slice(endBlockIndex + 1)
]
}).document;
const pointPath =
startBlock.type === 'list' ? [startBlockIndex, startContainer[1] ?? 0] : [startBlockIndex];
const point = pointFromInlineTextOffset(normalized, pointPath, offset);
return { document: normalized, point, changed: true };
}
function splitContainerAtPoint(
document: WordsDocument,
containerPath: WordsPath,
point: WordsPoint
): InlineSplit {
const children = getInlineChildrenAtPath(document, containerPath);
if (!children) return { before: [], after: [] };
return splitInlinesAtPoint(children, containerPath, point);
}
function splitInlinesAtPoint(
inlines: readonly WordsInline[],
parentPath: WordsPath,
point: WordsPoint
): InlineSplit {
const before: WordsInline[] = [];
const after: WordsInline[] = [];
for (const [index, inline] of inlines.entries()) {
const path = [...parentPath, index];
const split = splitInlineAtPoint(inline, path, point);
before.push(...split.before);
after.push(...split.after);
}
return { before, after };
}
function splitInlineAtPoint(inline: WordsInline, path: WordsPath, point: WordsPoint): InlineSplit {
if (inline.type === 'text') {
if (!samePath(path, point.path)) {
return comparePath(path, point.path) < 0
? { before: [inline], after: [] }
: { before: [], after: [inline] };
}
return {
before:
point.offset > 0 ? [createText(inline.text.slice(0, point.offset), inline.marks)] : [],
after:
point.offset < inline.text.length
? [createText(inline.text.slice(point.offset), inline.marks)]
: []
};
}
if (!pathIsAncestorOrSelf(path, point.path)) {
return comparePath(path, point.path) < 0
? { before: [inline], after: [] }
: { before: [], after: [inline] };
}
const split = splitInlinesAtPoint(inline.children, path, point);
return {
before: split.before.length ? [createLink(inline.href, split.before, inline.title)] : [],
after: split.after.length ? [createLink(inline.href, split.after, inline.title)] : []
};
}
function editingContainerPath(document: WordsDocument, path: WordsPath): WordsPath | undefined {
const blockIndex = path[0];
if (blockIndex === undefined) return undefined;
const block = document.children[blockIndex];
if (!block) return undefined;
if (block.type !== 'list') return [blockIndex];
const itemIndex = path[1] ?? 0;
return block.children[itemIndex] ? [blockIndex, itemIndex] : undefined;
}
function pointFromInlineTextOffset(
document: WordsDocument,
containerPath: WordsPath,
offset: number
): WordsPoint {
const children = getInlineChildrenAtPath(document, containerPath);
if (!children) {
const fallback = getTextEntries(document)[0];
return fallback ? { path: fallback.path, offset: 0 } : { path: [0, 0], offset: 0 };
}
return findPointInInlines(children, containerPath, Math.max(0, offset));
}
function findPointInInlines(
inlines: readonly WordsInline[],
parentPath: WordsPath,
offset: number
): WordsPoint {
let remaining = offset;
let fallback: WordsPoint | undefined;
for (const [index, inline] of inlines.entries()) {
const path = [...parentPath, index];
if (inline.type === 'text') {
const length = inline.text.length;
if (remaining <= length) return { path, offset: remaining };
remaining -= length;
fallback = { path, offset: length };
continue;
}
const length = inlineTextLength(inline.children);
if (remaining <= length) return findPointInInlines(inline.children, path, remaining);
remaining -= length;
fallback = lastPointInInlines(inline.children, path) ?? fallback;
}
return fallback ?? { path: [...parentPath, 0], offset: 0 };
}
function lastPointInInlines(
inlines: readonly WordsInline[],
parentPath: WordsPath
): WordsPoint | undefined {
for (let index = inlines.length - 1; index >= 0; index -= 1) {
const inline = inlines[index];
if (!inline) continue;
const path = [...parentPath, index];
if (inline.type === 'text') return { path, offset: inline.text.length };
const point = lastPointInInlines(inline.children, path);
if (point) return point;
}
return undefined;
}
function textOffsetInInlineContainer(
document: WordsDocument,
containerPath: WordsPath,
point: WordsPoint
): number {
const children = getInlineChildrenAtPath(document, containerPath);
if (!children) return 0;
return offsetInInlines(children, containerPath, point) ?? 0;
}
function offsetInInlines(
inlines: readonly WordsInline[],
parentPath: WordsPath,
point: WordsPoint
): number | undefined {
let offset = 0;
for (const [index, inline] of inlines.entries()) {
const path = [...parentPath, index];
if (inline.type === 'text') {
if (samePath(path, point.path))
return offset + Math.max(0, Math.min(point.offset, inline.text.length));
offset += inline.text.length;
continue;
}
if (pathIsAncestorOrSelf(path, point.path)) {
const nested = offsetInInlines(inline.children, path, point);
return nested === undefined ? undefined : offset + nested;
}
offset += inlineTextLength(inline.children);
}
return undefined;
}
function getSelectedTextSegments(
document: WordsDocument,
range: NormalizedWordsRange
): readonly SelectedTextSegment[] {
return getTextEntries(document)
.map((entry): SelectedTextSegment | undefined => {
if (comparePath(entry.path, range.start.path) < 0) return undefined;
if (comparePath(entry.path, range.end.path) > 0) return undefined;
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) return undefined;
return { entry, start, end };
})
.filter((entry): entry is SelectedTextSegment => Boolean(entry));
}
function selectedRangeText(document: WordsDocument, range: NormalizedWordsRange): string {
return getSelectedTextSegments(document, range)
.map(({ entry, start, end }) => entry.text.text.slice(start, end))
.join('');
}
function remapSelectionByContainerOffsets(
beforeDocument: WordsDocument,
afterDocument: WordsDocument,
range: NormalizedWordsRange
): WordsSelection | undefined {
const startContainer = editingContainerPath(beforeDocument, range.start.path);
const endContainer = editingContainerPath(beforeDocument, range.end.path);
if (!startContainer || !endContainer || !samePath(startContainer, endContainer)) return undefined;
const startOffset = textOffsetInInlineContainer(beforeDocument, startContainer, range.start);
const endOffset = textOffsetInInlineContainer(beforeDocument, endContainer, range.end);
const start = pointFromInlineTextOffset(afterDocument, startContainer, startOffset);
const end = pointFromInlineTextOffset(afterDocument, endContainer, endOffset);
return { anchor: start, focus: end };
}
function remapSelectionAfterSetBlock(
beforeDocument: WordsDocument,
afterDocument: WordsDocument,
selection: WordsSelection,
blockIndexMap: ReadonlyMap<number, number>,
listItemIndexMap: ReadonlyMap<string, number>
): WordsSelection {
return {
anchor: remapPointAfterSetBlock(
beforeDocument,
afterDocument,
selection.anchor,
blockIndexMap,
listItemIndexMap
),
focus: remapPointAfterSetBlock(
beforeDocument,
afterDocument,
selection.focus,
blockIndexMap,
listItemIndexMap
)
};
}
function remapPointAfterSetBlock(
beforeDocument: WordsDocument,
afterDocument: WordsDocument,
point: WordsPoint,
blockIndexMap: ReadonlyMap<number, number>,
listItemIndexMap: ReadonlyMap<string, number>
): WordsPoint {
const blockIndex = point.path[0] ?? 0;
const block = beforeDocument.children[blockIndex];
if (block?.type === 'list') {
const itemIndex = point.path[1] ?? 0;
const nextBlockIndex = listItemIndexMap.get(listItemMapKey(blockIndex, itemIndex));
if (nextBlockIndex !== undefined) {
return clampPoint(afterDocument, {
path: [nextBlockIndex, ...point.path.slice(2)],
offset: point.offset
});
}
}
const nextBlockIndex = blockIndexMap.get(blockIndex);
if (nextBlockIndex !== undefined) {
return clampPoint(afterDocument, {
path: [nextBlockIndex, ...point.path.slice(1)],
offset: point.offset
});
}
return clampPoint(afterDocument, point);
}
function listItemMapKey(blockIndex: number, itemIndex: number): string {
return `${blockIndex}:${itemIndex}`;
}
function getActiveMarksForSelection(
document: WordsDocument,
selection: WordsSelection | null,
collapsedFallback: readonly WordsMark[] = []
): readonly WordsMark[] {
if (!selection) return [];
const range = normalizedSelectionRange(document, selection);
if (range.collapsed) {
if (collapsedFallback.length > 0) return normalizeMarks(collapsedFallback);
return normalizeMarks(resolveTextNode(document, range.start.path)?.marks);
}
const segments = getSelectedTextSegments(document, 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((mark) => current.includes(mark));
}
return marks;
}
function normalizedSelectionRange(
document: WordsDocument,
selection: WordsSelection
): NormalizedWordsRange {
return normalizeRange(clampSelection(document, selection));
}
function mergeWithPreviousContainer(
state: WordsEditorState,
containerPath: WordsPath
): WordsOperationResult {
const blockIndex = containerPath[0] ?? -1;
const block = state.document.children[blockIndex];
if (!block) return { state, changed: false };
if (block.type === 'list') {
const itemIndex = containerPath[1] ?? -1;
if (itemIndex === 0) {
const current = block.children[itemIndex];
if (!current) return { state, changed: false };
const remainingItems = block.children.slice(1);
const replacement: WordsBlock[] = [
createParagraph(current.children),
...(remainingItems.length ? [createList(block.kind, remainingItems)] : [])
];
const normalized = normalizeDocument({
...state.document,
children: [
...state.document.children.slice(0, blockIndex),
...replacement,
...state.document.children.slice(blockIndex + 1)
]
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex], 0);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
if (itemIndex < 0) return { state, changed: false };
const previous = block.children[itemIndex - 1];
const current = block.children[itemIndex];
if (!previous || !current) return { state, changed: false };
const offset = inlineTextLength(previous.children);
const nextItem = createListItem(
[...previous.children, ...current.children],
previous.checked === true
);
const nextList = createList(block.kind, [
...block.children.slice(0, itemIndex - 1),
nextItem,
...block.children.slice(itemIndex + 1)
]);
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextList)
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex, itemIndex - 1], offset);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
if (blockIndex <= 0) return { state, changed: false };
const previous = state.document.children[blockIndex - 1];
if (!previous || previous.type === 'list') return { state, changed: false };
const offset = inlineTextLength(previous.children);
const merged = withInlineChildren(previous, [...previous.children, ...block.children]);
const normalized = normalizeDocument({
...state.document,
children: [
...state.document.children.slice(0, blockIndex - 1),
merged,
...state.document.children.slice(blockIndex + 1)
]
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex - 1], offset);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
function mergeWithNextContainer(
state: WordsEditorState,
containerPath: WordsPath
): WordsOperationResult {
const blockIndex = containerPath[0] ?? -1;
const block = state.document.children[blockIndex];
if (!block) return { state, changed: false };
if (block.type === 'list') {
const itemIndex = containerPath[1] ?? -1;
const current = block.children[itemIndex];
const next = block.children[itemIndex + 1];
if (!current || !next) return { state, changed: false };
const offset = inlineTextLength(current.children);
const nextItem = createListItem(
[...current.children, ...next.children],
current.checked === true
);
const nextList = createList(block.kind, [
...block.children.slice(0, itemIndex),
nextItem,
...block.children.slice(itemIndex + 2)
]);
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextList)
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex, itemIndex], offset);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
const next = state.document.children[blockIndex + 1];
if (!next || next.type === 'list') return { state, changed: false };
const offset = inlineTextLength(block.children);
const merged = withInlineChildren(block, [...block.children, ...next.children]);
const normalized = normalizeDocument({
...state.document,
children: [
...state.document.children.slice(0, blockIndex),
merged,
...state.document.children.slice(blockIndex + 2)
]
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex], offset);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
function findAncestorPath(
document: WordsDocument,
path: WordsPath,
type: WordsInline['type']
): WordsPath | undefined {
for (let length = path.length; length >= 0; length -= 1) {
const currentPath = path.slice(0, length);
const node = getNodeAtPath(document, currentPath);
if (node && 'type' in node && node.type === type) return currentPath;
}
return undefined;
}
function blockWithChildren(
type: Exclude<WordsBlockType, 'list'>,
children: readonly WordsInline[],
level: WordsHeadingLevel | undefined
): WordsBlock {
if (type === 'heading') return createHeading(level ?? 1, children);
if (type === 'quote') return createQuote(children);
return createParagraph(children);
}
function sameBlockShape(a: WordsBlock, b: WordsBlock): boolean {
if (a.type !== b.type) return false;
if (a.type === 'heading' && b.type === 'heading') return a.level === b.level;
return true;
}
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
);
}
function truncateListAtItem(
block: Extract<WordsBlock, { type: 'list' }>,
itemIndex: number,
children: readonly WordsInline[]
): WordsBlock {
const item = block.children[itemIndex] ?? createListItem();
return createList(block.kind, [
...block.children.slice(0, itemIndex),
createListItem(children, item.checked === true)
]);
}
function withInlineChildren(
block: Exclude<WordsBlock, { type: 'list' }>,
children: readonly WordsInline[]
): WordsBlock {
switch (block.type) {
case 'heading':
return createHeading(block.level, children);
case 'quote':
return createQuote(children);
default:
return createParagraph(children);
}
}
function ensureInlineChildren(children: readonly WordsInline[]): readonly WordsInline[] {
return children.length ? children : [createText('')];
}
function inlineTextLength(inlines: readonly WordsInline[]): number {
return inlines.reduce((total, inline) => total + getInlineText(inline).length, 0);
}
function inlineChildrenText(inlines: readonly WordsInline[]): string {
return inlines.map(getInlineText).join('');
}
function toggleMarkSet(marks: readonly WordsMark[], mark: WordsMark): readonly WordsMark[] {
return normalizeMarks(marks.includes(mark) ? marks.filter((m) => m !== mark) : [...marks, mark]);
}
function pathIsAncestorOrSelf(parent: WordsPath, child: WordsPath): boolean {
if (parent.length > child.length) return false;
return parent.every((part, index) => part === child[index]);
}

Powered by TurnKey Linux.