import { type ResolvedPos, NodeRange } from '@tiptap/pm/model' import { SelectionRange } from '@tiptap/pm/state' export interface GetSelectionRangesOptions { /** * Whether nodes should be included when the selection only overlaps their * start or end content boundary. * @default true */ extendOnBoundaryOverlap?: boolean } function getNodeContentBounds( nodeStart: number, nodeSize: number, node: { isText: boolean; isAtom: boolean }, ) { const contentOffset = node.isText || node.isAtom ? 0 : 1 return { start: nodeStart + contentOffset, end: nodeStart + nodeSize - contentOffset, } } /** * Calculates node-aligned selection ranges between two resolved positions. * * The helper derives a suitable depth when none is provided and returns a * `SelectionRange` for each matching child node in the computed `NodeRange`. * Each returned range exposes `$from` as the resolved start position of the * node selection and `$to` as the resolved end position. * * @param $from The resolved anchor position where the selection starts. * @param $to The resolved head position where the selection ends. * @param depth An optional depth to force when creating the ProseMirror `NodeRange`. * When omitted, the depth is inferred from the shared depth of `$from` and `$to`. * @param options Optional behavior flags for how boundary nodes are handled. * @param options.extendOnBoundaryOverlap Whether touching only a node's start * or end content boundary should still include that node in the returned ranges. * @returns An array of `SelectionRange` objects for the nodes covered at the * computed depth. * @example * ```ts * const { $from, $to } = editor.state.selection * const ranges = getSelectionRanges($from, $to, undefined, { * extendOnBoundaryOverlap: false, * }) * * ranges.forEach(range => { * console.log(range.$from.pos, range.$to.pos) * }) * ``` */ export function getSelectionRanges( $from: ResolvedPos, $to: ResolvedPos, depth?: number, options: GetSelectionRangesOptions = {}, ): SelectionRange[] { const ranges: SelectionRange[] = [] const doc = $from.node(0) const { extendOnBoundaryOverlap = true } = options // Determine the appropriate depth if (typeof depth === 'number' && depth >= 0) { // Use the provided depth } else if ($from.sameParent($to)) { depth = Math.max(0, $from.sharedDepth($to.pos) - 1) } else { depth = $from.sharedDepth($to.pos) } const nodeRange = new NodeRange($from, $to, depth) const offset = nodeRange.depth === 0 ? 0 : doc.resolve(nodeRange.start).posAtIndex(0) nodeRange.parent.forEach((node, pos) => { const from = offset + pos const to = from + node.nodeSize const contentBounds = getNodeContentBounds(from, node.nodeSize, node) const overlapsNodeContent = extendOnBoundaryOverlap ? $to.pos >= contentBounds.start && $from.pos <= contentBounds.end : $to.pos > contentBounds.start && $from.pos < contentBounds.end if (from < nodeRange.start || from >= nodeRange.end) { return } if (!overlapsNodeContent) { return } const selectionRange = new SelectionRange(doc.resolve(from), doc.resolve(to)) ranges.push(selectionRange) }) return ranges }