/**
* Selection and caret reading utilities.
*
* This module provides functions for reading the current selection/caret state
* from the DOM. It is the foundation for other caret utilities.
*/
/**
* Returns TextNode containing a caret and a caret offset in it.
* Returns null if there is no caret set.
*
* Handles a case when focusNode is an ElementNode and focusOffset is a child index,
* returns child node with focusOffset index as a new focusNode.
*
* @returns Tuple of [node, offset] or [null, 0] if no selection
*/
export const getCaretNodeAndOffset = (): [Node | null, number] => {
const selection = window.getSelection();
if (selection === null) {
return [null, 0];
}
const initialFocusNode = selection.focusNode;
const initialFocusOffset = selection.focusOffset;
if (initialFocusNode === null) {
return [null, 0];
}
/**
* Case when focusNode is an Element (or Document). In this case, focusOffset is a child index.
* We need to return child with focusOffset index as a new focusNode.
*
*
|hello
<---- Selection references to instead of text node
*/
if (initialFocusNode.nodeType === Node.TEXT_NODE || initialFocusNode.childNodes.length === 0) {
return [initialFocusNode, initialFocusOffset];
}
/**
* In normal cases, focusOffset is a child index.
*/
const regularChild = initialFocusNode.childNodes[initialFocusOffset];
if (regularChild !== undefined) {
return [regularChild, 0];
}
/**
* But in Firefox, focusOffset can be 1 with the single child.
*/
const fallbackChild = initialFocusNode.childNodes[initialFocusOffset - 1] ?? null;
const textContent = fallbackChild?.textContent ?? null;
return [fallbackChild, textContent !== null ? textContent.length : 0];
};
/**
* Get the current caret offset within a contenteditable element.
* Returns the number of text characters from the start of the element to the caret.
*
* @param input - Optional input element. If not provided, uses the current selection's container.
* @returns Offset in text characters, or 0 if no selection
*/
export const getCaretOffset = (input?: HTMLElement): number => {
const selection = window.getSelection();
if (selection === null || selection.rangeCount === 0) {
return 0;
}
const range = selection.getRangeAt(0);
// If no input provided, try to find the contenteditable ancestor
const container = input ?? range.startContainer.parentElement?.closest('[contenteditable="true"]');
if (container === null || container === undefined) {
return 0;
}
// Create a range from start of input to current caret position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(container);
preCaretRange.setEnd(range.startContainer, range.startOffset);
// Get the text length up to the caret
return preCaretRange.toString().length;
};