import { chipToken, normalizeInlineWikilinkValue, } from './inlineWikilinkTokens' export interface InlineSelectionRange { start: number end: number } interface SelectionBoundary { container: Node offset: number } export function serializeInlineNode(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return normalizeInlineWikilinkValue(node.textContent ?? '') } if (node instanceof HTMLElement) { if (node.dataset.chipTarget) { return chipToken(node.dataset.chipTarget) } if (node.tagName === 'BR') return '' } return Array.from(node.childNodes).map(serializeInlineNode).join('') } function selectionFallsOutsideEditor( selection: Selection | null, root: HTMLDivElement, ): boolean { if (!selection || selection.rangeCount === 0) return true const range = selection.getRangeAt(0) return !root.contains(range.startContainer) || !root.contains(range.endContainer) } function serializedSelectionBoundary( root: HTMLDivElement, container: Node, offset: number, ): number { const range = document.createRange() range.setStart(root, 0) range.setEnd(container, offset) return serializeInlineNode(range.cloneContents()).length } export function readSelectionRange(root: HTMLDivElement): InlineSelectionRange { const currentSelection = window.getSelection() if (!currentSelection || selectionFallsOutsideEditor(currentSelection, root)) { const index = serializeInlineNode(root).length return { start: index, end: index } } const range = currentSelection.getRangeAt(0) return { start: serializedSelectionBoundary(root, range.startContainer, range.startOffset), end: serializedSelectionBoundary(root, range.endContainer, range.endOffset), } } export function readSelectionIndex(root: HTMLDivElement): number { return readSelectionRange(root).end } function boundaryAtEditorEnd(root: HTMLDivElement): SelectionBoundary { const endBoundary = findTextBoundaryAtEdge(root, 'end') return endBoundary ?? { container: root, offset: root.childNodes.length } } function findTextBoundaryAtEdge( node: Node, edge: 'start' | 'end', ): SelectionBoundary | null { if (node.nodeType === Node.TEXT_NODE) { return { container: node, offset: edge === 'start' ? 0 : node.textContent?.length ?? 0, } } const children = Array.from(node.childNodes) const orderedChildren = edge === 'start' ? children : [...children].reverse() for (const child of orderedChildren) { const boundary = findTextBoundaryAtEdge(child, edge) if (boundary) return boundary } return null } function boundaryForTextNode( child: Node, remaining: number, ): { boundary: SelectionBoundary | null; remaining: number } { const textLength = normalizeInlineWikilinkValue(child.textContent ?? '').length if (remaining <= textLength) { return { boundary: { container: child, offset: remaining }, remaining: 0, } } return { boundary: null, remaining: remaining - textLength, } } function boundaryForChip( node: Node, child: HTMLElement, index: number, remaining: number, ): { boundary: SelectionBoundary | null; remaining: number } { const tokenLength = chipToken(child.dataset.chipTarget ?? '').length if (remaining <= 0) { const previousSibling = node.childNodes.item(index - 1) return { boundary: previousSibling ? findTextBoundaryAtEdge(previousSibling, 'end') ?? { container: node, offset: index } : { container: node, offset: index }, remaining: 0, } } if (remaining <= tokenLength) { const nextSibling = node.childNodes.item(index + 1) return { boundary: nextSibling ? findTextBoundaryAtEdge(nextSibling, 'start') ?? { container: node, offset: index + 1 } : { container: node, offset: index + 1 }, remaining: 0, } } return { boundary: null, remaining: remaining - tokenLength, } } function findSelectionBoundary( node: Node, selectionIndex: number, ): SelectionBoundary | null { let remaining = Math.max(0, selectionIndex) const children = Array.from(node.childNodes) for (const [index, child] of children.entries()) { if (child.nodeType === Node.TEXT_NODE) { const textBoundary = boundaryForTextNode(child, remaining) if (textBoundary.boundary) return textBoundary.boundary remaining = textBoundary.remaining continue } if (!(child instanceof HTMLElement)) continue if (child.dataset.chipTarget) { const chipBoundary = boundaryForChip(node, child, index, remaining) if (chipBoundary.boundary) return chipBoundary.boundary remaining = chipBoundary.remaining continue } const childLength = serializeInlineNode(child).length if (remaining <= childLength) { return findSelectionBoundary(child, remaining) } remaining -= childLength } return null } export function applySelectionRange( root: HTMLDivElement, selectionRange: InlineSelectionRange, ) { const selection = window.getSelection() if (!selection) return const range = document.createRange() const serializedLength = serializeInlineNode(root).length const start = Math.max(0, Math.min(selectionRange.start, selectionRange.end, serializedLength)) const end = Math.max(start, Math.min(Math.max(selectionRange.start, selectionRange.end), serializedLength)) const startBoundary = findSelectionBoundary(root, start) ?? boundaryAtEditorEnd(root) const endBoundary = findSelectionBoundary(root, end) ?? boundaryAtEditorEnd(root) range.setStart(startBoundary.container, startBoundary.offset) range.setEnd(endBoundary.container, endBoundary.offset) selection.removeAllRanges() selection.addRange(range) } export function applySelectionIndex( root: HTMLDivElement, selectionIndex: number, ) { applySelectionRange(root, { start: selectionIndex, end: selectionIndex }) }