Persisting the changes of range objects after selection in HTML - javascript

Is there a way to save the changes like changing the background of HTML text that span over multiple tags so that when it is loaded again the changes made should be reflected in the HTML page.
EDIT: Detailed explanation.
When the HTML page is loaded, the text is selected and highlighted using the range object and the executeCommand:
document.execCommand("BackColor", false, 'yellow');
The changes (highlighting the text as yellow) remain until the page is reloaded. But when the page is reloaded these changes are not there. What i want is to save somehow these changes like in local DB sqlite so that when page is reloaded/refreshed the changes in HTML page should appear.
Any idea how to do it. Do i need to save its range start offset and end offset which can be used to create range next time the page is loaded. Please give your insights.

For each selection, you could serialize the selected range to character offsets and deserialize it again on reload using something like this:
Demo: http://jsfiddle.net/WeWy7/3/
Code:
var saveSelection, restoreSelection;
if (window.getSelection && document.createRange) {
saveSelection = function(containerEl) {
var range = window.getSelection().getRangeAt(0);
var preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(containerEl);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
var start = preSelectionRange.toString().length;
return {
start: start,
end: start + range.toString().length
};
};
restoreSelection = function(containerEl, savedSel) {
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 && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
range.setStart(node, savedSel.start - charIndex);
foundStart = true;
}
if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
range.setEnd(node, savedSel.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) {
saveSelection = function(containerEl) {
var selectedTextRange = document.selection.createRange();
var preSelectionTextRange = document.body.createTextRange();
preSelectionTextRange.moveToElementText(containerEl);
preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange);
var start = preSelectionTextRange.text.length;
return {
start: start,
end: start + selectedTextRange.text.length
}
};
restoreSelection = function(containerEl, savedSel) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", savedSel.end);
textRange.moveStart("character", savedSel.start);
textRange.select();
};
}

Using character offsets doesn't work if the cursor is at the beginning of a new paragraph. The approach below walks the DOM node and counts all nodes towards the offset. It also handles start and end individually to make sure that the selection remembers its exact position. Here is an updated version that I use in a major project (see functions at end):
/*
Gets the offset of a node within another node. Text nodes are
counted a n where n is the length. Entering (or passing) an
element is one offset. Exiting is 0.
*/
var getNodeOffset = function(start, dest) {
var offset = 0;
var node = start;
var stack = [];
while (true) {
if (node === dest) {
return offset;
}
// Go into children
if (node.firstChild) {
// Going into first one doesn't count
if (node !== start)
offset += 1;
stack.push(node);
node = node.firstChild;
}
// If can go to next sibling
else if (stack.length > 0 && node.nextSibling) {
// If text, count length (plus 1)
if (node.nodeType === 3)
offset += node.nodeValue.length + 1;
else
offset += 1;
node = node.nextSibling;
}
else {
// If text, count length
if (node.nodeType === 3)
offset += node.nodeValue.length + 1;
else
offset += 1;
// No children or siblings, move up stack
while (true) {
if (stack.length <= 1)
return offset;
var next = stack.pop();
// Go to sibling
if (next.nextSibling) {
node = next.nextSibling;
break;
}
}
}
}
};
// Calculate the total offsets of a node
var calculateNodeOffset = function(node) {
var offset = 0;
// If text, count length
if (node.nodeType === 3)
offset += node.nodeValue.length + 1;
else
offset += 1;
if (node.childNodes) {
for (var i=0;i<node.childNodes.length;i++) {
offset += calculateNodeOffset(node.childNodes[i]);
}
}
return offset;
};
// Determine total offset length from returned offset from ranges
var totalOffsets = function(parentNode, offset) {
if (parentNode.nodeType == 3)
return offset;
if (parentNode.nodeType == 1) {
var total = 0;
// Get child nodes
for (var i=0;i<offset;i++) {
total += calculateNodeOffset(parentNode.childNodes[i]);
}
return total;
}
return 0;
};
var getNodeAndOffsetAt = function(start, offset) {
var node = start;
var stack = [];
while (true) {
// If arrived
if (offset <= 0)
return { node: node, offset: 0 };
// If will be within current text node
if (node.nodeType == 3 && (offset <= node.nodeValue.length))
return { node: node, offset: Math.min(offset, node.nodeValue.length) };
// Go into children (first one doesn't count)
if (node.firstChild) {
if (node !== start)
offset -= 1;
stack.push(node);
node = node.firstChild;
}
// If can go to next sibling
else if (stack.length > 0 && node.nextSibling) {
// If text, count length
if (node.nodeType === 3)
offset -= node.nodeValue.length + 1;
else
offset -= 1;
node = node.nextSibling;
}
else {
// No children or siblings, move up stack
while (true) {
if (stack.length <= 1) {
// No more options, use current node
if (node.nodeType == 3)
return { node: node, offset: Math.min(offset, node.nodeValue.length) };
else
return { node: node, offset: 0 };
}
var next = stack.pop();
// Go to sibling
if (next.nextSibling) {
// If text, count length
if (node.nodeType === 3)
offset -= node.nodeValue.length + 1;
else
offset -= 1;
node = next.nextSibling;
break;
}
}
}
}
};
exports.save = function(containerEl) {
// Get range
var selection = window.getSelection();
if (selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
return {
start: getNodeOffset(containerEl, range.startContainer) + totalOffsets(range.startContainer, range.startOffset),
end: getNodeOffset(containerEl, range.endContainer) + totalOffsets(range.endContainer, range.endOffset)
};
}
else
return null;
};
exports.restore = function(containerEl, savedSel) {
if (!savedSel)
return;
var range = document.createRange();
var startNodeOffset, endNodeOffset;
startNodeOffset = getNodeAndOffsetAt(containerEl, savedSel.start);
endNodeOffset = getNodeAndOffsetAt(containerEl, savedSel.end);
range.setStart(startNodeOffset.node, startNodeOffset.offset);
range.setEnd(endNodeOffset.node, endNodeOffset.offset);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
This only works on modern browsers (IE 9+ at least).

Without knowing more about the context, it is hard to give an exact answer, but yes it would be possible, but it will be quite complex for most cases. Depending on the usecase, there are a few ways to go.
Cookies or Local storage
You could use some sort of client-side storage (cookies, local storage or similar) and save information about what elements were modified and how. Whenever the page is reloaded you read that storage and apply the changes. How to implement it will depend on how those changes are made, and will be to0 extensive to cover in a single SO-answer I'm afraid.
Server-side storage
If you know who each user is (you have some form of authentication), whenever they change the appearance of something (however that is made), you make an ajax-request to the server and save those changes to a database. On every subsequent page load, you would then have to check what use is making the request, do a lookup in your database to see if they've made any changes, and in that case apply them accordingly.
Common for both the client- and server-side storage solutions is that they will be quite extensive to implement I believe.
Browser plugin
Another way to go would be to make use of plugins like Greasemonkey for Firefox that allow the user to customize the way a webpage is rendered. Those customizations will be persistent across page loads.

Related

Creating, setting styles on a range

I am trying to auto-detect addresses on a page and add the class "address" where found.
var rangyPatternApplier = function(element, pattern, style) {
var innerText = element.innerText;
var matches = innerText.match(pattern);
if (matches) {
for (var i = 0; i < matches.length; i++) {
console.log("Match: " + matches[i]);
var start = innerText.indexOf(matches[i]);
var end = start + matches[i].length;
let range = document.createRange();
var start = innerText.indexOf(matches[i]);
console.log('inner text: ' + innerText);
console.log('start: ' + start);
console.log('starts with: ' + innerText.substring(start));
var end = start + matches[i].length;
var startNode = element.childNodes[0];
var endNode = startNode;
while (startNode.nodeValue.length < start) {
start -= startNode.nodeValue.length;
end -= startNode.nodeValue.length;
startNode = startNode.nextSibling;
endNode = startNode;
if (startNode == null) {
error.reportError("Just wrong in Sections.rangyPatternApplier");
return;
}
}
while (endNode.nodeValue.length < end) {
end -= endNode.nodeValue.length;
if (endNode.nextSibling) endNode = endNode.nextSibling;
while (!endNode.nodeValue) {
endNode = endNode.childNodes[0];
}
if (endNode == null) {
error.reportError("Just wrong in Sections.rangyPatternApplier");
}
}
range.setStart(startNode, start);
console.log("starts with: " + startNode.nodeValue.substring(start));
range.setEnd(endNode, end);
var applier = rangy.createClassApplier(style, {
elementTagName: "span",
elementProperties: {
},
});
window.getSelection().addRange(range);
applier.toggleSelection();
}
}
}
Called via:
$("P").each(function () {
rangyPatternApplier(this, new RegExp("\\d+\\s[A-z]+\\s[A-z0-9]+\\s(Street|St|Avenue|Av|Ave|Road|Rd)", "mgi"), "Address");
});
On text in a paragraph:
If the income renders the household ineligible for CA/CILOCA, the case will be systemically referred to the Administration for Children s Services Transitional Child Care Unit at 109 East 16th Street 3rd floor for evaluation of Transitional Child Care (TCC) benefits. The TCC Worker determines eligibility for up to 12 months of TCC benefits.
The regex is working, the address class is being applied. I am applying the range to the window selection because there appears to be a bug in rangy when applied just on the Range (I'm getting an error message). But somehow, when I create the range, the span appears 5 characters before the start of the address and ends 9 characters early. The early ending part could be due to the tag around the "th" in 16th street. But why is the range 5 characters earlier than what I'm finding in innerText?
Sheesh this was a pain but I got it working. Adding my solution here so hopefully at least a few people don't have to go through doing something that should be much more "built-in", in my opinion
//nextTextNode is for getting the next text node from the DOM
function nextTextNode(node) {
if (node.nodeType == 1) { //element
while (node.nodeType != 3) {
node = node.firstChild;
}
return node;
}
if (node.nodeType == 3) { //text node
if (node.nextSibling) {
if (node.nextSibling.nodeType == 3) {
return node.nextSibling;
} else {
return nextTextNode(node.nextSibling);
}
} else {
while (!node.nextSibling) {
node = node.parentNode;
if (!node) return null;
}
if (node.nextSibling.nodeType == 3) {
return node.nextSibling;
} else {
return nextTextNode(node.nextSibling);
}
}
} else {
throw new Error("nextTextNode: Node is either null, not connected to the DOM, or is not of node type 1 or 3");
}
}
And then create range. Text nodes have extra newline and space characters compared to element.innerText . In the function below I track both the number of extra characters and the total characters to keep track of the inconsistancies between innerText and node.nodeValue and how many characters "in" it is.
function createRangeForString(startElement, text) {
var extras = 0;
var innerText = startElement.innerText;
var start = innerText.indexOf(text);
if (start === -1) throw new Error ("createRangeForString. text: " + text + " not found in startElement");
var textNode = nextTextNode(startElement);
var totalCharsSeen = 0;
var range = document.createRange();
for (var i = 0; i < start; i++) { // I don't think I have to add extras in limit for i. Is already included
if ((i + extras) - totalCharsSeen >= textNode.nodeValue.length) { //check if textNode is long enough
totalCharsSeen += textNode.nodeValue.length;
textNode = nextTextNode(textNode);
}
while (textNode.nodeValue.charAt(i + extras - totalCharsSeen) == "\n") {
extras++;
}
while (textNode.nodeValue.charAt(i + extras - totalCharsSeen) == " " && innerText.charAt(i) != " ") {
extras++;
}
}
range.setStart(textNode, i + extras - totalCharsSeen);
var end = start + text.length;
for (var i = start + 1; i < end; i++) { // I don't think I have to add extras in limit for i. Is already included
if ((i + extras) - totalCharsSeen >= textNode.nodeValue.length) { //check if textNode is long enough
totalCharsSeen += textNode.nodeValue.length;
textNode = nextTextNode(textNode);
}
while (textNode.nodeValue.charAt(i + extras - totalCharsSeen) == "\n") {
extras++;
}
while (textNode.nodeValue.charAt(i + extras - totalCharsSeen) == " " && innerText.charAt(i) != " ") {
extras++;
}
}
range.setEnd(textNode, i + extras - totalCharsSeen);
return range;
}

How do I use javascript to get & set caret position using character offset?

Is there a way to use javascript to get & set caret position using the character offset (relative to the start of the document)?
In my situation, for many reasons, the preferred value to work with is the character offset and not dom nodes. Is there a reasonably fast (no noticeable time) way to accomplish this? I would prefer to treat any 'p' tag as a character ('\n'), but if this makes the solution impossible, ignoring them is ok.
Edit:
Since it is apparently confusing, I am asking about a caret because there is a caret. Not because there isn't. [Those who don't know about contentEditable have no chance of answering the question anyways, so I deemed it unnecessary to explain]. Since I am expected to demonstrate that I tried but don't know the solution to the question I am asking (why else would I be asking, I wonder?), here is the best attempt I have been able to work out. It doesn't give the correct answer.
Edit:
It now gives the correct answer; the only question would be if there is some javascript method or simpler, faster approach.
Edit:
Fixed to give correct answer when selection ends after newline.
function nodeOffset(findnode) {
var offset = 0;
var root = document.body
var node = root.childNodes[0];
while(node != null) {
if(node.nodeType == 3) {
if(node == findnode)
return offset;
offset += node.length; //text
} else if (node.tagName == 'P') {
offset += 1; //newline character
if(node == findnode)
return offset;
}
if(node.hasChildNodes()) {
node = node.firstChild;
} else {
while(node.nextSibling == null && node != root) {
node = node.parentNode;
}
node = node.nextSibling;
}
}
}
var selection = window.getSelection();
var offset = nodeOffset(selection.focusNode) + selection.focusOffset;
A brute force approach. (It has a good chance of tripping up, for example if other tags are display:block or if p is not display:block, or other possible issues):
To get the selection caret/anchor offset, which may be one and the same:
function nodeOffset(findnode) {
var offset = 0;
var root = document.body
var node = root.childNodes[0];
while(node != null) {
if(node.nodeType == 3) {
if(node == findnode)
return offset;
offset += node.length; //text
} else if (node.tagName == 'P') {
offset += 1; //newline character
if(node == findnode)
return offset;
}
if(node.hasChildNodes()) {
node = node.firstChild;
} else {
while(node.nextSibling == null && node != root) {
node = node.parentNode;
}
node = node.nextSibling;
}
}
}
var selection = window.getSelection();
var focusoffset = nodeOffset(selection.focusNode)+selection.focusOffset;
var anchoroffset = nodeOffset(selection.anchorNode)+selection.anchorOffset;
To set the caret/anchor offset, though they must be ordered smallest->largest, with no choice of which to be focus & which to be anchor:
function offsetNode(findoffset) {
var offset = 0;
var root = document.body
var node = root.childNodes[0];
while(node != null) {
if(node.nodeType == 3) {
offset += node.length; //text
if(offset >= findoffset)
return [node, findoffset-offset+node.length];
} else if (node.tagName == 'P') {
offset += 1; //newline character
}
if(node.hasChildNodes()) {
node = node.firstChild;
} else {
while(node.nextSibling == null && node != root) {
node = node.parentNode;
}
node = node.nextSibling;
}
}
}
var selection = window.getSelection();
var range = document.createRange();
var start = offsetNode(startoffset)
var end = offsetNode(endoffset)
range.setStart(start[0], start[1]);
range.setEnd(end[0], end[1]);
selection.removeAllRanges();
selection.addRange(range);

Can't restore selection after HTML modify, even if it's the same HTML

I'm trying to store a selection of a contentEditable element and restore it later.
I want to observe the paste event and store the HTML as it was before, clear the html and then manually insert the pasted text with some changes at the selected position.
Take a look at this example: jsfiddle.net/gEhjZ
When you select a part of the text, hit store, remove the selection again and hit restore, it's working as expected.
But when you first hit store, then replace the HTML with the exact same HTML by hitting overwrite html and then try to restore, nothing happens.
I thought that using .cloneRange() would make a difference, but it won't. Even a deep copy of the object ($.extend(true, {}, oldRange)) won't do the trick. As soon as I overwrite the HTML, the selection object sel is being changed too. It makes sense for me that changing the selection context will wipe the range, but I'm trying to restore it for the exact same HTML.
I know I could use rangy, but I really don't want to use a huge library just for this small feature. What am I missing? Any help would be much appreciated!
Note: only Firefox/Chrome, so no crossbrowser-hacks needed.
Update:
#Tim Down's answer works when using a div, but I'm actually using an iframe. When I made that example, I thought it wouldn't make any difference.
Now when I try to restore the iframe's body, i get the following error: TypeError: Value does not implement interface Node. in the following line preSelectionRange.selectNodeContents(containerEl);. I didn't get much from googling. I tried to wrap the contents of the body and restore the wrap's html, but I get the same error.
jsfiddle isn't working in this case because it is using iframes to display the results itself, so I put an example here: snipt.org/AJad3
And the same without the wrap: snipt.org/AJaf0
Update 2:
I figured that I have to use editable.get(0), of course. But now the start and end of the iframe's selection is 0. see snipt.org/AJah2
You could save and restore the character position using functions like these:
https://stackoverflow.com/a/13950376/96100
I've adapted these function slightly to work for an element inside an iframe.
Demo: http://jsfiddle.net/timdown/gEhjZ/4/
Code:
var saveSelection, restoreSelection;
if (window.getSelection && document.createRange) {
saveSelection = function(containerEl) {
var doc = containerEl.ownerDocument, win = doc.defaultView;
var range = win.getSelection().getRangeAt(0);
var preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(containerEl);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
var start = preSelectionRange.toString().length;
return {
start: start,
end: start + range.toString().length
};
};
restoreSelection = function(containerEl, savedSel) {
var doc = containerEl.ownerDocument, win = doc.defaultView;
var charIndex = 0, range = doc.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 && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
range.setStart(node, savedSel.start - charIndex);
foundStart = true;
}
if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
range.setEnd(node, savedSel.end - charIndex);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
var sel = win.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
} else if (document.selection) {
saveSelection = function(containerEl) {
var doc = containerEl.ownerDocument, win = doc.defaultView || doc.parentWindow;
var selectedTextRange = doc.selection.createRange();
var preSelectionTextRange = doc.body.createTextRange();
preSelectionTextRange.moveToElementText(containerEl);
preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange);
var start = preSelectionTextRange.text.length;
return {
start: start,
end: start + selectedTextRange.text.length
};
};
restoreSelection = function(containerEl, savedSel) {
var doc = containerEl.ownerDocument, win = doc.defaultView || doc.parentWindow;
var textRange = doc.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", savedSel.end);
textRange.moveStart("character", savedSel.start);
textRange.select();
};
}
Provided solution works very well.
replacing that line
if (!foundStart && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
by
if (!foundStart && savedSel.start >= charIndex && savedSel.start < nextCharIndex) {
prevents Chrome / Edge to select the end of the previous line

How to set the caret (cursor) position in a contenteditable element (div)?

I have this simple HTML as an example:
<div id="editable" contenteditable="true">
text text text<br>
text text text<br>
text text text<br>
</div>
<button id="button">focus</button>
I want simple thing - when I click the button, I want to place caret(cursor) into specific place in the editable div. From searching over the web, I have this JS attached to button click, but it doesn't work (FF, Chrome):
const range = document.createRange();
const myDiv = document.getElementById("editable");
range.setStart(myDiv, 5);
range.setEnd(myDiv, 5);
Is it possible to set manually caret position like this?
In most browsers, you need the Range and Selection objects. You specify each of the selection boundaries as a node and an offset within that node. For example, to set the caret to the fifth character of the second line of text, you'd do the following:
function setCaret() {
var el = document.getElementById("editable")
var range = document.createRange()
var sel = window.getSelection()
range.setStart(el.childNodes[2], 5)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
<div id="editable" contenteditable="true">
text text text<br>text text text<br>text text text<br>
</div>
<button id="button" onclick="setCaret()">focus</button>
IE < 9 works completely differently. If you need to support these browsers, you'll need different code.
jsFiddle example: http://jsfiddle.net/timdown/vXnCM/
Most answers you find on contenteditable cursor positioning are fairly simplistic in that they only cater for inputs with plain vanilla text. Once you using html elements within the container the text entered gets split into nodes and distributed liberally across a tree structure.
To set the cursor position I have this function which loops round all the child text nodes within the supplied node and sets a range from the start of the initial node to the chars.count character:
function createRange(node, chars, range) {
if (!range) {
range = document.createRange()
range.selectNode(node);
range.setStart(node, 0);
}
if (chars.count === 0) {
range.setEnd(node, chars.count);
} else if (node && chars.count >0) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.length < chars.count) {
chars.count -= node.textContent.length;
} else {
range.setEnd(node, chars.count);
chars.count = 0;
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = createRange(node.childNodes[lp], chars, range);
if (chars.count === 0) {
break;
}
}
}
}
return range;
};
I then call the routine with this function:
function setCurrentCursorPosition(chars) {
if (chars >= 0) {
var selection = window.getSelection();
range = createRange(document.getElementById("test").parentNode, { count: chars });
if (range) {
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
The range.collapse(false) sets the cursor to the end of the range. I've tested it with the latest versions of Chrome, IE, Mozilla and Opera and they all work fine.
PS. If anyone is interested I get the current cursor position using this code:
function isChildOf(node, parentId) {
while (node !== null) {
if (node.id === parentId) {
return true;
}
node = node.parentNode;
}
return false;
};
function getCurrentCursorPosition(parentId) {
var selection = window.getSelection(),
charCount = -1,
node;
if (selection.focusNode) {
if (isChildOf(selection.focusNode, parentId)) {
node = selection.focusNode;
charCount = selection.focusOffset;
while (node) {
if (node.id === parentId) {
break;
}
if (node.previousSibling) {
node = node.previousSibling;
charCount += node.textContent.length;
} else {
node = node.parentNode;
if (node === null) {
break
}
}
}
}
}
return charCount;
};
The code does the opposite of the set function - it gets the current window.getSelection().focusNode and focusOffset and counts backwards all text characters encountered until it hits a parent node with id of containerId. The isChildOf function just checks before running that the suplied node is actually a child of the supplied parentId.
The code should work straight without change, but I have just taken it from a jQuery plugin I've developed so have hacked out a couple of this's - let me know if anything doesn't work!
I refactored #Liam's answer. I put it in a class with static methods, I made its functions receive an element instead of an #id, and some other small tweaks.
This code is particularly good for fixing the cursor in a rich text box that you might be making with <div contenteditable="true">. I was stuck on this for several days before arriving at the below code.
edit: His answer and this answer have a bug involving hitting enter. Since enter doesn't count as a character, the cursor position gets messed up after hitting enter. If I am able to fix the code, I will update my answer.
edit2: Save yourself a lot of headaches and make sure your <div contenteditable=true> is display: inline-block. This fixes some bugs related to Chrome putting <div> instead of <br> when you press enter.
How To Use
let richText = document.getElementById('rich-text');
let offset = Cursor.getCurrentCursorPosition(richText);
// insert code here that does stuff to the innerHTML, such as adding/removing <span> tags
Cursor.setCurrentCursorPosition(offset, richText);
richText.focus();
Code
// Credit to Liam (Stack Overflow)
// https://stackoverflow.com/a/41034697/3480193
class Cursor {
static getCurrentCursorPosition(parentElement) {
var selection = window.getSelection(),
charCount = -1,
node;
if (selection.focusNode) {
if (Cursor._isChildOf(selection.focusNode, parentElement)) {
node = selection.focusNode;
charCount = selection.focusOffset;
while (node) {
if (node === parentElement) {
break;
}
if (node.previousSibling) {
node = node.previousSibling;
charCount += node.textContent.length;
} else {
node = node.parentNode;
if (node === null) {
break;
}
}
}
}
}
return charCount;
}
static setCurrentCursorPosition(chars, element) {
if (chars >= 0) {
var selection = window.getSelection();
let range = Cursor._createRange(element, { count: chars });
if (range) {
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
static _createRange(node, chars, range) {
if (!range) {
range = document.createRange()
range.selectNode(node);
range.setStart(node, 0);
}
if (chars.count === 0) {
range.setEnd(node, chars.count);
} else if (node && chars.count >0) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.length < chars.count) {
chars.count -= node.textContent.length;
} else {
range.setEnd(node, chars.count);
chars.count = 0;
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = Cursor._createRange(node.childNodes[lp], chars, range);
if (chars.count === 0) {
break;
}
}
}
}
return range;
}
static _isChildOf(node, parentElement) {
while (node !== null) {
if (node === parentElement) {
return true;
}
node = node.parentNode;
}
return false;
}
}
I made this for my simple text editor.
Differences from other methods:
High performance
Works with all spaces
usage
// get current selection
const [start, end] = getSelectionOffset(container)
// change container html
container.innerHTML = newHtml
// restore selection
setSelectionOffset(container, start, end)
// use this instead innerText for get text with keep all spaces
const innerText = getInnerText(container)
const textBeforeCaret = innerText.substring(0, start)
const textAfterCaret = innerText.substring(start)
selection.ts
/** return true if node found */
function searchNode(
container: Node,
startNode: Node,
predicate: (node: Node) => boolean,
excludeSibling?: boolean,
): boolean {
if (predicate(startNode as Text)) {
return true
}
for (let i = 0, len = startNode.childNodes.length; i < len; i++) {
if (searchNode(startNode, startNode.childNodes[i], predicate, true)) {
return true
}
}
if (!excludeSibling) {
let parentNode = startNode
while (parentNode && parentNode !== container) {
let nextSibling = parentNode.nextSibling
while (nextSibling) {
if (searchNode(container, nextSibling, predicate, true)) {
return true
}
nextSibling = nextSibling.nextSibling
}
parentNode = parentNode.parentNode
}
}
return false
}
function createRange(container: Node, start: number, end: number): Range {
let startNode
searchNode(container, container, node => {
if (node.nodeType === Node.TEXT_NODE) {
const dataLength = (node as Text).data.length
if (start <= dataLength) {
startNode = node
return true
}
start -= dataLength
end -= dataLength
return false
}
})
let endNode
if (startNode) {
searchNode(container, startNode, node => {
if (node.nodeType === Node.TEXT_NODE) {
const dataLength = (node as Text).data.length
if (end <= dataLength) {
endNode = node
return true
}
end -= dataLength
return false
}
})
}
const range = document.createRange()
if (startNode) {
if (start < startNode.data.length) {
range.setStart(startNode, start)
} else {
range.setStartAfter(startNode)
}
} else {
if (start === 0) {
range.setStart(container, 0)
} else {
range.setStartAfter(container)
}
}
if (endNode) {
if (end < endNode.data.length) {
range.setEnd(endNode, end)
} else {
range.setEndAfter(endNode)
}
} else {
if (end === 0) {
range.setEnd(container, 0)
} else {
range.setEndAfter(container)
}
}
return range
}
export function setSelectionOffset(node: Node, start: number, end: number) {
const range = createRange(node, start, end)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
}
function hasChild(container: Node, node: Node): boolean {
while (node) {
if (node === container) {
return true
}
node = node.parentNode
}
return false
}
function getAbsoluteOffset(container: Node, offset: number) {
if (container.nodeType === Node.TEXT_NODE) {
return offset
}
let absoluteOffset = 0
for (let i = 0, len = Math.min(container.childNodes.length, offset); i < len; i++) {
const childNode = container.childNodes[i]
searchNode(childNode, childNode, node => {
if (node.nodeType === Node.TEXT_NODE) {
absoluteOffset += (node as Text).data.length
}
return false
})
}
return absoluteOffset
}
export function getSelectionOffset(container: Node): [number, number] {
let start = 0
let end = 0
const selection = window.getSelection()
for (let i = 0, len = selection.rangeCount; i < len; i++) {
const range = selection.getRangeAt(i)
if (range.intersectsNode(container)) {
const startNode = range.startContainer
searchNode(container, container, node => {
if (startNode === node) {
start += getAbsoluteOffset(node, range.startOffset)
return true
}
const dataLength = node.nodeType === Node.TEXT_NODE
? (node as Text).data.length
: 0
start += dataLength
end += dataLength
return false
})
const endNode = range.endContainer
searchNode(container, startNode, node => {
if (endNode === node) {
end += getAbsoluteOffset(node, range.endOffset)
return true
}
const dataLength = node.nodeType === Node.TEXT_NODE
? (node as Text).data.length
: 0
end += dataLength
return false
})
break
}
}
return [start, end]
}
export function getInnerText(container: Node) {
const buffer = []
searchNode(container, container, node => {
if (node.nodeType === Node.TEXT_NODE) {
buffer.push((node as Text).data)
}
return false
})
return buffer.join('')
}
I'm writting a syntax highlighter (and basic code editor), and I needed to know how to auto-type a single quote char and move the caret back (like a lot of code editors nowadays).
Heres a snippet of my solution, thanks to much help from this thread, the MDN docs, and a lot of moz console watching..
//onKeyPress event
if (evt.key === "\"") {
let sel = window.getSelection();
let offset = sel.focusOffset;
let focus = sel.focusNode;
focus.textContent += "\""; //setting div's innerText directly creates new
//nodes, which invalidate our selections, so we modify the focusNode directly
let range = document.createRange();
range.selectNode(focus);
range.setStart(focus, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
//end onKeyPress event
This is in a contenteditable div element
I leave this here as a thanks, realizing there is already an accepted answer.
const el = document.getElementById("editable");
el.focus()
let char = 1, sel; // character at which to place caret
if (document.selection) {
sel = document.selection.createRange();
sel.moveStart('character', char);
sel.select();
}
else {
sel = window.getSelection();
sel.collapse(el.lastChild, char);
}
If you don't want to use jQuery you can try this approach:
public setCaretPosition() {
const editableDiv = document.getElementById('contenteditablediv');
const lastLine = this.input.nativeElement.innerHTML.replace(/.*?(<br>)/g, '');
const selection = window.getSelection();
selection.collapse(editableDiv.childNodes[editableDiv.childNodes.length - 1], lastLine.length);
}
editableDiv you editable element, don't forget to set an id for it. Then you need to get your innerHTML from the element and cut all brake lines. And just set collapse with next arguments.
function set_mouse() {
var as = document.getElementById("editable");
el = as.childNodes[1].childNodes[0]; //goal is to get ('we') id to write (object Text) because it work only in object text
var range = document.createRange();
var sel = window.getSelection();
range.setStart(el, 1);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
document.getElementById("we").innerHTML = el; // see out put of we id
}
<div id="editable" contenteditable="true">dddddddddddddddddddddddddddd
<p>dd</p>psss
<p>dd</p>
<p>dd</p>
<p>text text text</p>
</div>
<p id='we'></p>
<button onclick="set_mouse()">focus</button>
It is very hard set caret in proper position when you have advance element like (p) (span) etc. The goal is to get (object text):
<div id="editable" contenteditable="true">dddddddddddddddddddddddddddd<p>dd</p>psss<p>dd</p>
<p>dd</p>
<p>text text text</p>
</div>
<p id='we'></p>
<button onclick="set_mouse()">focus</button>
<script>
function set_mouse() {
var as = document.getElementById("editable");
el = as.childNodes[1].childNodes[0];//goal is to get ('we') id to write (object Text) because it work only in object text
var range = document.createRange();
var sel = window.getSelection();
range.setStart(el, 1);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
document.getElementById("we").innerHTML = el;// see out put of we id
}
</script>
I think it's not simple to set caret to some position in contenteditable element. I wrote my own code for this. It bypasses the node tree calcing how many characters left and sets caret in needed element. I didn't test this code much.
//Set offset in current contenteditable field (for start by default or for with forEnd=true)
function setCurSelectionOffset(offset, forEnd = false) {
const sel = window.getSelection();
if (sel.rangeCount !== 1 || !document.activeElement) return;
const firstRange = sel.getRangeAt(0);
if (offset > 0) {
bypassChildNodes(document.activeElement, offset);
}else{
if (forEnd)
firstRange.setEnd(document.activeElement, 0);
else
firstRange.setStart(document.activeElement, 0);
}
//Bypass in depth
function bypassChildNodes(el, leftOffset) {
const childNodes = el.childNodes;
for (let i = 0; i < childNodes.length && leftOffset; i++) {
const childNode = childNodes[i];
if (childNode.nodeType === 3) {
const curLen = childNode.textContent.length;
if (curLen >= leftOffset) {
if (forEnd)
firstRange.setEnd(childNode, leftOffset);
else
firstRange.setStart(childNode, leftOffset);
return 0;
}else{
leftOffset -= curLen;
}
}else
if (childNode.nodeType === 1) {
leftOffset = bypassChildNodes(childNode, leftOffset);
}
}
return leftOffset;
}
}
I also wrote code to get current caret position (didn't test):
//Get offset in current contenteditable field (start offset by default or end offset with calcEnd=true)
function getCurSelectionOffset(calcEnd = false) {
const sel = window.getSelection();
if (sel.rangeCount !== 1 || !document.activeElement) return 0;
const firstRange = sel.getRangeAt(0),
startContainer = calcEnd ? firstRange.endContainer : firstRange.startContainer,
startOffset = calcEnd ? firstRange.endOffset : firstRange.startOffset;
let needStop = false;
return bypassChildNodes(document.activeElement);
//Bypass in depth
function bypassChildNodes(el) {
const childNodes = el.childNodes;
let ans = 0;
if (el === startContainer) {
if (startContainer.nodeType === 3) {
ans = startOffset;
}else
if (startContainer.nodeType === 1) {
for (let i = 0; i < startOffset; i++) {
const childNode = childNodes[i];
ans += childNode.nodeType === 3 ? childNode.textContent.length :
childNode.nodeType === 1 ? childNode.innerText.length :
0;
}
}
needStop = true;
}else{
for (let i = 0; i < childNodes.length && !needStop; i++) {
const childNode = childNodes[i];
ans += bypassChildNodes(childNode);
}
}
return ans;
}
}
You also need to be aware of range.startOffset and range.endOffset contain character offset for text nodes (nodeType === 3) and child node offset for element nodes (nodeType === 1). range.startContainer and range.endContainer may refer to any element node of any level in the tree (of course they also can refer to text nodes).
Based on Tim Down's answer, but it checks for the last known "good" text row. It places the cursor at the very end.
Furthermore, I could also recursively/iteratively check the last child of each consecutive last child to find the absolute last "good" text node in the DOM.
function onClickHandler() {
setCaret(document.getElementById("editable"));
}
function setCaret(el) {
let range = document.createRange(),
sel = window.getSelection(),
lastKnownIndex = -1;
for (let i = 0; i < el.childNodes.length; i++) {
if (isTextNodeAndContentNoEmpty(el.childNodes[i])) {
lastKnownIndex = i;
}
}
if (lastKnownIndex === -1) {
throw new Error('Could not find valid text content');
}
let row = el.childNodes[lastKnownIndex],
col = row.textContent.length;
range.setStart(row, col);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
el.focus();
}
function isTextNodeAndContentNoEmpty(node) {
return node.nodeType == Node.TEXT_NODE && node.textContent.trim().length > 0
}
<div id="editable" contenteditable="true">
text text text<br>text text text<br>text text text<br>
</div>
<button id="button" onclick="onClickHandler()">focus</button>
var sel = window.getSelection();
sel?.setPosition(wordDiv.childNodes[0], 5);
event.preventDefault();
move(element:any,x:number){//parent
let arr:Array<any>=[];
arr=this.getAllnodeOfanItem(this.input.nativeElement,arr);
let j=0;
while (x>arr[j].length && j<arr.length){
x-=arr[j].length;
j++;
}
var el = arr[j];
var range = document.createRange();
var sel = window.getSelection();
range.setStart(el,x );
range.collapse(true);
if (sel)sel.removeAllRanges();
if (sel)sel.addRange(range);
}
getAllnodeOfanItem(element:any,rep:Array<any>){
let ch:Array<any>=element.childNodes;
if (ch.length==0 && element.innerText!="")
rep.push(element);
else{
for (let i=0;i<ch.length;i++){
rep=this.getAllnodeOfanItem(ch[i],rep)
}
}
return rep;
}
I've readed and tried some cases from here and just put here what is working for me, considering some details according dom nodes:
focus(textInput){
const length = textInput.innerText.length;
textInput.focus();
if(!!textInput.lastChild){
const sel = window.getSelection();
sel.collapse(textInput.lastChild, length);
}
}

How do I add text to a textarea at the cursor location using javascript [duplicate]

This question already has answers here:
Closed 10 years ago.
Possible Duplicate:
Inserting a text where cursor is using Javascript/jquery
I'm building a simple templating system here. Basically I've set up buttons that add preset variables into a textarea.
The thing is that I want to be able to add text in my textarea at the position where my cursor is. Just like when you click on the URL link in the editor window for posts here at stackoverflow the link is added at the position where your cursor is.
How can I do that? And can it be done using prototype JS?
It's a bit tricky in IE, which does an exceptionally bad job of this kind of thing. The following will insert text at the end of the current selection in a textarea or text input (or at the caret if the no text is selected) and then position the cursor after the text:
Demo: http://jsfiddle.net/RqTvT/1/
Code:
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
};
}
function offsetToRangeCharacterMove(el, offset) {
return offset - (el.value.slice(0, offset).split("\r\n").length - 1);
}
function setSelection(el, start, end) {
if (typeof el.selectionStart == "number" && typeof el.selectionEnd == "number") {
el.selectionStart = start;
el.selectionEnd = end;
} else if (typeof el.createTextRange != "undefined") {
var range = el.createTextRange();
var startCharMove = offsetToRangeCharacterMove(el, start);
range.collapse(true);
if (start == end) {
range.move("character", startCharMove);
} else {
range.moveEnd("character", offsetToRangeCharacterMove(el, end));
range.moveStart("character", startCharMove);
}
range.select();
}
}
function insertTextAtCaret(el, text) {
var pos = getInputSelection(el).end;
var newPos = pos + text.length;
var val = el.value;
el.value = val.slice(0, pos) + text + val.slice(pos);
setSelection(el, newPos, newPos);
}
var textarea = document.getElementById("your_textarea");
insertTextAtCaret(textarea, "[INSERTED]");

Categories

Resources