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, 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(); const listItemIndexMap = new Map(); 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, listItemIndexMap: ReadonlyMap ): 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, listItemIndexMap: ReadonlyMap ): 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, 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, 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, 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]); }