187

I'm finding tons of good, cross-browser answers on how to set the caret position in a contentEditable element, but none on how to get the caret position in the first place.

What I want to do is know the caret position within a div on keyup. So, when the user is typing text, I can, at any point, know the caret position within the contentEditable element.

<div id="contentBox" contentEditable="true"></div>

$('#contentbox').keyup(function() { 
    // ... ? 
});
d4nyll
  • 11,811
  • 6
  • 54
  • 68
Bertvan
  • 4,943
  • 5
  • 40
  • 61

18 Answers18

168

The following code assumes:

  • There is always a single text node within the editable <div> and no other nodes
  • The editable div does not have the CSS white-space property set to pre

If you need a more general approach that will work content with nested elements, try this answer:

https://stackoverflow.com/a/4812022/96100

Code:

function getCaretPosition(editableDiv) {
  var caretPos = 0,
    sel, range;
  if (window.getSelection) {
    sel = window.getSelection();
    if (sel.rangeCount) {
      range = sel.getRangeAt(0);
      if (range.commonAncestorContainer.parentNode == editableDiv) {
        caretPos = range.endOffset;
      }
    }
  } else if (document.selection && document.selection.createRange) {
    range = document.selection.createRange();
    if (range.parentElement() == editableDiv) {
      var tempEl = document.createElement("span");
      editableDiv.insertBefore(tempEl, editableDiv.firstChild);
      var tempRange = range.duplicate();
      tempRange.moveToElementText(tempEl);
      tempRange.setEndPoint("EndToEnd", range);
      caretPos = tempRange.text.length;
    }
  }
  return caretPos;
}
#caretposition {
  font-weight: bold;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<div id="contentbox" contenteditable="true">Click me and move cursor with keys or mouse</div>
<div id="caretposition">0</div>
<script>
  var update = function() {
    $('#caretposition').html(getCaretPosition(this));
  };
  $('#contentbox').on("mousedown mouseup keydown keyup", update);
</script>
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • Sorry, had to undo the answer: I _am_ going to need other tags. There will be tags within the
    , but no nesting. Will test your solution, maybe this even works for what I need...
    – Bertvan Oct 20 '10 at 16:50
  • Oh, and I know I first said that there wouldn't be any other tags in there. Sorry, my mistake. – Bertvan Oct 20 '10 at 16:51
  • 13
    This won't work if there's any other tags in there. Question: if the caret's inside an `` element inside the ` – Tim Down Oct 20 '10 at 17:01
  • You might want to remove console.log as this will crash in browsers that do not have firebug installed. – Nico Burns Oct 20 '10 at 18:34
  • Did you find a way to get the caret position around html child elements? I am trying to tackle the same problem :( – wilsonpage May 10 '11 at 14:50
  • @pagewil: I've answered the new question you created (http://stackoverflow.com/questions/5951886/how-to-get-caret-position-within-contenteditable-div-with-html-child-elements) – Tim Down May 10 '11 at 15:16
  • I found your snippet when I was searching for this issue, but in my solution I am using the `white-space: pre` property. Would you be able to provide some insight on how to edit your code snippet to handle this (or some information about how the caret is handled differently)? I appreciate your help. Thank you! –  Dec 30 '12 at 22:57
  • I am using this code to get the caret position for non IE browsers, but my problem is that for each new line the caret position begins with 0. I would like it to include also the number of characters on previous lines. Is it possible? I also have asked this question here (http://stackoverflow.com/questions/14565572/contenteditable-div-caret-position) – Crista23 Jan 30 '13 at 12:03
  • This ain't working in firefox (and also in IE). returning `0` or `1` instead of `4` when I type `when` in my Contenteditable div. Chrome is returning `4` as expected. – Mr_Green Apr 26 '13 at 11:06
  • @TimDown I got it. Type `when` and then press `ENTER` and then press `SPACE`. the output in firefox is `0`. It is happening because of `placeCaretAtEnd()` function which you mentioned [here](http://stackoverflow.com/q/4233265/1577396). [**Here is the fiddle**](http://jsfiddle.net/BN5N6/6/). Please help. – Mr_Green Apr 26 '13 at 11:41
  • How can this code be changed to work with child nodes as well? So that the cursor position will count the html tags, etc. – Rafael May 28 '14 at 03:53
  • Beware of this code, if you are typing really fast, the same number will show multiple time (at least in chrome). Here's a [fiddle](http://jsfiddle.net/Akatsukle/fb75rL5g/) – Richard Aug 20 '14 at 09:45
  • 3
    @Richard: Well, `keyup` is likely to be the wrong event for this but is what was used in the original question. `getCaretPosition()` itself is fine within its own limitations. – Tim Down Aug 20 '14 at 10:14
  • 4
    That JSFIDDLE demo fails if I press enter and go on a new line. The position will show 0. – giorgio79 Aug 21 '14 at 15:23
  • 8
    @giorgio79: Yes, because the line break generates a `
    ` or `
    ` element, which violates the first assumption mentioned in the answer. If you need a slightly more general solution, you could try http://stackoverflow.com/a/4812022/96100
    – Tim Down Aug 21 '14 at 16:10
  • 2
    Is there anyway to do this so it includes line number? – Adjit Jul 18 '16 at 16:30
  • Why important the CSS rule for this solution? – sarkiroka Apr 30 '20 at 18:56
  • @sarkiroka: The CSS rule isn't important. It's just there to provide some visual difference between the editable and non-editable parts of the demo. – Tim Down May 04 '20 at 14:46
50

A few wrinkles that I don't see being addressed in other answers:

  1. the element can contain multiple levels of child nodes (e.g. child nodes that have child nodes that have child nodes...)
  2. a selection can consist of different start and end positions (e.g. multiple chars are selected)
  3. the node containing a Caret start/end may not be either the element or its direct children

Here's a way to get start and end positions as offsets to the element's textContent value:

// node_walk: walk the element tree, stop when func(node) returns false
function node_walk(node, func) {
  var result = func(node);
  for(node = node.firstChild; result !== false && node; node = node.nextSibling)
    result = node_walk(node, func);
  return result;
};

// getCaretPosition: return [start, end] as offsets to elem.textContent that
//   correspond to the selected portion of text
//   (if start == end, caret is at given position and no text is selected)
function getCaretPosition(elem) {
  var sel = window.getSelection();
  var cum_length = [0, 0];

  if(sel.anchorNode == elem)
    cum_length = [sel.anchorOffset, sel.extentOffset];
  else {
    var nodes_to_find = [sel.anchorNode, sel.extentNode];
    if(!elem.contains(sel.anchorNode) || !elem.contains(sel.extentNode))
      return undefined;
    else {
      var found = [0,0];
      var i;
      node_walk(elem, function(node) {
        for(i = 0; i < 2; i++) {
          if(node == nodes_to_find[i]) {
            found[i] = true;
            if(found[i == 0 ? 1 : 0])
              return false; // all done
          }
        }

        if(node.textContent && !node.firstChild) {
          for(i = 0; i < 2; i++) {
            if(!found[i])
              cum_length[i] += node.textContent.length;
          }
        }
      });
      cum_length[0] += sel.anchorOffset;
      cum_length[1] += sel.extentOffset;
    }
  }
  if(cum_length[0] <= cum_length[1])
    return cum_length;
  return [cum_length[1], cum_length[0]];
}
mwag
  • 3,557
  • 31
  • 38
  • 7
    This must be selected as the right answer. It works with tags inside the text (the accepted response doesn't) – Hamboy75 Apr 11 '19 at 07:37
  • 1
    Is there a way to include line breaks? Pressing "enter" doesn't isn't changing the result of this function. Also I know it isn't mentioned in the question, but an equivalent "setCaretPosition" would be super helpful to see – Connor Aug 21 '20 at 03:50
  • Re newlines: yes, but it is a somewhat more convoluted solution. newlines are represented in the text nodes as text-less BR nodes inserted into the node tree, which are not properly reflected in textContent. So, to handle them, basically any reference to textContent must be replaced by a function e.g. "getNodeInnerText()" which will walk the node tree and construct the proper text string, and in particular, will insert "\n" for any BR nodes (under most conditions-- it's more subtle than that) – mwag Aug 21 '20 at 13:46
  • setCaretPosition is asked/answered here: https://stackoverflow.com/questions/512528/set-keyboard-caret-position-in-html-textbox (though I use a modified version of the solution, don't remember why) – mwag Aug 21 '20 at 13:46
  • Hello. A few years late to the party. Question, how does one use your code? If I want to display the caret position for example? How do you "manually" save position and later on load it? Thanks for the answer. Have been looking for something like this that works for days. – WeAreDoomed Mar 30 '21 at 13:13
  • This answer doesn't work when window.getSelection().anchorNode and window.getSelection().extentNode are not set – Patlatus Apr 06 '21 at 08:58
  • 1
    @WeAreDoomed please see aforementioned comment re setCaretPosition – mwag Apr 08 '21 at 18:35
30

Kinda late to the party, but in case anyone else is struggling. None of the Google searches I've found for the past two days have come up with anything that works, but I came up with a concise and elegant solution that will always work no matter how many nested tags you have:

function cursor_position() {
    var sel = document.getSelection();
    sel.modify("extend", "backward", "paragraphboundary");
    var pos = sel.toString().length;
    if(sel.anchorNode != undefined) sel.collapseToEnd();

    return pos;
}

// Demo:
var elm = document.querySelector('[contenteditable]');
elm.addEventListener('click', printCaretPosition)
elm.addEventListener('keydown', printCaretPosition)

function printCaretPosition(){
  console.log( cursor_position(), 'length:', this.textContent.trim().length )
}
<div contenteditable>some text here <i>italic text here</i> some other text here <b>bold text here</b> end of text</div>

It selects all the way back to the beginning of the paragraph and then counts the length of the string to get the current position and then undoes the selection to return the cursor to the current position. If you want to do this for an entire document (more than one paragraph), then change paragraphboundary to documentboundary or whatever granularity for your case. Check out the API for more details. Cheers! :)

vsync
  • 118,978
  • 58
  • 307
  • 400
Soubriquet
  • 3,100
  • 10
  • 37
  • 52
  • 1
    If I have ```
    some text here italic text here some other text here bold text here end of text
    ``` Everytime I place cursor before `i` tag or any child html element inside `div`, cursor position is starting at 0. Is there a way to escape this restarting count?
    – vam Feb 05 '19 at 14:20
  • Odd. I'm not getting that behavior in Chrome. What browser are you using? – Soubriquet Feb 08 '19 at 16:02
  • 8
    Looks like selection.modify may or may not be supported on all browsers. https://developer.mozilla.org/en-US/docs/Web/API/Selection – Chris Sullivan Apr 07 '19 at 07:42
  • Very nice. Good job. – Len White Dec 20 '20 at 02:10
  • 1
    Not working in Firefox :/ `NS_ERROR_NOT_IMPLEMENTED` selection.modify looks like it is not really supported on this browser: https://developer.mozilla.org/en-US/docs/Web/API/Selection/modify – fguillen Jul 07 '21 at 13:58
  • This worked very well for me, as I had great difficulty determining which line the text was on - isolating to a particular line works so well. I'm building an @mention tagger lookup, so the options need to appear just below the `@`. I tried a lot of other stuff before finding this. – Caspar Harmer May 23 '23 at 07:12
  • Thank you for this solution! `document.getSelection()` is very cleaver! – RilDev Jul 12 '23 at 10:02
25

$("#editable").on('keydown keyup mousedown mouseup',function(e){
     
       if($(window.getSelection().anchorNode).is($(this))){
       $('#position').html('0')
       }else{
         $('#position').html(window.getSelection().anchorOffset);
       }
 });
body{
  padding:40px;
}
#editable{
  height:50px;
  width:400px;
  border:1px solid #000;
}
#editable p{
  margin:0;
  padding:0;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.0.1/jquery.min.js"></script>
<div contenteditable="true" id="editable">move the cursor to see position</div>
<div>
position : <span id="position"></span>
</div>
h8n
  • 748
  • 5
  • 12
  • 8
    This unfortunately stops working as soon as you hit enter and start on another line (it starts at 0 again - probably counting from the CR/LF). – Ian Sep 13 '16 at 08:33
  • 3
    It does not work properly if you have some Bold and/or Italic words. – user2824371 Jan 29 '18 at 21:13
21

window.getSelection - vs - document.selection

This one works for me:

function getCaretCharOffset(element) {
  var caretOffset = 0;

  if (window.getSelection) {
    var range = window.getSelection().getRangeAt(0);
    var preCaretRange = range.cloneRange();
    preCaretRange.selectNodeContents(element);
    preCaretRange.setEnd(range.endContainer, range.endOffset);
    caretOffset = preCaretRange.toString().length;
  } 

  else if (document.selection && document.selection.type != "Control") {
    var textRange = document.selection.createRange();
    var preCaretTextRange = document.body.createTextRange();
    preCaretTextRange.moveToElementText(element);
    preCaretTextRange.setEndPoint("EndToEnd", textRange);
    caretOffset = preCaretTextRange.text.length;
  }

  return caretOffset;
}


// Demo:
var elm = document.querySelector('[contenteditable]');
elm.addEventListener('click', printCaretPosition)
elm.addEventListener('keydown', printCaretPosition)

function printCaretPosition(){
  console.log( getCaretCharOffset(elm), 'length:', this.textContent.trim().length )
}
<div contenteditable>some text here <i>italic text here</i> some other text here <b>bold text here</b> end of text</div>

The calling line depends on event type, for key event use this:

getCaretCharOffsetInDiv(e.target) + ($(window.getSelection().getRangeAt(0).startContainer.parentNode).index());

for mouse event use this:

getCaretCharOffsetInDiv(e.target.parentElement) + ($(e.target).index())

on these two cases I take care for break lines by adding the target index

vsync
  • 118,978
  • 58
  • 307
  • 400
Jonathan R.
  • 211
  • 2
  • 4
15
function getCaretPosition() {
    var x = 0;
    var y = 0;
    var sel = window.getSelection();
    if(sel.rangeCount) {
        var range = sel.getRangeAt(0).cloneRange();
        if(range.getClientRects()) {
        range.collapse(true);
        var rect = range.getClientRects()[0];
        if(rect) {
            y = rect.top;
            x = rect.left;
        }
        }
    }
    return {
        x: x,
        y: y
    };
}
Tom
  • 679
  • 1
  • 12
  • 29
Nishad Up
  • 3,457
  • 1
  • 28
  • 32
14

Try this:

Caret.js Get caret postion and offset from text field

https://github.com/ichord/Caret.js

demo: http://ichord.github.com/Caret.js

J.Y Han
  • 329
  • 3
  • 8
  • This is sweet. I needed this behavior to set caret to end of a `contenteditable` `li` when clicked on a button to rename `li`'s content. – akinuri Feb 26 '18 at 09:12
  • @AndroidDev I am not the author of Caret.js but have you considered that getting the caret position for all major browsers is more complex than a few lines? **Do you know or have created** a non-bloated alternative that you can share with us? – adelriosantiago Nov 28 '19 at 17:00
8

As this took me forever to figure out using the new window.getSelection API I am going to share for posterity. Note that MDN suggests there is wider support for window.getSelection, however, your mileage may vary.

const getSelectionCaretAndLine = () => {
    // our editable div
    const editable = document.getElementById('editable');

    // collapse selection to end
    window.getSelection().collapseToEnd();

    const sel = window.getSelection();
    const range = sel.getRangeAt(0);

    // get anchor node if startContainer parent is editable
    let selectedNode = editable === range.startContainer.parentNode
      ? sel.anchorNode 
      : range.startContainer.parentNode;

    if (!selectedNode) {
        return {
            caret: -1,
            line: -1,
        };
    }

    // select to top of editable
    range.setStart(editable.firstChild, 0);

    // do not use 'this' sel anymore since the selection has changed
    const content = window.getSelection().toString();
    const text = JSON.stringify(content);
    const lines = (text.match(/\\n/g) || []).length + 1;

    // clear selection
    window.getSelection().collapseToEnd();

    // minus 2 because of strange text formatting
    return {
        caret: text.length - 2, 
        line: lines,
    }
} 

Here is a jsfiddle that fires on keyup. Note however, that rapid directional key presses, as well as rapid deletion seems to be skip events.

Chris Sullivan
  • 1,011
  • 9
  • 11
  • With this text selection is not possible anymore as it is collapsed. Possible scenario : needing to evaluate on every keyUp event – XtraBytesLab Sep 28 '19 at 00:31
  • I like this solution but I do agree with Xtra where this has the same issue that it will completely remove an existing text selection via keybinds. – FireController1847 Jul 26 '22 at 22:03
4
//global savedrange variable to store text range in
var savedrange = null;

function getSelection()
{
    var savedRange;
    if(window.getSelection && window.getSelection().rangeCount > 0) //FF,Chrome,Opera,Safari,IE9+
    {
        savedRange = window.getSelection().getRangeAt(0).cloneRange();
    }
    else if(document.selection)//IE 8 and lower
    { 
        savedRange = document.selection.createRange();
    }
    return savedRange;
}

$('#contentbox').keyup(function() { 
    var currentRange = getSelection();
    if(window.getSelection)
    {
        //do stuff with standards based object
    }
    else if(document.selection)
    { 
        //do stuff with microsoft object (ie8 and lower)
    }
});

Note: the range object its self can be stored in a variable, and can be re-selected at any time unless the contents of the contenteditable div change.

Reference for IE 8 and lower: http://msdn.microsoft.com/en-us/library/ms535872(VS.85).aspx

Reference for standards (all other) browsers: https://developer.mozilla.org/en/DOM/range (its the mozilla docs, but code works in chrome, safari, opera and ie9 too)

Nico Burns
  • 16,639
  • 10
  • 40
  • 54
  • 1
    Thanks, but how exactly do I get the 'index' of the caret position in the div contents? – Bertvan Oct 19 '10 at 20:22
  • OK, it looks like calling .baseOffset on .getSelection() does the trick. So this, together with your answer, answers my question. Thanks! – Bertvan Oct 19 '10 at 20:32
  • 2
    Unfortunately .baseOffset only work in webkit (i think). It also only gives you the offset from the imediate parent of the caret (if you have a tag inside the
    it will give the offset from the start of the , not the start of the
    . Standards based ranges can use range.endOffset range.startOffset range.endContainer and range.startContainer to get the offset from the parent *node* of the selection, and the node itself (this includes text nodes). IE provides range.offsetLeft which is the offset from the left in *pixels*, and so useless.
    – Nico Burns Oct 19 '10 at 20:52
  • It is best just to store the range object its self and use window.getSelection().addrange(range); <--standards and range.select(); <--IE for re-positioning the cursor in the same place. range.insertNode(nodetoinsert); <--standards and range.pasteHTML(htmlcode); <--IE to insert text or html at the cursor. – Nico Burns Oct 19 '10 at 20:56
  • The `Range` object returned by most browsers and the `TextRange` object returned by IE are extremely different things, so I'm not sure this answer solves much. – Tim Down Oct 19 '10 at 23:44
  • @Nico (immediate parent): I'm not planning to allow other tags within the
    , only text
    – Bertvan Oct 20 '10 at 04:44
  • @Tim: At this point, it's working in Webkit, so I'm happy, but if a better (cross browser) solution comes up I'll change the answer. – Bertvan Oct 20 '10 at 04:45
3

Try this way to get the Caret position from ContentEditable Div.

Description:

  1. I have written this code for Angular but it also works for native HTML.
  2. The code returns caret position only for SPAN element inside editable div.

My Code:

private getCaretPosition() {
   let caretRevCount = 0;
   if (window.getSelection) {
      const selection = window.getSelection();
      const currentNode = selection.focusNode.parentNode;
      caretRevCount = selection.focusOffset;
      let previousNode = currentNode.previousSibling;
      while(previousNode && previousNode.nodeName === 'SPAN') { 
      // you can check specific element
      caretRevCount += previousNode.textContent.length;
      previousNode = previousNode.previousSibling;
      }
    }
    return caretRevCount;
}

How code works:

Example scenario: "Hi there, this| is sample text".

Caret position: At the end of "this" text.

  1. Initially, getting the selection area where caret is present from window.getSelection() method.
  2. selection.focusOffSet returns only currentNode text length. In Eg. case currentNode is "this". It returns 4 to caretRevCount.
  3. My approach is to backtrack from current node. So, I am looping previous nodes which is ["there, " , "Hi"] and adding its text length to caretRevCount.
  4. Finally, after the loop gets completed caretRevCount returns a sum value which is caretPosition.
  • Welcome to Stack Overflow. Dear @Parthybaraja V, please please answer to questions with more details. – CyberEternal Apr 03 '21 at 06:32
  • 1
    it works, just one question how do you get Div text in first place? i'm using [(ngModel)] on div, but it's empty – Amirreza Mar 09 '22 at 22:33
  • @Amirreza Thanks! I use id attribute in div and get value using document.getElementById function – Parthybaraja V Mar 11 '22 at 03:02
  • 1
    @ParthybarajaV you have written the right thing, and it works in plane HTML too maybe try adding some more content to your answer, it really could be helpful for others. – Ace Sep 18 '22 at 18:05
  • @y.kaf. Thanks for the suggestion! Updated the solution with some more details. Hope this updated solution will be useful. – Parthybaraja V Sep 20 '22 at 04:55
2

If you set the editable div style to "display:inline-block; white-space: pre-wrap" you don't get new child divs when you enter a new line, you just get LF character (i.e. &#10);.

function showCursPos(){
    selection = document.getSelection();
    childOffset = selection.focusOffset;
    const range = document.createRange();
    eDiv = document.getElementById("eDiv");
    range.setStart(eDiv, 0);
    range.setEnd(selection.focusNode, childOffset);
    var sHtml = range.toString();
    p = sHtml.length; 
    sHtml=sHtml.replace(/(\r)/gm, "\\r");
    sHtml=sHtml.replace(/(\n)/gm, "\\n");
    document.getElementById("caretPosHtml").value=p;
    document.getElementById("exHtml").value=sHtml;   
  }
click/type in div below:
<br>
<div contenteditable name="eDiv" id="eDiv"  
     onkeyup="showCursPos()" onclick="showCursPos()" 
     style="width: 10em; border: 1px solid; display:inline-block; white-space: pre-wrap; "
     >123&#13;&#10;456&#10;789</div>
<p>
html caret position:<br> <input type="text" id="caretPosHtml">
<p>  
html from start of div:<br> <input type="text" id="exHtml">

What I noticed was when you press "enter" in the editable div, it creates a new node, so the focusOffset resets to zero. This is why I've had to add a range variable, and extend it from the child nodes' focusOffset back to the start of eDiv (and thus capturing all text in-between).

will
  • 153
  • 3
  • 6
2

This one builds on @alockwood05's answer and provides both get and set functionality for a caret with nested tags inside the contenteditable div as well as the offsets within nodes so that you have a solution that is both serializable and de-serializable by offsets as well.

I'm using this solution in a cross-platform code editor that needs to get the caret start/end position prior to syntax highlighting via a lexer/parser and then set it back immediately afterward.

function countUntilEndContainer(parent, endNode, offset, countingState = {count: 0}) {
    for (let node of parent.childNodes) {
        if (countingState.done) break;
        if (node === endNode) {
            countingState.done = true;
            countingState.offsetInNode = offset;
            return countingState;
        }
        if (node.nodeType === Node.TEXT_NODE) {
            countingState.offsetInNode = offset;
            countingState.count += node.length;
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            countUntilEndContainer(node, endNode, offset, countingState);
        } else {
            countingState.error = true;
        }
    }
    return countingState;
}

function countUntilOffset(parent, offset, countingState = {count: 0}) {
    for (let node of parent.childNodes) {
        if (countingState.done) break;
        if (node.nodeType === Node.TEXT_NODE) {
            if (countingState.count <= offset && offset < countingState.count + node.length)
            {
                countingState.offsetInNode = offset - countingState.count;
                countingState.node = node; 
                countingState.done = true; 
                return countingState; 
            }
            else { 
                countingState.count += node.length; 
            }
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            countUntilOffset(node, offset, countingState);
        } else {
            countingState.error = true;
        }
    }
    return countingState;
}

function getCaretPosition()
{
    let editor = document.getElementById('editor');
    let sel = window.getSelection();
    if (sel.rangeCount === 0) { return null; }
    let range = sel.getRangeAt(0);    
    let start = countUntilEndContainer(editor, range.startContainer, range.startOffset);
    let end = countUntilEndContainer(editor, range.endContainer, range.endOffset);
    let offsetsCounts = { start: start.count + start.offsetInNode, end: end.count + end.offsetInNode };
    let offsets = { start: start, end: end, offsets: offsetsCounts };
    return offsets;
}

function setCaretPosition(start, end)
{
    let editor = document.getElementById('editor');
    let sel = window.getSelection();
    if (sel.rangeCount === 0) { return null; }
    let range = sel.getRangeAt(0);
    let startNode = countUntilOffset(editor, start);
    let endNode = countUntilOffset(editor, end);
    let newRange = new Range();
    newRange.setStart(startNode.node, startNode.offsetInNode);
    newRange.setEnd(endNode.node, endNode.offsetInNode);
    sel.removeAllRanges();
    sel.addRange(newRange);
    return true;
}
John Ernest
  • 785
  • 1
  • 8
  • 20
1

A straight forward way, that iterates through all the chidren of the contenteditable div until it hits the endContainer. Then I add the end container offset and we have the character index. Should work with any number of nestings. uses recursion.

Note: requires a poly fill for ie to support Element.closest('div[contenteditable]')

https://codepen.io/alockwood05/pen/vMpdmZ

function caretPositionIndex() {
    const range = window.getSelection().getRangeAt(0);
    const { endContainer, endOffset } = range;

    // get contenteditableDiv from our endContainer node
    let contenteditableDiv;
    const contenteditableSelector = "div[contenteditable]";
    switch (endContainer.nodeType) {
      case Node.TEXT_NODE:
        contenteditableDiv = endContainer.parentElement.closest(contenteditableSelector);
        break;
      case Node.ELEMENT_NODE:
        contenteditableDiv = endContainer.closest(contenteditableSelector);
        break;
    }
    if (!contenteditableDiv) return '';


    const countBeforeEnd = countUntilEndContainer(contenteditableDiv, endContainer);
    if (countBeforeEnd.error ) return null;
    return countBeforeEnd.count + endOffset;

    function countUntilEndContainer(parent, endNode, countingState = {count: 0}) {
      for (let node of parent.childNodes) {
        if (countingState.done) break;
        if (node === endNode) {
          countingState.done = true;
          return countingState;
        }
        if (node.nodeType === Node.TEXT_NODE) {
          countingState.count += node.length;
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          countUntilEndContainer(node, endNode, countingState);
        } else {
          countingState.error = true;
        }
      }
      return countingState;
    }
  }
alockwood05
  • 1,066
  • 11
  • 18
1

This answer works with nested text elements, using recursive functions.

Bonus: sets the caret position to saved position.

function getCaretData(elem) {
  var sel = window.getSelection();
  return [sel.anchorNode, sel.anchorOffset];
}

function setCaret(el, pos) {
  var range = document.createRange();
  var sel = window.getSelection();
  range.setStart(el,pos);
  range.collapse(true);
  sel.removeAllRanges();
  sel.addRange(range);
}


let indexStack = [];

function checkParent(elem) {
  
  let parent = elem.parentNode;
  let parentChildren = Array.from(parent.childNodes);
  
  let elemIndex = parentChildren.indexOf(elem);
  
  indexStack.unshift(elemIndex);
  
  if (parent !== cd) {
    
    checkParent(parent);
    
  } else {
    
    return;
    
  }
  
}

let stackPos = 0;
let elemToSelect;

function getChild(parent, index) {
  
  let child = parent.childNodes[index];
  
  if (stackPos < indexStack.length-1) {
    
    stackPos++;
        
    getChild(child, indexStack[stackPos]);
    
  } else {
    
    elemToSelect = child;
    
    return;
    
  }
  
}


let cd = document.querySelector('.cd'),
    caretpos = document.querySelector('.caretpos');

cd.addEventListener('keyup', () => {
  
  let caretData = getCaretData(cd);
  
  let selectedElem = caretData[0];
  let caretPos = caretData[1];
  
  
  indexStack = [];
  checkParent(selectedElem);
    
  
  cd.innerHTML = 'Hello world! <span>Inline! <span>In inline!</span></span>';
  
  
  stackPos = 0;
  getChild(cd, indexStack[stackPos]);
  
  
  setCaret(elemToSelect, caretPos);
  
  
  caretpos.innerText = 'indexStack: ' + indexStack + '. Got child: ' + elemToSelect.data + '. Moved caret to child at pos: ' + caretPos;
  
})
.cd, .caretpos {
  font-family: system-ui, Segoe UI, sans-serif;
  padding: 10px;
}

.cd span {
  display: inline-block;
  color: purple;
  padding: 5px;
}

.cd span span {
  color: chocolate;
  padding: 3px;
}

:is(.cd, .cd span):hover {
  border-radius: 3px;
  box-shadow: inset 0 0 0 2px #005ecc;
}
<div class="cd" contenteditable="true">Hello world! <span>Inline! <span>In inline!</span></span></div>
<div class="caretpos">Move your caret inside the elements above ⤴</div>

Codepen

benhatsor
  • 1,863
  • 6
  • 20
1

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;
}
PhiLho
  • 40,535
  • 6
  • 96
  • 134
1

So based off of the answer provided by Chris Sullivan, I managed to create a version of it that wouldn't reset when a selection was made via keyboard and was able to detect both the column and the line number.

In this method, you first have to figure out a solution to fetching all of the text up to the carat. You can do this by getting the current selection (which is the caret), cloning the first range of it, collapsing it, then changing the start node of the range to be the beginning of your element. From there, you can extract all the text up to the carat by simply running a toString on the range. Now that you have the text, we can perform some simple calculations on it to determine the line number and column.

For the line number, you simply need to calculate the number of newlines in the string of text. This can be done using some simple regexp, which can be seen in the code below.

For the column number, there's three ways to get a "column number."

  1. The "relative column" to the line number, similar to how Windows Notepad calculates it, is the easiest to calculate. This is simply the range's end offset (range.endOffset).
  2. The actual position of the caret relative to the number of arrow-key presses you would need to press to get to that position. This can be calculated by replacing all of the newlines in the text, and then getting the length of it.
  3. The actual position of the caret relative to the actual text; this you can fetch by just getting the length of the text.

Enough talk, now time for some show:

// Caret
function getCaretPosition(element) {
    // Check for selection
    if (window.getSelection().type == "None") {
        return {
            "ln": -1,
            "col": -1
        }
    }

    // Copy range
    var selection = window.getSelection();
    var range = selection.getRangeAt(0).cloneRange();

    // Collapse range
    range.collapse();

    // Move range to encompass everything
    range.setStart(element.firstChild, 0);

    // Calculate position
    var content = range.toString();
    var text = JSON.stringify(content);
    var lines = (text.match(/\\n/g) || []).length + 1;

    // Return caret position (col - 2 due to some weird calculation with regex)
    return {
        "ln": lines,
        // "col": range.endOffset + 1 // Method 1
        "col": text.replace(/\\n/g, " ").length - 2 // Method 2
        // "col": text.length -2 // Method 3
    }
}

Now through this method, if you wanted, you can get the caret position every time the selection is updated:

document.addEventListener("selectionchange", function(e) {
    console.log(getCaretPosition(document.getElementById("text-area")));
});

I hope this helps someone, I was pulling my hair out for hours trying to figure out how to do this!

FireController1847
  • 1,458
  • 1
  • 11
  • 26
0

The code below counts the caret position by taking the offset at the current element and then navigating back all the elements inside the contenteditable and counting the total number of characters.

This will:

  • Not break formatting functionality
  • Work with multiple rows.

If you encounter an issue please let me know so I can update the code.

function getRowTextLength(currentNode) {
    let previousSibling;
    let textLength = 0;
    //this means we are outside our desired scope
    if (currentNode?.contentEditable == "true") {
        return textLength;
    }
    while (currentNode) {
        //get the previous element of the currentNode
        previousSibling =
            currentNode.previousSibling || //UNFORMATTED text case
            //avoid targetting the contenteditable div itself
            (currentNode.parentNode.nodeName != "DIV"
                ? currentNode.parentNode.previousSibling //FORMATTED text case
                : null);

        //count the number of characters in the previous element, if exists
        textLength = previousSibling
            ? textLength + previousSibling.textContent.length
            : textLength;
        //set current element as previous element
        currentNode = previousSibling;
        //continue looping as long as we have a previous element
    }
    return textLength;
}

//pass e.target from an eventListener as argument
function getCaretPosition(element) {
    let selection = getSelection(element);
    //caret position at current row
    let caretPosition = selection.anchorOffset;
    let currentNode = selection.baseNode;

    caretPosition += getRowTextLength(currentNode);

    //get closest div parent node
    if (caretPosition != 0) {
        do {
            currentNode = currentNode.parentNode;
        } while (currentNode.nodeName != "DIV");
    }

    caretPosition += getRowTextLength(currentNode);

    //console.log("CARET POSITION ", caretPosition);
    return caretPosition;
}
0

Get the caret's index position relative to the content editable:

const getCaretPosition = () => {
  var selection = document.getSelection();
  if (!selection || !divRef) return 0;
  selection.collapseToEnd();
  const range = selection.getRangeAt(0);
  const clone = range.cloneRange();
  clone.selectNodeContents(divRef);
  clone.setEnd(range.startContainer, range.startOffset);
  return clone.toString().length;
}
snnsnn
  • 10,486
  • 4
  • 39
  • 44