23

I have a contentEditable div, the innerHTML of which can be updated through AJAX while editing. The problem is that when you change the contents of the div it moves the cursor to the end of the div (or loses focus depending on the browser). What is a good cross-browser solution to store caret position before changing innerHTML and then to restore it?

Andry
  • 16,172
  • 27
  • 138
  • 246
Arty
  • 5,923
  • 9
  • 39
  • 44

3 Answers3

40

back to 2016 :)
After I came across solutions here and they did not suit me, because my DOM was replaced completely after each typing. I've done more research and come with a simple solution that saves the cursor by character's position that works perfect for me.

The idea is very simple.

  1. find the length of characters before caret and save it.
  2. change the DOM.
  3. using TreeWalker to walk just on text nodes of context node and counting characters until we got the right text node and the position inside it

Two edge case:

  1. content removed completely so there is no text node:
    so: move the cursor to the start of the context node

  2. there is less content than the index pointed on :
    so: move the cursor to the end of the last node

function saveCaretPosition(context){
    var selection = window.getSelection();
    var range = selection.getRangeAt(0);
    range.setStart(  context, 0 );
    var len = range.toString().length;

    return function restore(){
        var pos = getTextNodeAtPosition(context, len);
        selection.removeAllRanges();
        var range = new Range();
        range.setStart(pos.node ,pos.position);
        selection.addRange(range);

    }
}

function getTextNodeAtPosition(root, index){
    const NODE_TYPE = NodeFilter.SHOW_TEXT;
    var treeWalker = document.createTreeWalker(root, NODE_TYPE, function next(elem) {
        if(index > elem.textContent.length){
            index -= elem.textContent.length;
            return NodeFilter.FILTER_REJECT
        }
        return NodeFilter.FILTER_ACCEPT;
    });
    var c = treeWalker.nextNode();
    return {
        node: c? c: root,
        position: index
    };
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.5.1/prism.min.js"></script>
<link href="https://rawgit.com/PrismJS/prism/gh-pages/themes/prism.css" rel="stylesheet"/>
<style>
  *{
    outline: none
    }
</style>  
<h3>Edit the CSS Snippet </H3>
<pre>
    <code class="language-css" contenteditable=true >p { color: red }</code>
</pre>

<script >
  var code = document.getElementsByTagName('code')[0];
  
  code.addEventListener('input',function () {
        var restore = saveCaretPosition(this);
        Prism.highlightElement(this);
        restore();
    })
</script>
pery mimon
  • 7,713
  • 6
  • 52
  • 57
  • 1
    What if you have line breaks and other formatting elements within the content editable element? – pelican_george Nov 01 '16 at 14:29
  • you mean like
    of ? it still should work . that code build for richtext editor that change elements around the cursor when user writing
    – pery mimon Dec 13 '16 at 02:19
  • 1
    I have a content editable with breaks and such and it works. If you use it for an undo function, store the last keypress in a onKeyDown handler and use `range.setStart(pos.node ,pos.position-(lastKeypress == 13 ? 0:1));` to prevent the cursor going on a walk :-) – Tschallacka Sep 14 '17 at 09:49
  • 3
    I actually have the same question as @pelican_george - your approach works very well, but it does not work with line breaks. As soon as your insert a line break, the cursor stays on the first line (even thou the new line has been created). Have a look at the jsfiddle with your example: https://jsfiddle.net/80ovoxr9 I couldn't the line breaks to work unfortunately :( – Lucas Motta Feb 09 '18 at 14:23
  • Thanks for the fiddle. I check it and find a little bug in the loop. but there another bug into `Prism`. I fixed the loop bug (https://jsfiddle.net/80ovoxr9/10/]. ( Shift + Enter help you get down a line) – pery mimon Feb 19 '18 at 00:00
  • What about restoring a selection, not only a caret position? – Nathan B Jun 02 '18 at 19:36
  • user selecting some text and then continue to write stuff, and he expect the selecting area remains ? anyway I guess you can create two range object one for the start position and one for the end position and use them to restore the selection range – pery mimon Jun 08 '18 at 02:00
  • @perymimon That saved my day. Thank you. – kirill.buga Jan 22 '19 at 13:35
  • I get a bug with this script that causes the cursor to jump to the beginning when I put it at the end and enter a character. The solution is as simple as changing to `index > elem.textContent.length` and `position: index`, but I can't edit the answer because the queue is full. – tvanc Apr 25 '20 at 03:44
  • 'lastNode' is assigned a value but never used – Motla Jun 29 '20 at 16:21
  • @Motla Thanks :), it clean it up a bit, probably stay there from a previous version – pery mimon Jul 18 '20 at 08:18
  • This answer is so close to perfect, but in still has some enter key/line break issues. Shift + enter doesn't work on firefox. Also adding line breaks to the end of the string doesn't work in firefox. Also, in Chrome, is it possible to make "enter" work in the same way as "shift + enter"? – Connor Aug 20 '20 at 17:13
  • thanks. can you know how the reason for this and how to solve it? I will update my The reason – pery mimon Aug 22 '20 at 09:28
  • 1
    Seem like changing to display inline-block help a bit with line-break. – Ambroise Rabier Oct 19 '21 at 13:40
  • If anyone interested anymore, I have an approach that will work for line breaks and formatting element. – shadi Oct 09 '22 at 14:04
  • Anyone using this in react/nextjs? this works for me, but have a side effect.. its selecting/highlighting all the text before the caret.. its weird because in the snippet above it doesnt show – jasjamjos Nov 21 '22 at 16:12
13

I know this is an ancient thread but I thought I would provide an alternative non-library solution

http://jsfiddle.net/6jbwet9q/9/

Tested in chrome, FF, and IE10+ Allows you to change, delete and restore html while retaining caret position/selection.

HTML

<div id=bE contenteditable=true></div>

JS

function saveRangePosition()
  {
  var range=window.getSelection().getRangeAt(0);
  var sC=range.startContainer,eC=range.endContainer;

  A=[];while(sC!==bE){A.push(getNodeIndex(sC));sC=sC.parentNode}
  B=[];while(eC!==bE){B.push(getNodeIndex(eC));eC=eC.parentNode}

  return {"sC":A,"sO":range.startOffset,"eC":B,"eO":range.endOffset};
  }

function restoreRangePosition(rp)
  {
  bE.focus();
  var sel=window.getSelection(),range=sel.getRangeAt(0);
  var x,C,sC=bE,eC=bE;

  C=rp.sC;x=C.length;while(x--)sC=sC.childNodes[C[x]];
  C=rp.eC;x=C.length;while(x--)eC=eC.childNodes[C[x]];

  range.setStart(sC,rp.sO);
  range.setEnd(eC,rp.eO);
  sel.removeAllRanges();
  sel.addRange(range)
  }

function getNodeIndex(n){var i=0;while(n=n.previousSibling)i++;return i}
poby
  • 1,572
  • 15
  • 39
  • 2
    This looks as though it's converting each selection range boundary to a path and back again. This is a great approach so long as the structure of the DOM is the same before and after the `innerHTML` changes, which isn't guaranteed to be true. – Tim Down Mar 31 '15 at 16:28
  • Is it possible to fix this code for multiple contenteditable divs? So that I can select, lets say, 1 of 3 div contenteditable, and then retrive the position of where I want to insert. – Gjert Jan 06 '16 at 13:35
  • 3
    Uncaught ReferenceError: bE is not defined – Nathan B Jun 02 '18 at 19:35
9

Update: I've ported Rangy's code to a standalone Gist:

https://gist.github.com/timdown/244ae2ea7302e26ba932a43cb0ca3908

Original answer

You could use Rangy, my cross-browser range and selection library. It has a selection save and restore module that seems well-suited to your needs.

The approach is not complicated: it inserts marker elements at the beginning and end of each selected range and uses those marker elements to restore the range boundaries again later, which could be implemented without Rangy in not much code (and you could even adapt Rangy's own code). The main advantage of Rangy is support for IE <= 8.

Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • Fantastic. I had some trepidation about using a random library from some guy on SO, but it did what I wanted in 2 lines of code. Thanks! – thedayturns Aug 07 '11 at 22:05
  • 3
    @thedayturns: That's the correct attitude to have, so I don't blame you :) I'm glad it helped. – Tim Down Aug 07 '11 at 22:13
  • @TimDown Does Rangy support multiple contenteditable divs? Like, saving position of caret over three different divs. Reason being, I want to use 1 editor for 3 different fields. – Gjert Jan 06 '16 at 14:22
  • I not sure how that approach can work if i replace completely the whole div's content – pery mimon Jul 20 '16 at 08:15
  • @perymimon: No, it wouldn't. I'd use a character offset-based approach instead in that case. – Tim Down Jul 20 '16 at 08:57
  • Hey Tim, sorry for asking here, but is there a standalone version of the save / restore module that will not require the Rangy core ? – Norman Mar 10 '19 at 06:27
  • I'm essentially looking for two functions: "save_range": to insert invisible span elements at current selection start and end (or one at caret). and "restore_range": that will change the spans back into a selection. nothing much more than that. – Norman Mar 10 '19 at 06:34
  • 1
    @Norman: I ported Rangy's code to a standalone Gist: https://gist.github.com/timdown/244ae2ea7302e26ba932a43cb0ca3908. Obviously you can cut the selection stuff out if you like, but I left it in in case it was useful to anyone else. – Tim Down Mar 11 '19 at 11:44