JavaScript convert mouse position to selection range - javascript

I would like to be able to convert the current mouse position to a range, in CKEditor in particular.
The CKEditor provides an API for setting the cursor according to a range:
var ranges = new CKEDITOR.dom.range( editor.document );
editor.getSelection().selectRanges( [ ranges ] );
Since CKEditor provides this API, the problem may be simplified by removing this requirement and just find a way to produce the range from the mouse coordinates over a div containing various HTML elements.
However, this is not the same as converting a mouse coordinate into the cursor position in a textarea since textareas have fixed column widths and row heights where the CKEditor renders HTML through an iframe.
Based on this, it looks like the range may be applied to elements.
How would you figure out the start/end range which is closest to the current mouse position?
Edit:
An example of how one might use the ckeditor API to select a range on the mouseup event.
editor.document.on('mouseup', function(e) {
this.focus();
var node = e.data.$.target;
var range = new CKEDITOR.dom.range( this.document );
range.setStart(new CKEDITOR.dom.node(node), 0);
range.collapse();
var ranges = [];
ranges.push(range);
this.getSelection().selectRanges( ranges );
});
The problem with the above example is that the event target node (e.data.$.target) is only firing for nodes such as HTML, BODY, or IMG but not for text nodes. Even if it did, these nodes represent chunks of text which wouldn't support setting the cursor to the position of the mouse within that chunk of text.

What you're trying to do is really hard in a browser. I'm not familar with ckeditor in particular, but regular javascript allows you to select text using a range so I don't think it's adding anything special. You have to find the browser element that contains the click, then find the character within the element that was clicked.
Detecting the browser element is the easy bit: you need to either register your handler on every element, or use the event's target field. There is lots of info on this out there, ask a more specific question on stackoverflow if that's what you're having trouble with.
Once you have the element you need to find out which character within the element was clicked, then create an appropriate range to put the cursor there. As the post you linked to stated, browser variations make this really hard. This page is a bit dated, but has a good discussion of ranges: http://www.quirksmode.org/dom/range_intro.html
Ranges can't tell you their positions on the page, so you'll have to use another technique to find out what bit of text was clicked.
I've never seen a complete solution to this in javascript. A few years ago I worked on one but I didn't come up with an answer I was happy with (some really hard edge cases). The approach I used was a horrible hack: insert spans into the text then use them to perform binary search until you find the smallest possible span containing the mouse click. Spans don't change the layout, so you can use the span's position_x/y properties to find out they contain the click.
E.g. suppose you have the following text in a node:
<p>Here is some paragraph text.</p>
We know the click was somewhere in this paragraph. Split the paragraph in half with a span:
<p><span>Here is some p</span>aragraph text.</p>
If the span contains the click coordinates, continue binary search in that half, otherwise search the second half.
This works great for single lines, but if the text spans multiple lines you have to first find line breaks, or the spans can overlap. You also have to work out what to do when the click wasn't on any text but was in the element --- past the end of the last line in a paragraph for example.
Since I worked on this browsers have got a lot faster. They're probably fast enough now to add s around each character, then around each two characters etc to create a binary tree which is easy to search. You could try this approach - it would make it much easier to work out which line you're working on.
TL;DR this is a really hard problem and if there is an answer, it might not be worth your time to come up with it.

Sorry for bumping an old thread, but I wanted to post this here in case anyone else stumbles across this question, as there is very little information on this. I just had to write a function that does this for an Outlook for web userscript, because they override the default drag-n-drop functionality and break it in the compose box. This is the solution I came up with:
function rangeFromCoord(x, y) {
const closest = {
offset: 0,
xDistance: Infinity,
yDistance: Infinity,
};
const {
minOffset,
maxOffset,
element,
} = (() => {
const range = document.createRange();
range.selectNodeContents(document.elementFromPoint(x, y));
return {
element: range.startContainer,
minOffset: range.startOffset,
maxOffset: range.endOffset,
};
})();
for(let i = minOffset; i <= maxOffset; i++) {
const range = document.createRange();
range.setStart(element, i);
range.setEnd(element, i);
const marker = document.createElement("span");
marker.style.width = "0";
marker.style.height = "0";
marker.style.position = "absolute";
marker.style.overflow = "hidden";
range.insertNode(marker);
const rect = marker.getBoundingClientRect();
const distX = Math.abs(x - rect.left);
const distY = Math.abs(y - rect.top);
marker.remove();
if(closest.yDistance > distY) {
closest.offset = i;
closest.xDistance = distX;
closest.yDistance = distY;
} else if(closest.yDistance === distY) {
if(closest.xDistance > distX) {
closest.offset = i;
closest.xDistance = distX;
closest.yDistance = distY;
}
}
}
const range = document.createRange();
range.setStart(element, closest.offset);
range.setEnd(element, closest.offset);
return range;
}
All you do is pass in the client coordinates, and the function will automatically select the most specific element at that position. It will use that selection to get the parent element used by the browser (most notably contenteditable elements), as well as the maximum and minimum offsets. It will then proceed, iterating through the offsets, placing marker span elements with position: absolute; width: 0; height: 0; overflow: hidden; at each offset to probe their position, removing them, and checking distance. As per most text editors, it will first get as close as it can on the Y coordinate, and then move in on the X coordinate. Once it finds the closest position, it will create a new selection and return it.

There are two ways of doing this, just like every WYSIWYG does.
First:
- you give up because it is too hard and it will end up to be a browser killer;
Second:
- you try to parse the text and put it in the exact place in a semitransparent textarea or div above the original, but here we have two problems:
1) how would you parse the dynamic chunks of data to get only the text and to be sure you map it over the exact position of the actual content
2) how would you solve the update to parse for every darn character you type or every action you do in the editor.
In the end this is just a "A brutal odyssey to the dark side of the DOM tree", but if you choose the second way, than the code from your post will work like a charm.

I was working on a similar task to allow TinyMCE (inline mode) to initialize with a caret placed in mouse click position. The following code works in the latest Firefox and Chrome, at least:
let contentElem = $('#editorContentRootElem');
let editorConfig = { inline: true, forced_root_block: false };
let onFirstFocus = () => {
contentElem.off('click focus', onFirstFocus);
setTimeout(() => {
let uniqueId = 'uniqueCaretId';
let range = document.getSelection().getRangeAt(0);
let caret = document.createElement("span");
range.surroundContents(caret);
caret.outerHTML = `<span id="${uniqueId}" contenteditable="false"></span>`;
editorConfig.setup = (editor) => {
this.editor = editor;
editor.on('init', () => {
var caret = $('#' + uniqueId)[0];
if (!caret) return;
editor.selection.select(caret);
editor.selection.collapse(false);
caret.parentNode.removeChild(caret);
});
};
tinymce.init(editorConfig);
}, 0); // after redraw
}; // onFirstFocus
contentElem.on('click focus', onFirstFocus);
Explanation
It seems that after mouse click/focus event and redraw (setTimeout ms 0) document.getSelection().getRangeAt(0) returns valid cursor range. We can use it for any purpose. TinyMCE moves caret to start on initialization, so I create special span 'caret' element at current range start and later force editor to select it, then remove it.

Related

Vanilla JavaScript to Save and Restore Caret Position When HTML Updated in Content Editable DIV

I know there many similar questions with answers on StackOverflow and Google and I've tried many and none of them works. So I will post my own code below and hopefully someone can help. Thanks in advance.
Note: there might be typos in below codes as I manually typed this question. I've checked and apologise for any inconvenience if they confuse you.
The logic I'm trying to achieve in that contenteditable DIV:
User types in the DIV
Meanwhile an event listener is checking the text user enters and filter with pre-defined keywords array such as ['apple', 'banana']. Filtered text will be wrapped in the HTML and write back to the DIV.
While achieving above, user doesn't lose mouse caret which means this scanning and replacing procedure should be constantly happening in the background and doesn't affect user's editing.
The code:
HTML (Before Process)
<div id="editor">
Mr Jenkins has an apple and a banana.
</div>
HTML (After Processed)
<div id="editor">
Mr Jenkins has an <u>apple</u> and a <u>banana</u>.
</div>
JavaScript (It's in vue so I skipped other parts here and display in vanilla way here)
Plan A to use pre-defined selection variable
This throws error "DOMException:
Failed to execute 'collapse' on 'Selection': There is no child at offset 7"
// Global variable to store the offset value when DOM content loaded
// The input event of the #editor element is listened but skipped here
var caretPosition,
selection = window.getSelection();
function scanText() {
let editor = document.getElementById('editor');
// Save the position
caretPosition = selection.focusOffset;
// Scan and replace text
editor.innerHTML = filterText(); // Function filterText will return HTML as in above example
// Restore the position
selection.collapse(editor, caretPosition);
}
Plan B to use get selection on the go
This doesn't throw any errors but caret position is weird
The focusOffset value is far smaller than the real focusOffset value.
For example, if focusOffset in Plan A is 100, it's 9 in Plan B.
So it always restore at the incorrect position
function scanText() {
let editor = document.getElementById('editor');
// Save the position
caretPosition = window.getSelection().focusOffset;
// Scan and replace text
editor.innerHTML = filterText(); // Function filterText will return HTML as in above example
// Restore the position
window.getSelection().collapse(editor, caretPosition);
}
Plan C to use range
"IndexSizeError: Failed to execute 'setStart' on 'Range': There is no child at offset 5."
function scanText() {
let editor = document.getElementById('editor'),
offsets, range = new Range();
// Save the range
// It starts at 5 and ends at 101 though I only placed cursor at the end of the text
if (window.getSelection().rangeCount) {
offsets = [
window.getSelection().getRangeAt(0).startOffset, // 5
window.getSelection().getRangeAt(0).endOffset // 101
];
}
// Scan and replace text
editor.innerHTML = filterText(); // Function filterText will return HTML as in above example
// Restore the range
range.setStart(editor, offsets[0]); // Set start container to editor and start offset as 5
range.setEnd(editor, offsets[1]); // Set end container to editor and end offset as 101
}

Dragging selected text to copy/move it within a webpage?

I'd like to replicate the behavior of dragging selected text, to move it in/into a text input or text area, within a webpage.
I'd like to know if it's possible (using a mouse drag of a selection) to:
copy/move selected text from one text input to another
copy/move selected text within an input to another position
copy/move selected text from a header/paragraph (i.e. non-input) to an input/textarea.
populate the dragged text programmatically using javascript
(Bonus: copy/move selected text to external program)
e.g. The green arrow demonstrates the behavior working, however, I'd like to make it work within a webpage (red arrow).
All google suggestions I've found so far, relate to dragging and dropping HTML elements, not text.
I'm using chrome, but bonus points if it's a cross-browser solution. Don't mind if the solution is HTML, CSS or javascript.
Similar question, but for visual studio code - not a webpage.
** UPDATE **
I feel stupid (but I'll blame usability). If I select text, then ensure the mouse isn't moving and click, THEN drag the mouse, points 1-3 work as expected and drag the selected text. (my problem I suspect is that I was impatient, and subtlety moving the mouse before clicking to drag.)
#cjl750's answer still stands. I've changed the question to focus on point 4.
So, in my tests in Chrome, anything involving dropping into a text input is handled natively by the browser (and I would expect the same for other browsers). I am guessing whatever mouse events SO has connected to their search bar is interfering with drag and drop from there into another input, but any normal input I tested works fine.
That leaves you only needing to handle dragging into non-inputs.
There's two parts of that. First, you can get the current highlighted text with window.getSelection(). The tricky part is that you need to figure out what is under your cursor when you are done dragging that text somewhere. The solution on how to do that was inspired from Solution #2 on this answer.
We want to make use of document.caretPositionFromPoint(), but that's the new name for the method that is so far only supported in Firefox. So using the simple if/else statement from MDN we can cover everything but IE, which is what I'm doing in the snippet below. A more complete solution that includes IE support is found in the other link.
Basic strategy:
on mousedown, check for highlighted text
if we have some highlighted text, listen for the dragend event
assuming we stopped dragging on top of a text node, check cursor position within that node with document.caretPositionFromPoint() or document.caretRangeFromPoint()
using the caret position from #3 as well as the position of our highlighted text within its larger string, slice up our strings and output the result
(() => {
document.addEventListener('mouseup', checkForSelection);
let textBeingDragged;
let originalNode
function checkForSelection(event) {
const selection = window.getSelection();
const selectedText = selection.toString();
if (selectedText) {
originalNode = selection.anchorNode.parentNode;
textBeingDragged = selectedText;
document.addEventListener('dragend', handleDragEnd);
}
}
function handleDragEnd(event) {
const charRange = getCharPosition(event);
const elemDrugOver = charRange.endContainer;
if (elemDrugOver.nodeType === 3) {
const offset = charRange.startOffset;
const startText = elemDrugOver.wholeText.slice(0, offset);
const endText = elemDrugOver.wholeText.slice(offset);
elemDrugOver.textContent = `${startText}${textBeingDragged}${endText}`;
const origText = originalNode.textContent;
const indexOfSelection = origText.indexOf(textBeingDragged);
const origStartText = origText.slice(0, indexOfSelection);
const origEndText = origText.slice(offset + textBeingDragged.length);
originalNode.textContent = `${origStartText}${origEndText}`;
textBeingDragged = undefined;
originalNode = undefined;
}
document.removeEventListener('dragend', handleDragEnd);
}
function getCharPosition(event) {
if (document.caretPositionFromPoint) {
return document.caretPositionFromPoint(event.clientX, event.clientY);
} else if (document.caretRangeFromPoint) {
return document.caretRangeFromPoint(event.clientX, event.clientY);
}
return false;
}
})();
<h1>This is a (try to move me) header</h1>
<h2>This is another header</h2>
I'll leave it to you to add any enhancements you want, but this should get you 95% of the way there at least.

JS - surroundContents only retains highlight on text about 20% of the highlight attempts

I am using a mouseup event to trigger a function which highlights text and surrounds the highlighted text with a span (function from stack overflow):
function highlightText(e) {
var t = window.getSelection().toString();
if (t) {
$("#mySpan").remove();
var range = window.getSelection().getRangeAt(0);
newNode = document.createElement("span");
newNode.id = 'mySpan';
range.surroundContents(newNode);
}
}
The main problem I am encountering is that as long as surroundContents is included, the text remains highlighted only about 20% of the highlight attempts (otherwise highlighting disappears immediately). I tried adding a setTimeout, not calling surroundContent for 1s. I also tried removing the remove() statement, but still no good.
Any ideas on why this is happening?
I was facing the same problem with Chromium on Android. In some specific cases, the call of range.surroundContents(newNode) would cause a very weird behaviour of page reload and so on. After checking the documentation of the function:
This method is nearly equivalent to
newNode.appendChild(range.extractContents());
range.insertNode(newNode). After surrounding, the boundary points of
the range include newNode.
So the obvious thing was to apply another way highlight the text. I found mark.js library which did exactly what I wanted without that annoying side effect. (Here's a JSFiddle sample that shows how it's used to highlight just selection). The difference is that library was not using range.surroundContents(newNode) nor newNode.appendChild but rather node.replaceChild.
Based on that, here's the solution to the problem I was having and I think it applies to your case as well.
function surroundRangeWithSpan(range) {
var span = document.createElement('span');
// The text is within the same node (no other html elements inside of it)
if (range.startContainer.isEqualNode(range.endContainer) && range.startContainer.childNodes.length == 0) {
// Here you customise your <span> element
customSurroundContents(range, span);
} else {
// Here you have to break the selection down
}
return span;
}
function customSurroundContents(range, span) {
var node = range.commonAncestorContainer;
var startNode = node.splitText(range.startOffset);
var ret = startNode.splitText(range.toString().length);
span.textContent = startNode.textContent;
startNode.parentNode.replaceChild(span, startNode);
}
And you pass window.getSelection().getRangeAt(0) to the function.
The likely cause of the failure is the selected text encompasses only the beginning or the ending of a non-text node, and not both of them.
So if were to run that code only selecting "This is Bo" in the following it will fail (and throw an exception) because it doesn't also capture the closing tag in the selection:
This is <em>bold</em>
So ending up with:
This is <em>bo
Reference: https://developer.mozilla.org/en-US/docs/Web/API/Range/surroundContents

How to get cursor HTML offset in contenteditable? [duplicate]

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 ?
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:
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.
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.
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:
Get the offset of the caret within its node (document.getSelection().anchorOffset)
Get the text of the node in which the caret is located (document.getSelection().anchorNode.data)
Get the offset of that text within #mydiv by using indexOf()
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.
NOTE: This solution works even in nodes with repeated text, but it detects html entities (e.g.: ) 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:
Create a stack with all the parent elements of the node in which the caret is located.
While the stack is not empty, traverse the nodes of the containing element (initially the content editable div).
If the node is not the same one at the top of the stack, add its size to the offset.
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 are counted as one character only.

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 ?
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:
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.
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.
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:
Get the offset of the caret within its node (document.getSelection().anchorOffset)
Get the text of the node in which the caret is located (document.getSelection().anchorNode.data)
Get the offset of that text within #mydiv by using indexOf()
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.
NOTE: This solution works even in nodes with repeated text, but it detects html entities (e.g.: ) 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:
Create a stack with all the parent elements of the node in which the caret is located.
While the stack is not empty, traverse the nodes of the containing element (initially the content editable div).
If the node is not the same one at the top of the stack, add its size to the offset.
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 are counted as one character only.

Categories

Resources