I used John Ernest's excellent code, and reworked it a bit for my needs:
- Using TypeScript (in an Angular application);
- Using a slightly different data structure.
And while working on it, I stumbled on the little known (or little used) TreeWalker, and simplified the code further, as it allows to get rid of recursivity.
A possible optimization could be to walk the tree once to find both start node and end node, but:
- I doubt the speed gain would be perceptible by the user, even at the end of a huge, complex page;
- It would make the algorithm more complex and less readable.
Instead, I treated the case where the start is the same as the end (just a caret, no real selection).
[EDIT] It seems that range's nodes are always of Text type, so I simplified code a bit more, and it allows to get the node length without casting it.
Here is the code:
export type CountingState = {
countBeforeNode: number;
offsetInNode: number;
node?: Text; // Always of Text type
};
export type RangeOffsets = {
start: CountingState;
end: CountingState;
offsets: { start: number; end: number; }
};
export function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE;
}
export function getCaretPosition(container: Node): RangeOffsets | undefined {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { return undefined; }
const range = selection.getRangeAt(0);
const start = countUntilEndNode(container, range.startContainer as Text, range.startOffset);
const end = range.collapsed ? start : countUntilEndNode(container, range.endContainer as Text, range.endOffset);
const offsets = { start: start.countBeforeNode + start.offsetInNode, end: end.countBeforeNode + end.offsetInNode };
const rangeOffsets: RangeOffsets = { start, end, offsets };
return rangeOffsets;
}
export function setCaretPosition(container: Node, start: number, end: number): boolean {
const selection = window.getSelection();
if (!selection) { return false; }
const startState = countUntilOffset(container, start);
const endState = start === end ? startState : countUntilOffset(container, end);
const range = document.createRange(); // new Range() doesn't work for me!
range.setStart(startState.node!, startState.offsetInNode);
range.setEnd(endState.node!, endState.offsetInNode);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function countUntilEndNode(
parent: Node,
endNode: Text,
offset: number,
countingState: CountingState = { countBeforeNode: 0, offsetInNode: 0 },
): CountingState {
const treeWalker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
while (treeWalker.nextNode()) {
const node = treeWalker.currentNode as Text;
if (node === endNode) {
// We found the target node, memorize it.
countingState.node = node;
countingState.offsetInNode = offset;
break;
}
// Add length of text nodes found in the way, until we find the target node.
countingState.countBeforeNode += node.length;
}
return countingState;
}
function countUntilOffset(
parent: Node,
offset: number,
countingState: CountingState = { countBeforeNode: 0, offsetInNode: 0 },
): CountingState {
const treeWalker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
while (treeWalker.nextNode()) {
const node = treeWalker.currentNode as Text;
if (countingState.countBeforeNode <= offset && offset < countingState.countBeforeNode + node.length) {
countingState.offsetInNode = offset - countingState.countBeforeNode;
countingState.node = node;
break;
}
countingState.countBeforeNode += node.length;
}
return countingState;
}