Using character offsets doesn't work if the cursor is at the beginning of a new paragraph. The approach below walks the DOM node and counts all nodes towards the offset. It also handles start and end individually to make sure that the selection remembers its exact position. Here is an updated version that I use in a major project (see functions at end):
/* Gets the offset of a node within another node. Text nodes are counted a n where n is the length. Entering (or passing) an element is one offset. Exiting is 0. */ var getNodeOffset = function(start, dest) { var offset = 0; var node = start; var stack = []; while (true) { if (node === dest) { return offset; } // Go into children if (node.firstChild) { // Going into first one doesn't count if (node !== start) offset += 1; stack.push(node); node = node.firstChild; } // If can go to next sibling else if (stack.length > 0 && node.nextSibling) { // If text, count length (plus 1) if (node.nodeType === 3) offset += node.nodeValue.length + 1; else offset += 1; node = node.nextSibling; } else { // If text, count length if (node.nodeType === 3) offset += node.nodeValue.length + 1; else offset += 1; // No children or siblings, move up stack while (true) { if (stack.length <= 1) return offset; var next = stack.pop(); // Go to sibling if (next.nextSibling) { node = next.nextSibling; break; } } } } }; // Calculate the total offsets of a node var calculateNodeOffset = function(node) { var offset = 0; // If text, count length if (node.nodeType === 3) offset += node.nodeValue.length + 1; else offset += 1; if (node.childNodes) { for (var i=0;i<node.childNodes.length;i++) { offset += calculateNodeOffset(node.childNodes[i]); } } return offset; }; // Determine total offset length from returned offset from ranges var totalOffsets = function(parentNode, offset) { if (parentNode.nodeType == 3) return offset; if (parentNode.nodeType == 1) { var total = 0; // Get child nodes for (var i=0;i<offset;i++) { total += calculateNodeOffset(parentNode.childNodes[i]); } return total; } return 0; }; var getNodeAndOffsetAt = function(start, offset) { var node = start; var stack = []; while (true) { // If arrived if (offset <= 0) return { node: node, offset: 0 }; // If will be within current text node if (node.nodeType == 3 && (offset <= node.nodeValue.length)) return { node: node, offset: Math.min(offset, node.nodeValue.length) }; // Go into children (first one doesn't count) if (node.firstChild) { if (node !== start) offset -= 1; stack.push(node); node = node.firstChild; } // If can go to next sibling else if (stack.length > 0 && node.nextSibling) { // If text, count length if (node.nodeType === 3) offset -= node.nodeValue.length + 1; else offset -= 1; node = node.nextSibling; } else { // No children or siblings, move up stack while (true) { if (stack.length <= 1) { // No more options, use current node if (node.nodeType == 3) return { node: node, offset: Math.min(offset, node.nodeValue.length) }; else return { node: node, offset: 0 }; } var next = stack.pop(); // Go to sibling if (next.nextSibling) { // If text, count length if (node.nodeType === 3) offset -= node.nodeValue.length + 1; else offset -= 1; node = next.nextSibling; break; } } } } }; exports.save = function(containerEl) { // Get range var selection = window.getSelection(); if (selection.rangeCount > 0) { var range = selection.getRangeAt(0); return { start: getNodeOffset(containerEl, range.startContainer) + totalOffsets(range.startContainer, range.startOffset), end: getNodeOffset(containerEl, range.endContainer) + totalOffsets(range.endContainer, range.endOffset) }; } else return null; }; exports.restore = function(containerEl, savedSel) { if (!savedSel) return; var range = document.createRange(); var startNodeOffset, endNodeOffset; startNodeOffset = getNodeAndOffsetAt(containerEl, savedSel.start); endNodeOffset = getNodeAndOffsetAt(containerEl, savedSel.end); range.setStart(startNodeOffset.node, startNodeOffset.offset); range.setEnd(endNodeOffset.node, endNodeOffset.offset); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); };
This only works on modern browsers (IE 9+ at least).