Set cursor to specific position in CKEditor

manannan picture manannan · May 30, 2013 · Viewed 23k times · Source

Is there a way to set the cursor position to a known index inside CKEditor?

I want to do this because when I change the html inside the editor it resets the cursor to the start of the inserted element, which is a problem as I'm changing the content on the fly as the user types.

If I know that I want to set the cursor back to a known character position, say 100, inside the editor, is this possible?

(I asked a related question but I think I was overcomplicating the issue with example code.)

Answer

Reinmar picture Reinmar · May 31, 2013

The basic way of setting selection is by creating a Range, setting its position and selecting it.

Note: if you don't know the Range API (or at least the idea which stands behind ranges), you won't be able to use selection. Here's a pretty good introduction - DOM Range spec (yep, it is a spec, but it's good). CKEditor's Range API is very similar, but a little bit bigger.

For example:

// Having this HTML in editor:
// <p id="someId1">foo <em id="someId2">bar</em>.</p>

var range = editor.createRange();
range.setStart( editor.document.getById( 'someId1' ), 0 ); // <p>^foo
range.setEnd( editor.document.getById( 'someId2' ).getFirst(), 1 ); // <em>b^ar</em>

editor.getSelection().selectRanges( [ range ] );

// Will select:
// <p id="someId1">[foo <em id="someId2">b]ar</em>.</p>

Or other case:

// Having this HTML in editor:
// <p>foo bar.</p>
var range = editor.createRange();
range.moveToElementEditablePosition( editor.editable(), true ); // bar.^</p>

editor.getSelection().selectRanges( [ range ] );

// Will select:
// <p>foo bar.^</p>

Restoring selection after changing DOM

But very often you don't want to select a new range, but to restore an old selection or range. First thing you need to know is that it is impossible to correctly restore selection if you made an uncontrolled DOM changes. You need to be able to keep track of the containers and offsets of the selection's start and end.

Range keeps the references to its start and end containers (in startContainer and endContainer properties). Unfortunately, this references may be violated by:

  • overwriting innerHTML,
  • moving DOM nodes around,
  • deleting DOM nodes.

The same may happen with offsets (startOffset and endOffset properties) - if you removed one of start/end container's child nodes these offsets may need to be updated.

So in some situations range instance is not helpful when we want to remember a selection position. I'll explain three basic ways to deal with this problem.

First, this is our plan:

  1. We get the current selection position.
  2. We store it (somehow).
  3. We do the DOM changes.
  4. We restore selection.

Note: From now on I use "ranges" in plural form because Firefox supports multiple range selections - one selection can contain more than one range (try e.g. to use CTRL key while making selections).

Solution 1 - by a range

var ranges = editor.getSelection().getRanges();

// Make DOM changes.

editor.getSelection().selectRanges( ranges );

This is the simplest solution. It will work only if the DOM changes which we made haven't outdated ranges or we know how to update them.

Solution 2 - by an intrusive bookmarks

var bookmarks = editor.getSelection().createBookmarks();

// Make DOM changes.

editor.getSelection().selectBookmarks( bookmarks );

Bookmarks created by the createBookmarks method insert invisible <span> elements with special attributes (including data-cke-bookmark) at the selection's ranges start and end points.

If you can avoid uncontrolled innerHTML changes and instead append/remove/move some nodes, then just remember that you have to preserve these <span> elements and this method will work perfectly. You can also move bookmarks' elements if your modifications should change the selection as well.

By default bookmarks keep references to their <span> elements, but you can also create serializable bookmarks passing true to the createBookmarks method. This kind of bookmarks will keep references to nodes by ids, so you can overwrite entire innerHTML.

Note: This method is also available in a Range API.

This is the most popular method, because you have the full control over selection and you can change DOM, although you need to take care of bookmarks' spans.

Solution 3 - by a non intrusive bookmarks

var bookmarks = editor.getSelection().createBookmarks2();

// Make DOM changes.

editor.getSelection().selectBookmarks( bookmarks );

Note: In this solution we use createBookmarks2 method.

Here we also create an array of bookmarks objects, but we do not insert any elements into DOM. These bookmarks store their positions by the addresses. Address is an array of ancestors' indexes in their parents.

This solution is very similar to solution 1, but you can overwrite entire innerHTML, because it (most likely ;>) won't change the addresses of bookmarks' nodes. Although, in such a case you should pass true to createBookmarks2 to get normalized addresses because adjacent text nodes will be joined and empty ones removed when setting innerHTML.

To sum up...

... Working with DOM and selection isn't trivial. You need to know what you're doing, you need to know DOM and you need to pick the right solution for your problem. Most often it will be the second one, but it depends on a case.