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.
1335 lines
43 KiB
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]);
|
|
}
|