101

Suppose I have this HTML element:

<div id="parent">
 Hello everyone! <a>This is my home page</a>
 <p>Bye!</p>
</div>

And the user selects "home" with his mouse.

I want to be able to determine how many characters into #parent his selection starts (and how many characters from the end of #parent his selection ends). This should work even if he selects an HTML tag. (And I need it to work in all browsers)

range.startOffset looks promising, but it is an offset relative only to the range's immediate container, and is a character offset only if the container is a text node.

generalhenry
  • 17,227
  • 4
  • 48
  • 63
Tom Lehman
  • 85,973
  • 71
  • 200
  • 272
  • >This should work even if he selects an HTML tag< What do you mean by this? How will someone select a HTML tag? Please explain. – Satyajit Jan 27 '11 at 00:42
  • If the user selects everything in `#parent`, his selection will include some HTML tags ( and

    )

    – Tom Lehman Jan 27 '11 at 02:45
  • https://stackoverflow.com/questions/64618729/how-do-you-get-and-set-the-caret-position-in-a-contenteditable/64823701#64823701 – user875234 Nov 13 '20 at 15:56

4 Answers4

242

UPDATE

As pointed out in the comments, my original answer (below) only returns the end of the selection or the caret position. It's fairly easy to adapt the code to return a start and an end offset; here's an example that does so:

function getSelectionCharacterOffsetWithin(element) {
    var start = 0;
    var end = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.startContainer, range.startOffset);
            start = preCaretRange.toString().length;
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            end = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToStart", textRange);
        start = preCaretTextRange.text.length;
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        end = preCaretTextRange.text.length;
    }
    return { start: start, end: end };
}

function reportSelection() {
  var selOffsets = getSelectionCharacterOffsetWithin( document.getElementById("editor") );
  document.getElementById("selectionLog").innerHTML = "Selection offsets: " + selOffsets.start + ", " + selOffsets.end;
}

window.onload = function() {
  document.addEventListener("selectionchange", reportSelection, false);
  document.addEventListener("mouseup", reportSelection, false);
  document.addEventListener("mousedown", reportSelection, false);
  document.addEventListener("keyup", reportSelection, false);
};
#editor {
  padding: 5px;
  border: solid green 1px;
}
Select something in the content below:

<div id="editor" contenteditable="true">A <i>wombat</i> is a marsupial native to <b>Australia</b></div>
<div id="selectionLog"></div>

Here's a function that will get the character offset of the caret within the specified element; however, this is a naive implementation that will almost certainly have inconsistencies with line breaks, and makes no attempt to deal with text hidden via CSS (I suspect IE will correctly ignore such text while other browsers will not). To handle all this stuff properly would be tricky. I've now attempted it for my Rangy library.

Live example: http://jsfiddle.net/TjXEG/900/

function getCaretCharacterOffsetWithin(element) {
    var caretOffset = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            caretOffset = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        caretOffset = preCaretTextRange.text.length;
    }
    return caretOffset;
}
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • @TimDown: Having trouble getting this to work with a modal/iframe: http://jsfiddle.net/QcN4G/. Any suggestions? – Travesty3 Jun 19 '13 at 18:28
  • @Travesty3: Yes: use the iframe's `Window` and `Document` objects instead of `window` and `document`. I've updated my answer and your example: http://jsfiddle.net/QcN4G/2/ – Tim Down Jun 19 '13 at 23:11
  • 3
    @TimDown: Great, thanks! For my specific example (using TinyMCE), I actually found a simpler way: [`tinyMCE.execCommand('mceInsertContent', false, newContent);`](http://jsfiddle.net/QcN4G/3/), which I found [here](http://www.tinymce.com/forum/viewtopic.php?pid=12436#p12436). But +1 for the help! – Travesty3 Jun 20 '13 at 01:56
  • 4
    @RafaelDiaz: Yes, and any other line breaks implied by HTML or CSS. It's not an ideal solution. – Tim Down May 28 '14 at 07:58
  • 1
    @TimDown is there any way of modifying this to getting the position via that elements html instead? e.g. Html string: "
    Hello
    And the cursor has sellected "el" in Hello it would return 17
    – Fred Johnson Jul 21 '14 at 16:03
  • @user2330270: Not really. There are many different valid HTML representations of the same DOM so an offset within one may not be valid within another. I assume that what you want is probably the offset within the original HTML sent to the page but that's very diffifcult: the issue is that once the browser has parsed a page's HTML into DOM, that HTML is thrown away and can't be retrieved via JavaScript running in the page. In theory you could do it by re-requesting the original page and parsing the HTML by hand but that's a pretty insane thing to do. – Tim Down Jul 22 '14 at 08:56
  • 1
    @TimDown I'd suggest to add a `win.getSelection().rangeCount > 0` check before running `win.getSelection().getRangeAt(0)`, to prevent errors as described in http://stackoverflow.com/questions/22935320/uncaught-indexsizeerror-failed-to-execute-getrangeat-on-selection-0-is-not. I've run into the problem myself, the rangeCount check fixed it – Gregor Aug 21 '14 at 08:59
  • @Gregor: Fair point. I do usually add that check in my selection-related answers but obviously omitted it here. Thanks. – Tim Down Aug 21 '14 at 09:57
  • Be careful, the fiddle is NOT up to date. The function work perfectly tough :). – Richard Sep 09 '14 at 12:27
  • @Richard: Do you just mean that the fiddle didn't have the `rangeCount` check? I've fixed that now. – Tim Down Sep 09 '14 at 13:39
  • @TimDown I googled several variations of this, and your name came up in answers for every question I encountered. Quite nice code. Thanks a lot for an excellent technique. – Regular Jo Apr 22 '15 at 22:53
  • @TimDown Do you know of a way to reverse this, creating a selection from the offsets generated here? My attempts are failing with a `Failed to execute 'setEnd' on 'Range': There is no child at offset 30.` – Jordan Wallwork Aug 11 '15 at 15:32
  • Omg I just hate the fact that I can only upvote once. Thanks for that piece of code! Life saver! **Edit:** Omg and it even respects elements contained inside the editable!!! – Stefan Falk Mar 13 '16 at 20:19
  • 3
    Does anyone know how to deal with linebreaks? – k102 Apr 11 '16 at 09:24
  • @k102: Dealing with line breaks is tricky and trying to deal with all possible scenarios in all browsers is next to impossible. I have given it my best shot for Rangy, as noted in the answer. – Tim Down Apr 13 '16 at 10:33
  • How is this answer accepted? It only returns the cursor offset, and not the start and end offsets, like the user asked. – omerts Jul 25 '16 at 16:29
  • @omerts: Fair point. Looks like an oversight by me. However, the technique only needs a little modification to get start and end offsets. I'll update my answer. – Tim Down Jul 26 '16 at 14:30
  • I'm making a new plugin like `jquery mention input`. Im stuck in the cursor position. Your answer helps me a lot. Many thanks! – Tân Jul 26 '16 at 15:27
  • Shouldn't the first instance of `end = preCaretTextRange.text.length;` actually be `start = preCaretTextRange.text.length;`? – adam0101 Dec 01 '16 at 22:49
  • @adam0101: Yes, definitely. Thanks. – Tim Down Dec 02 '16 at 10:43
  • @TimDown Why do we need `element.document`? All browser supports `element.ownerDocument` – KimchiMan May 21 '17 at 05:05
  • 2
    @KimchiMan: You don't any more. `element.document` was for IE 5 and 5.5. – Tim Down May 22 '17 at 10:26
  • 1
    Is there an example where with your rangy library where the caret position is provided while respecting line breaks? – AshD Nov 11 '17 at 04:37
  • How can you generalize it to return a selection (start, end) if there is such? – Nathan B Jun 02 '18 at 19:23
  • `Selection#getRangeAt(0)` returns the first range in the DOM if multiple ranges are supported, probably, it would be better to use `Selection.focusNode`; also why not to iterate over text nodes to count the character position? – 4esn0k Apr 26 '20 at 13:44
  • 1
    @4esn0k: Using `focusNode` and `focusOffset` is probably an improvement. There's no need to iterate over text nodes though because the `toString()` method of a range does that for you. – Tim Down Apr 27 '20 at 10:17
  • @TimDown Hey I've been searching for something like this for ages. This is genus, nice work man! – 今際のアリス Feb 19 '21 at 21:32
  • @TimDown if there is an image tag in it then it is not counting it's position. Can you please fix this. http://jsfiddle.net/t7gxej62/ – Azam Alvi Dec 09 '21 at 13:39
  • @AzamAlvi Not easily, no. This answer is really only for quite a limited, specific use case. – Tim Down Dec 10 '21 at 09:36
28

I know this is a year old, but this post is a top search result for a lot of questions on finding the Caret position and I found this useful.

I was trying to use Tim's excellent script above to find the new cursor position after having drag-dropped an element from one position to another in a content editable div. It worked perfectly in FF and IE, but in Chrome, the dragging action highlighted all content between the beginning and end of the drag, which resulted in the returned caretOffset being too large or small (by the length of the selected area).

I added a few lines to the first if statement to check if text has been selected and adjust the result accordingly. The new statement is below. Forgive me if it's inappropriate to add this here, as it's not what the OP was trying to do, but as I said, several searches on info related to Caret position led me to this post, so it's (hopefully) likely to help someone else.

Tim's first if statement with added lines(*):

if (typeof window.getSelection != "undefined") {
  var range = window.getSelection().getRangeAt(0);
  var selected = range.toString().length; // *
  var preCaretRange = range.cloneRange();
  preCaretRange.selectNodeContents(element);
  preCaretRange.setEnd(range.endContainer, range.endOffset);

  caretOffset = preCaretRange.toString().length - selected; // *
}
Raine Revere
  • 30,985
  • 5
  • 40
  • 52
Cody Crumrine
  • 1,461
  • 1
  • 14
  • 19
  • 1
    Does anyone know how to highlight a word if I already have offset values with me(I am getting from server side JSON response) and want to highlight the word based on that? I couldn't find anything on rangy library where I can simply plug in those two values (`start` character offset and `stop` character offset) and highlight the word. Please advise. – John Sep 16 '16 at 20:11
  • I know this is 4 year old post, but can we highlight a word in such scenario where I have range to be selected – Varun Oct 05 '16 at 10:58
  • Is there a reason you check for `selected` before subtracting it? If it's 0, then you aren't changing the `caretOffset` by subtracting 0, right? – Donnie D'Amato Jul 15 '17 at 14:58
  • 1
    @DonnieD'Amato Good point. I did some testing and always got selected == 0 when there was no selection (never undefined or null or anything else unexpected) so you should be safe to skip that check and always subtract selected. – Cody Crumrine Jul 24 '17 at 13:49
  • 1
    @CodyCrumrine As an aside, subtracting something by `null` is equivalent to using `0` (but not sure if that's true in all Javascript engines). :) – aleclarson Nov 12 '17 at 18:48
  • @CodyCrumrine It's sad that `window.getSelection()` doesn't work on Safari. I see that many people have this problem but currently no solution for Safari. – KYin Jun 27 '22 at 03:37
26

After experimenting a few days I found a approach that looks promising. Because selectNodeContents() does not handle <br> tags correctly, I wrote a custom algorithm to determine the text length of each node inside a contenteditable. To calculate e.g. the selection start, I sum up the text lengths of all preceding nodes. That way, I can handle (multiple) line breaks:

var editor = null;
var output = null;

const getTextSelection = function (editor) {
    const selection = window.getSelection();

    if (selection != null && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);

        return {
            start: getTextLength(editor, range.startContainer, range.startOffset),
            end: getTextLength(editor, range.endContainer, range.endOffset)
        };
    } else
        return null;
}

const getTextLength = function (parent, node, offset) {
    var textLength = 0;

    if (node.nodeName == '#text')
        textLength += offset;
    else for (var i = 0; i < offset; i++)
        textLength += getNodeTextLength(node.childNodes[i]);

    if (node != parent)
        textLength += getTextLength(parent, node.parentNode, getNodeOffset(node));

    return textLength;
}

const getNodeTextLength = function (node) {
    var textLength = 0;

    if (node.nodeName == 'BR')
        textLength = 1;
    else if (node.nodeName == '#text')
        textLength = node.nodeValue.length;
    else if (node.childNodes != null)
        for (var i = 0; i < node.childNodes.length; i++)
            textLength += getNodeTextLength(node.childNodes[i]);

    return textLength;
}

const getNodeOffset = function (node) {
    return node == null ? -1 : 1 + getNodeOffset(node.previousSibling);
}

window.onload = function () {
    editor = document.querySelector('.editor');
    output = document.querySelector('#output');

    document.addEventListener('selectionchange', handleSelectionChange);
}

const handleSelectionChange = function () {
    if (isEditor(document.activeElement)) {
        const textSelection = getTextSelection(document.activeElement);

        if (textSelection != null) {
            const text = document.activeElement.innerText;
            const selection = text.slice(textSelection.start, textSelection.end);
            print(`Selection: [${selection}] (Start: ${textSelection.start}, End: ${textSelection.end})`);
        } else
            print('Selection is null!');
    } else
        print('Select some text above');
}

const isEditor = function (element) {
    return element != null && element.classList.contains('editor');
}

const print = function (message) {
    if (output != null)
        output.innerText = message;
    else
        console.log('output is null!');
}
* {
    font-family: 'Georgia', sans-serif;
    padding: 0;
    margin: 0;
}

body {
    margin: 16px;
}

.p {
    font-size: 16px;
    line-height: 24px;
    padding: 0 2px;
}

.editor {
    border: 1px solid #0000001e;
    border-radius: 2px;
    white-space: pre-wrap;
}

#output {
    margin-top: 16px;
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="./script.js" async></script>
    <link href="./stylesheet.css" rel="stylesheet">
    <title>Caret Position</title>
</head>
<body>
    <p class="editor" contenteditable="true"><em>Write<br></em><br>some <br>awesome <b><em>text </em></b>here...</p>
    <p id="output">Select some text above</p>
</body>
</html>
Candor
  • 493
  • 4
  • 12
  • I had to wrap the getTextLength and getNodeTextLength in an if condition checking ````if (node != null)````. Additionally I added a check for a null ````node.parentNode```` after ````if (node != parent)```` – RugerSR9 Oct 02 '19 at 16:28
  • Works perfectly thanks so much.`document.activeElement` wasn't working for some reason, so I used the single div I was working with. – nreh May 03 '20 at 11:04
  • Thanks for this nice work. This is the only one I found which deals correctly with BR tags. – Chrysotribax Dec 16 '21 at 13:24
2

This solution works by counting length of text content of previous siblings walking back up to the parent container. It probably doesn't cover all edge cases, although it does handle nested tags of any depth, but it's a good, simple place to start from if you have a similar need.

  calculateTotalOffset(node, offset) {
    let total = offset
    let curNode = node

    while (curNode.id != 'parent') {
      if(curNode.previousSibling) {
        total += curNode.previousSibling.textContent.length

        curNode = curNode.previousSibling
      } else {
        curNode = curNode.parentElement
      }
    }

   return total
 }

 // after selection

let start = calculateTotalOffset(range.startContainer, range.startOffset)
let end = calculateTotalOffset(range.endContainer, range.endOffset)
tfwright
  • 2,844
  • 21
  • 37