Get a range's start and end offset's relative to its parent container
Asked Answered
C

4

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.

Comedy answered 27/1, 2011 at 0:36 Comment(3)
>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.Konstance
If the user selects everything in #parent, his selection will include some HTML tags (<a> and <p>)Comedy
#64619229Aeriel
U
245

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;
}
Urfa answered 27/1, 2011 at 1:8 Comment(29)
@TimDown: Having trouble getting this to work with a modal/iframe: jsfiddle.net/QcN4G. Any suggestions?Flattery
@Travesty3: Yes: use the iframe's Window and Document objects instead of window and document. I've updated my answer and your example: jsfiddle.net/QcN4G/2Urfa
@TimDown: Great, thanks! For my specific example (using TinyMCE), I actually found a simpler way: tinyMCE.execCommand('mceInsertContent', false, newContent);, which I found here. But +1 for the help!Flattery
@RafaelDiaz: Yes, and any other line breaks implied by HTML or CSS. It's not an ideal solution.Urfa
@TimDown is there any way of modifying this to getting the position via that elements html instead? e.g. Html string: "<div class="c1">Hello</div> And the cursor has sellected "el" in Hello it would return 17Robson
@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.Urfa
@TimDown I'd suggest to add a win.getSelection().rangeCount > 0 check before running win.getSelection().getRangeAt(0), to prevent errors as described in #22935820. I've run into the problem myself, the rangeCount check fixed itOwlish
@Gregor: Fair point. I do usually add that check in my selection-related answers but obviously omitted it here. Thanks.Urfa
Be careful, the fiddle is NOT up to date. The function work perfectly tough :).Cull
@Richard: Do you just mean that the fiddle didn't have the rangeCount check? I've fixed that now.Urfa
@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.Ruddle
@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.Souter
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!!!Aranyaka
Does anyone know how to deal with linebreaks?Begird
@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.Urfa
How is this answer accepted? It only returns the cursor offset, and not the start and end offsets, like the user asked.Cheddite
@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.Urfa
I'm making a new plugin like jquery mention input. Im stuck in the cursor position. Your answer helps me a lot. Many thanks!Aria
Shouldn't the first instance of end = preCaretTextRange.text.length; actually be start = preCaretTextRange.text.length;?Hundred
@adam0101: Yes, definitely. Thanks.Urfa
@TimDown Why do we need element.document? All browser supports element.ownerDocumentSouthbound
@KimchiMan: You don't any more. element.document was for IE 5 and 5.5.Urfa
Is there an example where with your rangy library where the caret position is provided while respecting line breaks?Eyeless
How can you generalize it to return a selection (start, end) if there is such?Tillie
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?Stagy
@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.Urfa
@TimDown Hey I've been searching for something like this for ages. This is genus, nice work man!Cresting
@TimDown if there is an image tag in it then it is not counting it's position. Can you please fix this. jsfiddle.net/t7gxej62Mcabee
@AzamAlvi Not easily, no. This answer is really only for quite a limited, specific use case.Urfa
P
29

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>
Plumbago answered 24/1, 2019 at 17:33 Comment(3)
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)Confection
Works perfectly thanks so much.document.activeElement wasn't working for some reason, so I used the single div I was working with.Milka
Thanks for this nice work. This is the only one I found which deals correctly with BR tags.Rom
V
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; // *
}
Vanguard answered 19/9, 2012 at 18:35 Comment(6)
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.Brycebryn
I know this is 4 year old post, but can we highlight a word in such scenario where I have range to be selectedDaughterinlaw
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?Carpo
@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.Vanguard
@CodyCrumrine As an aside, subtracting something by null is equivalent to using 0 (but not sure if that's true in all Javascript engines). :)Mattland
@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.Melentha
G
3

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)
Gomphosis answered 16/6, 2020 at 20:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.