javascript : focusOffset with html tags

I have a contenteditable div as follow (| = cursor position):

<div id="mydiv" contenteditable="true">lorem ipsum <spanclass="highlight">indol|or sit</span> amet consectetur <span class='tag'>adipiscing</span> elit</div>

I would like to get the current cursor position including html tags. My code :

var offset = document.getSelection().focusOffset;

Offset is returning 5 (full text from the last tag) but i need it to handle html tags. The expected return value is 40. The code has to work with all recents browsers. (i also checked this : window.getSelection() offset with HTML tags? but it doesn't answer my question). Any ideas ?

Answers:

Answer

Another way to do it is by adding a temporary marker in the DOM and calculating the offset from this marker. The algorithm looks for the HTML serialization of the marker (its outerHTML) within the inner serialization (the innerHTML) of the div of interest. Repeated text is not a problem with this solution.

For this to work, the marker's serialization must be unique within its div. You cannot control what users type into a field but you can control what you put into the DOM so this should not be difficult to achieve. In my example, the marker is made unique statically: by choosing a class name unlikely to cause a clash ahead of time. It would also be possible to do it dynamically, by checking the DOM and changing the class until it is unique.

I have a fiddle for it (derived from Alvaro Montoro's own fiddle). The main part is:

function getOffset() {

    if ($("." + unique).length)
        throw new Error("marker present in document; or the unique class is not unique");

    // We could also use rangy.getSelection() but there's no reason here to do this.
    var sel = document.getSelection();

    if (!sel.rangeCount)
        return; // No ranges.

    if (!sel.isCollapsed)
        return; // We work only with collapsed selections.

    if (sel.rangeCount > 1)
        throw new Error("can't handle multiple ranges");

    var range = sel.getRangeAt(0);
    var saved = rangy.serializeSelection();
    // See comment below.
    $mydiv[0].normalize();

    range.insertNode($marker[0]);
    var offset = $mydiv.html().indexOf($marker[0].outerHTML);
    $marker.remove();

    // Normalizing before and after ensures that the DOM is in the same shape before 
    // and after the insertion and removal of the marker.
    $mydiv[0].normalize();
    rangy.deserializeSelection(saved);

    return offset;
}

As you can see, the code has to compensate for the addition and removal of the marker into the DOM because this causes the current selection to get lost:

  1. Rangy is used to save the selection and restore it afterwards. Note that the save and restore could be done with something lighter than Rangy but I did not want to load the answer with minutia. If you decide to use Rangy for this task, please read the documentation because it is possible to optimize the serialization and deserialization.

  2. For Rangy to work, the DOM must be in exactly the same state before and after the save. This is why normalize() is called before we add the marker and after we remove it. What this does is merge immediately adjacent text nodes into a single text node. The issue is that adding a marker to the DOM can cause a text node to be broken into two new text nodes. This causes the selection to be lost and, if not undone with a normalization, would cause Rangy to be unable to restore the selection. Again, something lighter than calling normalize could do the trick but I did not want to load the answer with minutia.

Answer

EDIT: This is an old answer that doesn't work for OP's requirement of having nodes with the same text. But it's cleaner and lighter if you don't have that requirement.

Here is one option that you can use and that works in all major browsers:

  1. Get the offset of the caret within its node (document.getSelection().anchorOffset)
  2. Get the text of the node in which the caret is located (document.getSelection().anchorNode.data)
  3. Get the offset of that text within #mydiv by using indexOf()
  4. Add the values obtained in 1 and 3, to get the offset of the caret within the div.

The code would look like this for your particular case:

var offset = document.getSelection().anchorOffset;
var text = document.getSelection().anchorNode.data;
var textOffset = $("#mydiv").html().indexOf( text );

offsetCaret = textOffset + offset;

You can see a working demo on this JSFiddle (view the console to see the results).

And a more generic version of the function (that allows to pass the div as a parameter, so it can be used with different contenteditable) on this other JSFiddle:

function getCaretHTMLOffset(obj) {

    var offset = document.getSelection().anchorOffset;
    var text = document.getSelection().anchorNode.data;
    var textOffset = obj.innerHTML.indexOf( text );

    return textOffset + offset;

}

About this answer

  • It will work in all recent browsers as requested (tested on Chrome 42, Firefox 37, and Explorer 11).
  • It is short and light, and doesn't require any external library (not even jQuery)
  • Issue: If you have different nodes with the same text, it may return the offset of the first occurrence instead of the real position of the caret.
Answer

NOTE: This solution works even in nodes with repeated text, but it detects html entities (e.g.: &nbsp;) as only one character.

I came up with a completely different solution based on processing the nodes. It is not as clean as the old answer (see other answer), but it works fine even when there are nodes with the same text (OP's requirement).

This is a description of how it works:

  1. Create a stack with all the parent elements of the node in which the caret is located.
  2. While the stack is not empty, traverse the nodes of the containing element (initially the content editable div).
  3. If the node is not the same one at the top of the stack, add its size to the offset.
  4. If the node is the same as the one at the top of the stack: pop it from the stack, go to step 2.

The code is like this:

function getCaretOffset(contentEditableDiv) {

    // read the node in which the caret is and store it in a stack
    var aux = document.getSelection().anchorNode;
    var stack = [ aux ];

    // add the parents to the stack until we get to the content editable div
    while ($(aux).parent()[0] != contentEditableDiv) { aux = $(aux).parent()[0]; stack.push(aux); }

    // traverse the contents of the editable div until we reach the one with the caret
    var offset   = 0;
    var currObj  = contentEditableDiv;
    var children = $(currObj).contents();
    while (stack.length) {
        // add the lengths of the previous "siblings" to the offset
        for (var x = 0; x < children.length; x++) {
            if (children[x] == stack[stack.length-1]) {
                // if the node is not a text node, then add the size of the opening tag
                if (children[x].nodeType != 3) { offset += $(children[x])[0].outerHTML.indexOf(">") + 1; }
                break;
            } else {
                if (children[x].nodeType == 3) {
                    // if it's a text node, add it's size to the offset
                    offset += children[x].length;
                } else {
                    // if it's a tag node, add it's size + the size of the tags
                    offset += $(children[x])[0].outerHTML.length;
                }
            }
        }

        // move to a more inner container
        currObj  = stack.pop();
        children = $(currObj).contents();
    }

    // finally add the offset within the last node
    offset += document.getSelection().anchorOffset;

    return offset;
}

You can see a working demo on this JSFiddle.


About this answer:

  • It works in all major browsers.
  • It is light and doesn't require external libraries (apart from jQuery)
  • It has an issue: html entities like &nbsp; are counted as one character only.

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us

©2020 All rights reserved.