Set caret position in span in contenteditable element [duplicate] - javascript

How would I go a about modifying this(How to set caret(cursor) position in contenteditable element (div)?) so it accepts a number index and element and sets the cursor position to that index?
For example:
If I had the paragraph:
<p contenteditable="true">This is a paragraph.</p>
And I called:
setCaret($(this).get(0), 3)
The cursor would move to index 3 like so:
Thi|s is a paragraph.
I have this but with no luck:
function setCaret(contentEditableElement, index)
{
var range,selection;
if(document.createRange)//Firefox, Chrome, Opera, Safari, IE 9+
{
range = document.createRange();//Create a range (a range is a like the selection but invisible)
range.setStart(contentEditableElement,index);
range.collapse(true);
selection = window.getSelection();//get the selection object (allows you to change selection)
selection.removeAllRanges();//remove any selections already made
selection.addRange(range);//make the range you have just created the visible selection
}
else if(document.selection)//IE 8 and lower
{
range = document.body.createTextRange();//Create a range (a range is a like the selection but invisible)
range.moveToElementText(contentEditableElement);//Select the entire contents of the element with the range
range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start
range.select();//Select the range (make it the visible selection
}
}
http://jsfiddle.net/BanQU/4/

Here's an answer adapted from Persisting the changes of range objects after selection in HTML. Bear in mind that this is less than perfect in several ways (as is MaxArt's, which uses the same approach): firstly, only text nodes are taken into account, meaning that line breaks implied by <br> and block elements are not included in the index; secondly, all text nodes are considered, even those inside elements that are hidden by CSS or inside <script> elements; thirdly, consecutive white space characters that are collapsed on the page are all included in the index; finally, IE <= 8's rules are different again because it uses a different mechanism.
var setSelectionByCharacterOffsets = null;
if (window.getSelection && document.createRange) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var charIndex = 0, range = document.createRange();
range.setStart(containerEl, 0);
range.collapse(true);
var nodeStack = [containerEl], node, foundStart = false, stop = false;
while (!stop && (node = nodeStack.pop())) {
if (node.nodeType == 3) {
var nextCharIndex = charIndex + node.length;
if (!foundStart && start >= charIndex && start <= nextCharIndex) {
range.setStart(node, start - charIndex);
foundStart = true;
}
if (foundStart && end >= charIndex && end <= nextCharIndex) {
range.setEnd(node, end - charIndex);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
} else if (document.selection) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
};
}

range.setStart and range.setEnd can be used on text nodes, not element nodes. Or else they will raise a DOM Exception. So what you have to do is
range.setStart(contentEditableElement.firstChild, index);
I don't get what you did for IE8 and lower. Where did you mean to use index?
Overall, your code fails if the content of the nodes is more than a single text node. It may happen for nodes with isContentEditable === true, since the user can paste text from Word or other places, or create a new line and so on.
Here's an adaptation of what I did in my framework:
var setSelectionRange = function(element, start, end) {
var rng = document.createRange(),
sel = getSelection(),
n, o = 0,
tw = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, null);
while (n = tw.nextNode()) {
o += n.nodeValue.length;
if (o > start) {
rng.setStart(n, n.nodeValue.length + start - o);
start = Infinity;
}
if (o >= end) {
rng.setEnd(n, n.nodeValue.length + end - o);
break;
}
}
sel.removeAllRanges();
sel.addRange(rng);
};
var setCaret = function(element, index) {
setSelectionRange(element, index, index);
};
The trick here is to use the setSelectionRange function - that selects a range of text inside and element - with start === end. In contentEditable elements, this puts the caret in the desired position.
This should work in all modern browsers, and for elements that have more than just a text node as a descendant. I'll let you add checks for start and end to be in the proper range.
For IE8 and lower, things are a little harder. Things would look a bit like this:
var setSelectionRange = function(element, start, end) {
var rng = document.body.createTextRange();
rng.moveToElementText(element);
rng.moveStart("character", start);
rng.moveEnd("character", end - element.innerText.length - 1);
rng.select();
};
The problem here is that innerText is not good for this kind of things, as some white spaces are collapsed. Things are fine if there's just a text node, but are screwed for something more complicated like the ones you get in contentEditable elements.
IE8 doesn't support textContent, so you have to count the characters using a TreeWalker. But than again IE8 doesn't support TreeWalker either, so you have to walk the DOM tree all by yourself...
I still have to fix this, but somehow I doubt I'll ever will. Even if I did code a polyfill for TreeWalker in IE8 and lower...

Here is my improvement over Tim's answer. It removes the caveat about hidden characters, but the other caveats remain:
only text nodes are taken into account (line breaks implied by <br> and block elements are not included in the index)
all text nodes are considered, even those inside elements that are hidden by CSS or inside elements
IE <= 8's rules are different again because it uses a different mechanism.
The code:
var setSelectionByCharacterOffsets = null;
if (window.getSelection && document.createRange) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var charIndex = 0, range = document.createRange();
range.setStart(containerEl, 0);
range.collapse(true);
var nodeStack = [containerEl], node, foundStart = false, stop = false;
while (!stop && (node = nodeStack.pop())) {
if (node.nodeType == 3) {
var hiddenCharacters = findHiddenCharacters(node, node.length)
var nextCharIndex = charIndex + node.length - hiddenCharacters;
if (!foundStart && start >= charIndex && start <= nextCharIndex) {
var nodeIndex = start-charIndex
var hiddenCharactersBeforeStart = findHiddenCharacters(node, nodeIndex)
range.setStart(node, nodeIndex + hiddenCharactersBeforeStart);
foundStart = true;
}
if (foundStart && end >= charIndex && end <= nextCharIndex) {
var nodeIndex = end-charIndex
var hiddenCharactersBeforeEnd = findHiddenCharacters(node, nodeIndex)
range.setEnd(node, nodeIndex + hiddenCharactersBeforeEnd);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
} else if (document.selection) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
};
}
var x = document.getElementById('a')
x.focus()
setSelectionByCharacterOffsets(x, 1, 13)
function findHiddenCharacters(node, beforeCaretIndex) {
var hiddenCharacters = 0
var lastCharWasWhiteSpace=true
for(var n=0; n-hiddenCharacters<beforeCaretIndex &&n<node.length; n++) {
if([' ','\n','\t','\r'].indexOf(node.textContent[n]) !== -1) {
if(lastCharWasWhiteSpace)
hiddenCharacters++
else
lastCharWasWhiteSpace = true
} else {
lastCharWasWhiteSpace = false
}
}
return hiddenCharacters
}

Related

Get range of selected text, relative to element

I found this function from another StackOverflow post, which highlights text based on a specified start and end range, relative to a parent element. Unfortunately, however, I am not entirely sure how it works. This is the method:
function setSelectionRange(el, start, end) {
if (document.createRange && window.getSelection) {
var range = document.createRange();
range.selectNodeContents(el);
var textNodes = getTextNodesIn(el);
var foundStart = false;
var charCount = 0, endCharCount;
for (var i = 0, textNode; textNode = textNodes[i++]; ) {
endCharCount = charCount + textNode.length;
if (!foundStart && start >= charCount && (start < endCharCount || (start == endCharCount && i < textNodes.length))) {
range.setStart(textNode, start - charCount);
foundStart = true;
}
if (foundStart && end <= endCharCount) {
range.setEnd(textNode, end - charCount);
break;
}
charCount = endCharCount;
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} else if (document.selection && document.body.createTextRange) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(el);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
}
}
Now I want to do the opposite. I want to get the selection range relative to an element, specifically the start and end values that getSelectionRange explicitly defined.
How would I write a "getSelectionRange" method relative to a parent element, using similar methods that are used in setSelectionRange, such that I could use both methods to get/set selection ranges?
The inverse process is simpler: given a range and a parent element, the process is
Create a new range that encompasses the contents of the element
Set the end of that range to the start boundary of the range you're measuring
Get the length of the string returned by calling toString() on the measuring range. This is your start offset.
Get the length of the string returned by calling toString() on the original range and add it to the start offset. This is your end offset.
See the saveSelection function here for an example.
The Selection object that you can retrieve using window.getSelection() already has a getRange method that'll give you the range object currently selected. This range object is the same used in the function you mention and contains all info regarding what is currently selected, meaning start nodes, end nodes, startOffset, endOffset and much more. See https://developer.mozilla.org/en-US/docs/Web/API/Range
And it's pretty much the inverse of what you have here meaning if you select content in a page, you can do this to get and set what you selected.
var current_range = window.getSelection().getRangeAt(0);
window.getSelection().removeAllRanges();
window.getSelection().addRange(current_range);

Javascript Contenteditable - set Cursor / Caret to index

How would I go a about modifying this(How to set caret(cursor) position in contenteditable element (div)?) so it accepts a number index and element and sets the cursor position to that index?
For example:
If I had the paragraph:
<p contenteditable="true">This is a paragraph.</p>
And I called:
setCaret($(this).get(0), 3)
The cursor would move to index 3 like so:
Thi|s is a paragraph.
I have this but with no luck:
function setCaret(contentEditableElement, index)
{
var range,selection;
if(document.createRange)//Firefox, Chrome, Opera, Safari, IE 9+
{
range = document.createRange();//Create a range (a range is a like the selection but invisible)
range.setStart(contentEditableElement,index);
range.collapse(true);
selection = window.getSelection();//get the selection object (allows you to change selection)
selection.removeAllRanges();//remove any selections already made
selection.addRange(range);//make the range you have just created the visible selection
}
else if(document.selection)//IE 8 and lower
{
range = document.body.createTextRange();//Create a range (a range is a like the selection but invisible)
range.moveToElementText(contentEditableElement);//Select the entire contents of the element with the range
range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start
range.select();//Select the range (make it the visible selection
}
}
http://jsfiddle.net/BanQU/4/
Here's an answer adapted from Persisting the changes of range objects after selection in HTML. Bear in mind that this is less than perfect in several ways (as is MaxArt's, which uses the same approach): firstly, only text nodes are taken into account, meaning that line breaks implied by <br> and block elements are not included in the index; secondly, all text nodes are considered, even those inside elements that are hidden by CSS or inside <script> elements; thirdly, consecutive white space characters that are collapsed on the page are all included in the index; finally, IE <= 8's rules are different again because it uses a different mechanism.
var setSelectionByCharacterOffsets = null;
if (window.getSelection && document.createRange) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var charIndex = 0, range = document.createRange();
range.setStart(containerEl, 0);
range.collapse(true);
var nodeStack = [containerEl], node, foundStart = false, stop = false;
while (!stop && (node = nodeStack.pop())) {
if (node.nodeType == 3) {
var nextCharIndex = charIndex + node.length;
if (!foundStart && start >= charIndex && start <= nextCharIndex) {
range.setStart(node, start - charIndex);
foundStart = true;
}
if (foundStart && end >= charIndex && end <= nextCharIndex) {
range.setEnd(node, end - charIndex);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
} else if (document.selection) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
};
}
range.setStart and range.setEnd can be used on text nodes, not element nodes. Or else they will raise a DOM Exception. So what you have to do is
range.setStart(contentEditableElement.firstChild, index);
I don't get what you did for IE8 and lower. Where did you mean to use index?
Overall, your code fails if the content of the nodes is more than a single text node. It may happen for nodes with isContentEditable === true, since the user can paste text from Word or other places, or create a new line and so on.
Here's an adaptation of what I did in my framework:
var setSelectionRange = function(element, start, end) {
var rng = document.createRange(),
sel = getSelection(),
n, o = 0,
tw = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, null);
while (n = tw.nextNode()) {
o += n.nodeValue.length;
if (o > start) {
rng.setStart(n, n.nodeValue.length + start - o);
start = Infinity;
}
if (o >= end) {
rng.setEnd(n, n.nodeValue.length + end - o);
break;
}
}
sel.removeAllRanges();
sel.addRange(rng);
};
var setCaret = function(element, index) {
setSelectionRange(element, index, index);
};
The trick here is to use the setSelectionRange function - that selects a range of text inside and element - with start === end. In contentEditable elements, this puts the caret in the desired position.
This should work in all modern browsers, and for elements that have more than just a text node as a descendant. I'll let you add checks for start and end to be in the proper range.
For IE8 and lower, things are a little harder. Things would look a bit like this:
var setSelectionRange = function(element, start, end) {
var rng = document.body.createTextRange();
rng.moveToElementText(element);
rng.moveStart("character", start);
rng.moveEnd("character", end - element.innerText.length - 1);
rng.select();
};
The problem here is that innerText is not good for this kind of things, as some white spaces are collapsed. Things are fine if there's just a text node, but are screwed for something more complicated like the ones you get in contentEditable elements.
IE8 doesn't support textContent, so you have to count the characters using a TreeWalker. But than again IE8 doesn't support TreeWalker either, so you have to walk the DOM tree all by yourself...
I still have to fix this, but somehow I doubt I'll ever will. Even if I did code a polyfill for TreeWalker in IE8 and lower...
Here is my improvement over Tim's answer. It removes the caveat about hidden characters, but the other caveats remain:
only text nodes are taken into account (line breaks implied by <br> and block elements are not included in the index)
all text nodes are considered, even those inside elements that are hidden by CSS or inside elements
IE <= 8's rules are different again because it uses a different mechanism.
The code:
var setSelectionByCharacterOffsets = null;
if (window.getSelection && document.createRange) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var charIndex = 0, range = document.createRange();
range.setStart(containerEl, 0);
range.collapse(true);
var nodeStack = [containerEl], node, foundStart = false, stop = false;
while (!stop && (node = nodeStack.pop())) {
if (node.nodeType == 3) {
var hiddenCharacters = findHiddenCharacters(node, node.length)
var nextCharIndex = charIndex + node.length - hiddenCharacters;
if (!foundStart && start >= charIndex && start <= nextCharIndex) {
var nodeIndex = start-charIndex
var hiddenCharactersBeforeStart = findHiddenCharacters(node, nodeIndex)
range.setStart(node, nodeIndex + hiddenCharactersBeforeStart);
foundStart = true;
}
if (foundStart && end >= charIndex && end <= nextCharIndex) {
var nodeIndex = end-charIndex
var hiddenCharactersBeforeEnd = findHiddenCharacters(node, nodeIndex)
range.setEnd(node, nodeIndex + hiddenCharactersBeforeEnd);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
} else if (document.selection) {
setSelectionByCharacterOffsets = function(containerEl, start, end) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
};
}
var x = document.getElementById('a')
x.focus()
setSelectionByCharacterOffsets(x, 1, 13)
function findHiddenCharacters(node, beforeCaretIndex) {
var hiddenCharacters = 0
var lastCharWasWhiteSpace=true
for(var n=0; n-hiddenCharacters<beforeCaretIndex &&n<node.length; n++) {
if([' ','\n','\t','\r'].indexOf(node.textContent[n]) !== -1) {
if(lastCharWasWhiteSpace)
hiddenCharacters++
else
lastCharWasWhiteSpace = true
} else {
lastCharWasWhiteSpace = false
}
}
return hiddenCharacters
}

Highlight text range using JavaScript

I would like to highlight (apply css to) a certain text range, denoted by its start and end position. This is more diffucult than it seems, since there may be other tags within the text, that need to be ignored.
Example:
<div>abcd<em>efg</em>hij</div>
highlight(2, 6) needs to highlight "cdef" without removing the tag.
I have tried already using a TextRange object, but without success.
Thanks in advance!
Below is a function to set the selection to a pair of character offsets within a particular element. This is naive implementation: it does not take into account any text that may be made invisible (either by CSS or by being inside a <script> or <style> element, for example) and may have browser discrepancies (IE versus everything else) with line breaks, and takes no account of collapsed whitespace (such as 2 or more consecutive space characters collapsing to one visible space on the page). However, it does work for your example in all major browsers.
For the other part, the highlighting, I'd suggest using document.execCommand() for that. You can use my function below to set the selection and then call document.execCommand(). You'll need to make the document temporarily editable in non-IE browsers for the command to work. See my answer here for code: getSelection & surroundContents across multiple tags
Here's a jsFiddle example showing the whole thing, working in all major browsers: http://jsfiddle.net/8mdX4/1211/
And the selection setting code:
function getTextNodesIn(node) {
var textNodes = [];
if (node.nodeType == 3) {
textNodes.push(node);
} else {
var children = node.childNodes;
for (var i = 0, len = children.length; i < len; ++i) {
textNodes.push.apply(textNodes, getTextNodesIn(children[i]));
}
}
return textNodes;
}
function setSelectionRange(el, start, end) {
if (document.createRange && window.getSelection) {
var range = document.createRange();
range.selectNodeContents(el);
var textNodes = getTextNodesIn(el);
var foundStart = false;
var charCount = 0, endCharCount;
for (var i = 0, textNode; textNode = textNodes[i++]; ) {
endCharCount = charCount + textNode.length;
if (!foundStart && start >= charCount
&& (start < endCharCount ||
(start == endCharCount && i <= textNodes.length))) {
range.setStart(textNode, start - charCount);
foundStart = true;
}
if (foundStart && end <= endCharCount) {
range.setEnd(textNode, end - charCount);
break;
}
charCount = endCharCount;
}
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} else if (document.selection && document.body.createTextRange) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(el);
textRange.collapse(true);
textRange.moveEnd("character", end);
textRange.moveStart("character", start);
textRange.select();
}
}
You could take a look at how works this powerful JavaScript utility which support selection over multiple DOM elements:
MASHA (short for Mark & Share) allow you to mark interesting parts of web page content and share it
http://mashajs.com/index_eng.html
It's also on GitHub https://github.com/SmartTeleMax/MaSha
Works even on Mobile Safari and IE!
Following solution doesn't work for IE, you'll need to apply TextRange objects etc. for that. As this uses selections to perform this, it shouldn't break the HTML in normal cases, for example:
<div>abcd<span>efg</span>hij</div>
With highlight(3,6);
outputs:
<div>abc<em>d<span>ef</span></em><span>g</span>hij</div>
Take note how it wraps the first character outside of the span into an em, and then the rest within the span into a new one. Where as if it would just open it at character 3 and end at character 6, it would give invalid markup like:
<div>abc<em>d<span>ef</em>g</span>hij</div>
The code:
var r = document.createRange();
var s = window.getSelection()
r.selectNode($('div')[0]);
s.removeAllRanges();
s.addRange(r);
// not quite sure why firefox has problems with this
if ($.browser.webkit) {
s.modify("move", "backward", "documentboundary");
}
function highlight(start,end){
for(var st=0;st<start;st++){
s.modify("move", "forward", "character");
}
for(var st=0;st<(end-start);st++){
s.modify("extend", "forward", "character");
}
}
highlight(2,6);
var ra = s.getRangeAt(0);
var newNode = document.createElement("em");
newNode.appendChild(ra.extractContents());
ra.insertNode(newNode);
Example: http://jsfiddle.net/niklasvh/4NDb9/
edit Looks like at least my FF4 had some issues with
s.modify("move", "backward", "documentboundary");
but at the same time, it seems to work without it, so I just changed it to
if ($.browser.webkit) {
s.modify("move", "backward", "documentboundary");
}
edit
as Tim Pointed out, modify is only available from FF4 onwards, so I took a different approach to getting the selection, which doesn't need the modify method, in hopes in making it a bit more browser compatible (IE still needs its own solution).
The code:
var r = document.createRange();
var s = window.getSelection()
var pos = 0;
function dig(el){
$(el).contents().each(function(i,e){
if (e.nodeType==1){
// not a textnode
dig(e);
}else{
if (pos<start){
if (pos+e.length>=start){
range.setStart(e, start-pos);
}
}
if (pos<end){
if (pos+e.length>=end){
range.setEnd(e, end-pos);
}
}
pos = pos+e.length;
}
});
}
var start,end, range;
function highlight(element,st,en){
range = document.createRange();
start = st;
end = en;
dig(element);
s.addRange(range);
}
highlight($('div'),3,6);
var ra = s.getRangeAt(0);
var newNode = document.createElement("em");
newNode.appendChild(ra.extractContents());
ra.insertNode(newNode);
example: http://jsfiddle.net/niklasvh/4NDb9/
Based on the ideas of the jQuery.highlight plugin.
private highlightRange(selector: JQuery, start: number, end: number): void {
let cur = 0;
let replacements: { node: Text; pos: number; len: number }[] = [];
let dig = function (node: Node): void {
if (node.nodeType === 3) {
let nodeLen = (node as Text).data.length;
let next = cur + nodeLen;
if (next > start && cur < end) {
let pos = cur >= start ? cur : start;
let len = (next < end ? next : end) - pos;
if (len > 0) {
if (!(pos === cur && len === nodeLen && node.parentNode &&
node.parentNode.childNodes && node.parentNode.childNodes.length === 1 &&
(node.parentNode as Element).tagName === 'SPAN' && (node.parentNode as Element).className === 'highlight1')) {
replacements.push({
node: node as Text,
pos: pos - cur,
len: len,
});
}
}
}
cur = next;
}
else if (node.nodeType === 1) {
let childNodes = node.childNodes;
if (childNodes && childNodes.length) {
for (let i = 0; i < childNodes.length; i++) {
dig(childNodes[i]);
if (cur >= end) {
break;
}
}
}
}
};
selector.each(function (index, element): void {
dig(element);
});
for (let i = 0; i < replacements.length; i++) {
let replacement = replacements[i];
let highlight = document.createElement('span');
highlight.className = 'highlight1';
let wordNode = replacement.node.splitText(replacement.pos);
wordNode.splitText(replacement.len);
let wordClone = wordNode.cloneNode(true);
highlight.appendChild(wordClone);
wordNode.parentNode.replaceChild(highlight, wordNode);
}
}
I know that the question is not about this relevant but this is what I was actually searching for.
If you need to Highlight SELECTED TEXT
Use the following principe: operate with Selection Range methods, like this
document.getSelection().getRangeAt(0).surroundContents(YOUR_WRAPPER_NODE) // Adds wrapper
document.getSelection().getRangeAt(0).insertNode(NEW_NODE) // Inserts a new node
That's it, I recomend you to study more about Range methods.
I was strugling with this and my searching requests were incorrect, so I decided to post it here for the case there will be people like me.
Sorry again for irelevant answer.

IE's document.selection.createRange doesn't include leading or trailing blank lines

I'm trying to extract the exact selection and cursor location from a textarea. As usual, what's easy in most browsers is not in IE.
I'm using this:
var sel=document.selection.createRange();
var temp=sel.duplicate();
temp.moveToElementText(textarea);
temp.setEndPoint("EndToEnd", sel);
selectionEnd = temp.text.length;
selectionStart = selectionEnd - sel.text.length;
Which works 99% of the time. The problem is that TextRange.text doesn't return leading or trailing new line characters. So when the cursor is a couple of blank lines after a paragraph it yields a position at the end of the preceeding paragraph - rather than the actual cursor position.
eg:
the quick brown fox| <- above code thinks the cursor is here
| <- when really it's here
The only fix I can think of is to temporarily insert a character before and after the selection, grab the actual selection and then remove those temp characters again. It's a hack but in a quick experiment looks like it will work.
But first I'd like to be sure there's not an easier way.
I'm adding another answer since my previous one is already getting somewhat epic.
This is what I consider the best version yet: it takes bobince's approach (mentioned in the comments to my first answer) and fixes the two things I didn't like about it, which were first that it relies on TextRanges that stray outside the textarea (thus harming performance), and second the dirtiness of having to pick a giant number for the number of characters to move the range boundary.
function getSelection(el) {
var start = 0, end = 0, normalizedValue, range,
textInputRange, len, endRange;
if (typeof el.selectionStart == "number" && typeof el.selectionEnd == "number") {
start = el.selectionStart;
end = el.selectionEnd;
} else {
range = document.selection.createRange();
if (range && range.parentElement() == el) {
len = el.value.length;
normalizedValue = el.value.replace(/\r\n/g, "\n");
// Create a working TextRange that lives only in the input
textInputRange = el.createTextRange();
textInputRange.moveToBookmark(range.getBookmark());
// Check if the start and end of the selection are at the very end
// of the input, since moveStart/moveEnd doesn't return what we want
// in those cases
endRange = el.createTextRange();
endRange.collapse(false);
if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
start = end = len;
} else {
start = -textInputRange.moveStart("character", -len);
start += normalizedValue.slice(0, start).split("\n").length - 1;
if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
end = len;
} else {
end = -textInputRange.moveEnd("character", -len);
end += normalizedValue.slice(0, end).split("\n").length - 1;
}
}
}
}
return {
start: start,
end: end
};
}
var el = document.getElementById("your_textarea");
var sel = getSelection(el);
alert(sel.start + ", " + sel.end);
The move by negative bazillion seems to work perfectly.
Here's what I ended up with:
var sel=document.selection.createRange();
var temp=sel.duplicate();
temp.moveToElementText(textarea);
var basepos=-temp.moveStart('character', -10000000);
this.m_selectionStart = -sel.moveStart('character', -10000000)-basepos;
this.m_selectionEnd = -sel.moveEnd('character', -10000000)-basepos;
this.m_text=textarea.value.replace(/\r\n/gm,"\n");
Thanks bobince - how can I vote up your answer when it's just a comment :(
A jquery plugin to get selection index start and end in text area. The above javascript codes didnt work for IE7 and IE8 and gave very inconsistent results, so I have written this small jquery plugin. Allows to temporarily save start and end index of the selection and hightlight the selection at a later time.
A working example and brief version is here: http://jsfiddle.net/hYuzk/3/
A more details version with comments etc. is here: http://jsfiddle.net/hYuzk/4/
// Cross browser plugins to set or get selection/caret position in textarea, input fields etc for IE7,IE8,IE9, FF, Chrome, Safari etc
$.fn.extend({
// Gets or sets a selection or caret position in textarea, input field etc.
// Usage Example: select text from index 2 to 5 --> $('#myTextArea').caretSelection({start: 2, end: 5});
// get selected text or caret position --> $('#myTextArea').caretSelection();
// if start and end positions are the same, caret position will be set instead o fmaking a selection
caretSelection : function(options)
{
if(options && !isNaN(options.start) && !isNaN(options.end))
{
this.setCaretSelection(options);
}
else
{
return this.getCaretSelection();
}
},
setCaretSelection : function(options)
{
var inp = this[0];
if(inp.createTextRange)
{
var selRange = inp.createTextRange();
selRange.collapse(true);
selRange.moveStart('character', options.start);
selRange.moveEnd('character',options.end - options.start);
selRange.select();
}
else if(inp.setSelectionRange)
{
inp.focus();
inp.setSelectionRange(options.start, options.end);
}
},
getCaretSelection: function()
{
var inp = this[0], start = 0, end = 0;
if(!isNaN(inp.selectionStart))
{
start = inp.selectionStart;
end = inp.selectionEnd;
}
else if( inp.createTextRange )
{
var inpTxtLen = inp.value.length, jqueryTxtLen = this.val().length;
var inpRange = inp.createTextRange(), collapsedRange = inp.createTextRange();
inpRange.moveToBookmark(document.selection.createRange().getBookmark());
collapsedRange.collapse(false);
start = inpRange.compareEndPoints('StartToEnd', collapsedRange) > -1 ? jqueryTxtLen : inpRange.moveStart('character', -inpTxtLen);
end = inpRange.compareEndPoints('EndToEnd', collapsedRange) > -1 ? jqueryTxtLen : inpRange.moveEnd('character', -inpTxtLen);
}
return {start: Math.abs(start), end: Math.abs(end)};
},
// Usage: $('#txtArea').replaceCaretSelection({start: startIndex, end: endIndex, text: 'text to replace with', insPos: 'before|after|select'})
// Options start: start index of the text to be replaced
// end: end index of the text to be replaced
// text: text to replace the selection with
// insPos: indicates whether to place the caret 'before' or 'after' the replacement text, 'select' will select the replacement text
replaceCaretSelection: function(options)
{
var pos = this.caretSelection();
this.val( this.val().substring(0,pos.start) + options.text + this.val().substring(pos.end) );
if(options.insPos == 'before')
{
this.caretSelection({start: pos.start, end: pos.start});
}
else if( options.insPos == 'after' )
{
this.caretSelection({start: pos.start + options.text.length, end: pos.start + options.text.length});
}
else if( options.insPos == 'select' )
{
this.caretSelection({start: pos.start, end: pos.start + options.text.length});
}
}
});
N.B. Please refer to my other answer for the best solution I can offer. I'm leaving this here for background.
I've come across this problem and written the following that works in all cases. In IE it does use the method you suggested of temporarily inserting a character at the selection boundary, and then uses document.execCommand("undo") to remove the inserted character and prevent the insertion from remaining on the undo stack. I'm pretty sure there's no easier way. Happily, IE 9 will support the selectionStart and selectionEnd properties.
function getSelectionBoundary(el, isStart) {
var property = isStart ? "selectionStart" : "selectionEnd";
var originalValue, textInputRange, precedingRange, pos, bookmark;
if (typeof el[property] == "number") {
return el[property];
} else if (document.selection && document.selection.createRange) {
el.focus();
var range = document.selection.createRange();
if (range) {
range.collapse(!!isStart);
originalValue = el.value;
textInputRange = el.createTextRange();
precedingRange = textInputRange.duplicate();
pos = 0;
if (originalValue.indexOf("\r\n") > -1) {
// Trickier case where input value contains line breaks
// Insert a character in the text input range and use that as
// a marker
range.text = " ";
bookmark = range.getBookmark();
textInputRange.moveToBookmark(bookmark);
precedingRange.setEndPoint("EndToStart", textInputRange);
pos = precedingRange.text.length - 1;
// Executing an undo command to delete the character inserted
// prevents this method adding to the undo stack. This trick
// came from a user called Trenda on MSDN:
// http://msdn.microsoft.com/en-us/library/ms534676%28VS.85%29.aspx
document.execCommand("undo");
} else {
// Easier case where input value contains no line breaks
bookmark = range.getBookmark();
textInputRange.moveToBookmark(bookmark);
precedingRange.setEndPoint("EndToStart", textInputRange);
pos = precedingRange.text.length;
}
return pos;
}
}
return 0;
}
var el = document.getElementById("your_textarea");
var startPos = getSelectionBoundary(el, true);
var endPos = getSelectionBoundary(el, false);
alert(startPos + ", " + endPos);
UPDATE
Based on bobince's suggested approach in the comments, I've created the following, which seems to work well. Some notes:
bobince's approach is simpler and shorter.
My approach is intrusive: it makes changes to the input's value before reverting those changes, although there is no visible effect of this.
My approach has the advantage of keeping all operations within the input. bobince's approach relies on creating ranges that span from the start of the body to the current selection.
A consequence of 3. is that the performance of bobince's varies with the position of the input within the document whereas mine does not. My simple tests suggest that when the input is close to the start of the document, bobince's approach is significantly faster. When the input is after a significant chunk of HTML, my approach is faster.
function getSelection(el) {
var start = 0, end = 0, normalizedValue, textInputRange, elStart;
var range = document.selection.createRange();
var bigNum = -1e8;
if (range && range.parentElement() == el) {
normalizedValue = el.value.replace(/\r\n/g, "\n");
start = -range.moveStart("character", bigNum);
end = -range.moveEnd("character", bigNum);
textInputRange = el.createTextRange();
range.moveToBookmark(textInputRange.getBookmark());
elStart = range.moveStart("character", bigNum);
// Adjust the position to be relative to the start of the input
start += elStart;
end += elStart;
// Correct for line breaks so that offsets are relative to the
// actual value of the input
start += normalizedValue.slice(0, start).split("\n").length - 1;
end += normalizedValue.slice(0, end).split("\n").length - 1;
}
return {
start: start,
end: end
};
}
var el = document.getElementById("your_textarea");
var sel = getSelection(el);
alert(sel.start + ", " + sel.end);

Is there an Internet Explorer approved substitute for selectionStart and selectionEnd?

Finding out what's selected in real browsers is as simple as:
var range = {
start: textbox.selectionStart,
end: textbox.selectionEnd
}
But IE, as usual, doesn't understand. What's the best cross-browser way to do this?
I'll post this function for another time, seeing as this question got linked to from another one.
The following will do the job in all browsers and deals with all new line problems without seriously compromising performance. I've arrived at this after some toing and froing and now I'm pretty convinced it's the best such function around.
UPDATE
This function does assume the textarea/input has focus, so you may need to call the textarea's focus() method before calling it.
function getInputSelection(el) {
var start = 0, end = 0, normalizedValue, range,
textInputRange, len, endRange;
if (typeof el.selectionStart == "number" && typeof el.selectionEnd == "number") {
start = el.selectionStart;
end = el.selectionEnd;
} else {
range = document.selection.createRange();
if (range && range.parentElement() == el) {
len = el.value.length;
normalizedValue = el.value.replace(/\r\n/g, "\n");
// Create a working TextRange that lives only in the input
textInputRange = el.createTextRange();
textInputRange.moveToBookmark(range.getBookmark());
// Check if the start and end of the selection are at the very end
// of the input, since moveStart/moveEnd doesn't return what we want
// in those cases
endRange = el.createTextRange();
endRange.collapse(false);
if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
start = end = len;
} else {
start = -textInputRange.moveStart("character", -len);
start += normalizedValue.slice(0, start).split("\n").length - 1;
if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
end = len;
} else {
end = -textInputRange.moveEnd("character", -len);
end += normalizedValue.slice(0, end).split("\n").length - 1;
}
}
}
}
return {
start: start,
end: end
};
}
var el = document.getElementById("your_input");
el.focus();
var sel = getInputSelection(el);
alert(sel.start + ", " + sel.end);
IE's Range implementation is a slithy horror. It really wants you to use the execrable execCommand interface instead of anything involving indexing into the text.
There are two approaches I know of for getting the indices and they both have problems. The first uses range.text as in your example code. Unfortunately range.text has a habit of stripping off leading and trailing newlines, which means if the caret/selection is at the start of a line other than the first one, beforeLength will be off by (number of newlines*2) characters and you'll get the wrong selected text.
The second approach is to use range.moveStart/End (on a duplicated range), as outlined in the answer to this question: Character offset in an Internet Explorer TextRange (however as you are using a known textarea parent you can ignore the stuff about node-finding). This doesn't have the same problem, but it does report all indices as if newlines were simple LF characters, even though textarea.value and range.text will return them as CRLF sequences! So you can't use them directly to index into the textarea, but you can either fix them up with a bunch of newline counting or just string-replace away all the CRs from the value before you use it.
My current solution is verbose and based on this thread, but I'm open to better solutions.
function getSelection(inputBox) {
if ("selectionStart" in inputBox) {
return {
start: inputBox.selectionStart,
end: inputBox.selectionEnd
}
}
//and now, the blinkered IE way
var bookmark = document.selection.createRange().getBookmark()
var selection = inputBox.createTextRange()
selection.moveToBookmark(bookmark)
var before = inputBox.createTextRange()
before.collapse(true)
before.setEndPoint("EndToStart", selection)
var beforeLength = before.text.length
var selLength = selection.text.length
return {
start: beforeLength,
end: beforeLength + selLength
}
}
From BootstrapFormHelpers
function getCursorPosition($element) {
var position = 0,
selection;
if (document.selection) {
// IE Support
$element.focus();
selection = document.selection.createRange();
selection.moveStart ('character', -$element.value.length);
position = selection.text.length;
} else if ($element.selectionStart || $element.selectionStart === 0) {
position = $element.selectionStart;
}
return position;
}
function setCursorPosition($element, position) {
var selection;
if (document.selection) {
// IE Support
$element.focus ();
selection = document.selection.createRange();
selection.moveStart ('character', -$element.value.length);
selection.moveStart ('character', position);
selection.moveEnd ('character', 0);
selection.select ();
} else if ($element.selectionStart || $element.selectionStart === 0) {
$element.selectionStart = position;
$element.selectionEnd = position;
$element.focus ();
}
}

Categories