I am using JavaScript to highlight the occurrence of characters in a UIWebView. I use the following JavaScript code:
var uiWebview_SearchResultCount = 0;
function uiWebview_HighlightAllOccurencesOfStringForElement(element,keyword) {
if (element) {
if (element.nodeType == 3) { // Text node
while (true) {
//if (counter < 1) {
var value = element.nodeValue; // Search for keyword in text node
var idx = value.toLowerCase().indexOf(keyword);
if (idx < 0) break; // not found, abort
//(value.split);
//we create a SPAN element for every parts of matched keywords
var span = document.createElement("span");
var text = document.createTextNode(value.substr(idx,keyword.length));
span.appendChild(text);
span.setAttribute("class","uiWebviewHighlight");
span.style.backgroundColor="yellow";
span.style.color="black";
uiWebview_SearchResultCount++; // update the counter
text = document.createTextNode(value.substr(idx+keyword.length));
element.deleteData(idx, value.length - idx);
var next = element.nextSibling;
element.parentNode.insertBefore(span, next);
element.parentNode.insertBefore(text, next);
element = text;
window.scrollTo(0,span.offsetTop);
}
} else if (element.nodeType == 1) { // Element node
if (element.style.display != "none" && element.nodeName.toLowerCase() != 'select') {
for (var i=element.childNodes.length-1; i>=0; i--) {
uiWebview_HighlightAllOccurencesOfStringForElement(element.childNodes[i],keyword);
}
}
}
}
}
// the main entry point to start the search
function uiWebview_HighlightAllOccurencesOfString(keyword) {
uiWebview_RemoveAllHighlights();
uiWebview_HighlightAllOccurencesOfStringForElement(document.body, keyword.toLowerCase());
}
// helper function, recursively removes the highlights in elements and their childs
function uiWebview_RemoveAllHighlightsForElement(element) {
if (element) {
if (element.nodeType == 1) {
if (element.getAttribute("class") == "uiWebviewHighlight") {
var text = element.removeChild(element.firstChild);
element.parentNode.insertBefore(text,element);
element.parentNode.removeChild(element);
return true;
} else {
var normalize = false;
for (var i=element.childNodes.length-1; i>=0; i--) {
if (uiWebview_RemoveAllHighlightsForElement(element.childNodes[i])) {
normalize = true;
}
}
if (normalize) {
element.normalize();
}
}
}
}
return false;
}
// the main entry point to remove the highlights
function uiWebview_RemoveAllHighlights() {
uiWebview_SearchResultCount = 0;
uiWebview_RemoveAllHighlightsForElement(document.body);
}
I then call this and everything works fine. My problem is that for characters that are in the middle of a word (for example و which is in the middle of توحید), it disconnects the و character from its previous character (and it becomes ت وحید).
Firefox double click selected text with next space. How to remove end space on screen with javascript.
Thank you so much.
Here's solution that does remove trailing whitespace character selected on doubleclick in Windows browsers, somewhat emulating Firefox about:config setting layout.word_select.eat_space_to_next_word being set to "false".
Does it "On screen, not in variable".
Tested in Chrome 60.0.3112.113, Firefox 55.0.3, IE8 and IE Edge(14).
It's still sometimes visible as selection shrinks from that extra space but nothing can be done about that.
Works for both arbitrary text and input/textareas(those need completely different approaches).
Uses jQuery
(function() {
var lastSelEvent = null;
var lastSelInputEvent = null;
var lastDblClickEvent = null;
var selchangeModTs = null;
$(document).on('selectstart selectionchange', function (e) //not input/textarea case
{
fixEventTS(e);
lastSelEvent = e;
if (( selchangeModTs != null) && (new Date().getTime() - selchangeModTs < 50)) //without this we get infinite loop in IE11+ as changing selection programmatically apparently generates event itself...
return;
handleSelEvent(e);
});
$(document).on('select', function (e) //input/textarea
{
fixEventTS(e);
lastSelInputEvent = e;
handleSelEvent(e);
});
$(document).on('dblclick',function(e)
{
fixEventTS(e);
lastDblClickEvent = e;
handleSelEvent(e);
});
function fixEventTS(e)
{
if (typeof e.timeStamp == 'undefined') //IE 8 no timestamps for events...
{
e.timeStamp = new Date().getTime();
}
}
function handleSelEvent(e)
{
if (lastDblClickEvent===null)
return;
if ( ((e.type==='selectstart') || (e.type==='selectionchange') || (e.type==='dblclick')) && (lastSelEvent != null) && (Math.abs(lastDblClickEvent.timeStamp - lastSelEvent.timeStamp) < 1000) ) // different browsers have different event order so take abs to be safe...
{
switch (lastSelEvent.type)
{
case 'selectstart':
setTimeout(handleSelChange,50); //IE8 etc fix, selectionchange is actually only change, not selection "creation"
break;
case 'selectionchange':
handleSelChange();
break;
}
}
if ( ((e.type==='select') || (e.type==='dblclick')) && (lastSelInputEvent != null) && (Math.abs(lastDblClickEvent.timeStamp - lastSelInputEvent.timeStamp) < 1000) ){
handleSel(lastSelInputEvent);
}
}
function handleSel(e)
{
//right whitespace
while (/\s$/.test(e.target.value.substr(e.target.selectionEnd - 1, 1)) && e.target.selectionEnd > 0) {
e.target.selectionEnd -= 1;
}
//left whitespace
while (/\s$/.test(e.target.value.substr(e.target.selectionStart, 1)) && e.target.selectionStart > 0) {
e.target.selectionStart += 1;
}
}
function handleSelChange() {
var sel = null;
if (typeof window.getSelection == 'function') // modern browsers
sel = window.getSelection();
else if (typeof document.getSelection == 'function')
sel = document.getSelection();
if (sel && !sel.isCollapsed) {
var range = sel.getRangeAt(0); //have to use range instead of more direct selection.expand/selection.modify as otherwise have to consider selection's direction in non-doubleclick cases
if (range.endOffset > 0 && (range.endContainer.nodeType===3) && (range.endContainer.textContent != ''/*otherwise rightside pictures get deleted*/))
{
//right whitespaces
while ( /[\s\S]+\s$/.test(range.endContainer.textContent.substr(0,range.endOffset)) ) { //have to use instead of range.toString() for IE11+ as it auto-trims whitespaces there and in selection.toString()
selchangeModTs = new Date().getTime();
range.setEnd(range.endContainer, range.endOffset - 1);
}
}
if ((range.startContainer.nodeType===3) && (range.startContainer.textContent != '') && (range.startOffset < range.startContainer.textContent.length)) {
//left whitespaces
while (/^\s[\s\S]+/.test(range.startContainer.textContent.substr(range.startOffset))) {
selchangeModTs = new Date().getTime();
range.setStart(range.startContainer, range.startOffset + 1);
}
}
selchangeModTs = new Date().getTime();
sel.removeAllRanges(); //IE11+ fix, in Firefox/Chrome changes to range apply to selection automatically
sel.addRange(range);
}
else if (typeof document.selection != 'undefined') //IE 10 and lower case
{
var range = document.selection.createRange();
if (range && range.text && range.text.toString()) {
while ((range.text != '') && /[\s\S]+\s$/.test(range.text.toString())) {
selchangeModTs = new Date().getTime();
range.moveEnd('character', -1);
range.select();
}
while ((range.text != '') && /^\s[\s\S]+/.test(range.text.toString())) {
selchangeModTs = new Date().getTime();
range.moveStart('character', 1);
range.select();
}
}
}
}
})();
If you don't need old IE support & limitint to doubleclick-only removal it can be greatly simplified and only needs to handle selectchange and select events to respective functions.(but then you actually can't select stuff with keyboard)
Edit:
Using Rangy lib(https://github.com/timdown/rangy) Core + TextRange for non-input elements selection trimming is way better than my basic attempt that only works if endContainer happens to be a text node. Basic action of selection trimming is
rangy.getSelection().trim()
Minimal includes are
https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js
https://cdnjs.cloudflare.com/ajax/libs/rangy/1.3.0/rangy-core.min.js
https://cdnjs.cloudflare.com/ajax/libs/rangy/1.3.0/rangy-textrange.min.js
Rangy implementation additionally supporting triple-click whole-line selection trimming
(function() {
var lastSelEvent = null;
var lastSelInputEvent = null;
var lastDblClickEvent = null;
var selchangeModTs = null;
var tripleclickFixBound = false;
var selStartTimout = null;
$(document).on('selectstart selectionchange', function (e) //non-inputs
{
fixEventTS(e);
lastSelEvent = e;
if ( ( selchangeModTs != null) && (new Date().getTime() - selchangeModTs < 50) ) //ie11+ fix otherwise get self-loop with our selection changes generating this event
return;
handleSelEvent(e);
});
if ('onselect' in document.documentElement) {
$(document).on('select', function (e) //input/textarea
{
fixEventTS(e);
lastSelInputEvent = e;
handleSelEvent(e);
});
}
$(document).on('click',function(e){
if (typeof e.originalEvent.detail !== 'undefined')
{
multiclickHandlerfunction(e);
}
else
{
fixEventTS(e);
if (!tripleclickFixBound) {
$(document).on('dblclick', function (e) {
fixEventTS(e);
selchangeModTs = null;
lastDblClickEvent = e;
handleSelEvent(e);
});
tripleclickFixBound=true;
}
if ( (lastDblClickEvent != null) && (e.timeStamp - lastDblClickEvent.timeStamp < 300))
{
lastDblClickEvent = e;
selchangeModTs = null;
handleSelEvent(e);
}
}
});
function multiclickHandlerfunction(e) {
if (e.originalEvent.detail === 2 || e.originalEvent.detail === 3) {
fixEventTS(e);
selchangeModTs = null;
lastDblClickEvent = e;
handleSelEvent(e);
}
}
function fixEventTS(e)
{
if (typeof e.timeStamp == 'undefined') //IE 8
{
e.timeStamp = new Date().getTime();
}
}
function handleSelEvent(e)
{
if (lastDblClickEvent===null)
return;
if ( ((e.type==='selectstart') || (e.type==='selectionchange') || (e.type==='dblclick') || (e.type==='click')) && (lastSelEvent != null) && (Math.abs(lastDblClickEvent.timeStamp - lastSelEvent.timeStamp) < 1000) ) // different order of events in different browsers...
{
switch (lastSelEvent.type)
{
case 'selectstart':
case 'selectionchange':
clearTimeout(selStartTimout);
selStartTimout = setTimeout(handleSelChange,(/msie\s|trident\/|edge\//i.test(window.navigator.userAgent)?150:0));
break;
}
}
if ( ((e.type==='select') || (e.type==='dblclick') || (e.type==='click')) && (lastSelInputEvent != null) && (Math.abs(lastDblClickEvent.timeStamp - lastSelInputEvent.timeStamp) < 1000) )
{
handleSel(lastSelInputEvent);
}
}
function handleSel(e)
{
if (typeof(e.target.selectionEnd) != 'undefined') {
//left whitespace
while (/\s$/.test(e.target.value.substr(e.target.selectionEnd - 1, 1)) && e.target.selectionEnd > 0) {
e.target.selectionEnd -= 1;
}
//right whitespace
while (/^s/.test(e.target.value.substr(e.target.selectionStart - 1, 1)) && e.target.selectionStart > 0) {
e.target.selectionStart += 1;
}
}
}
function handleSelChange() {
var sel = rangy.getSelection();
if (sel && !sel.isCollapsed) {
selchangeModTs = new Date().getTime();
sel.trim();
}
else if (typeof document.selection != 'undefined') //IE 10- input/textArea case
{
var range = document.selection.createRange();
if (range && range.text && range.text.toString()) {
while ((range.text != '') && /[\s\S]+\s$/.test(range.text.toString())) {
selchangeModTs = new Date().getTime();
range.moveEnd('character', -1);
range.select();
}
while ((range.text != '') && /^\s[\s\S]+/.test(range.text.toString())) {
selchangeModTs = new Date().getTime();
range.moveStart('character', 1);
range.select();
}
}
}
}
})($,window);
CodePen demo
One thing I tried was to use range.setEnd() using the selection focus and anchor node and offset but -1.
Please excuse the lack of checks for browser compatibility.
var selection = window.getSelection();
var range = selection.getRangeAt(0);
var selected = range.toString();
if (!selection.isCollapsed) {
if (/\s+$/.test(selected)) {
if (selection.focusOffset > selection.anchorOffset) {
range.setEnd(selection.focusNode, selection.focusOffset - 1);
} else {
range.setEnd(selection.anchorNode, selection.anchorOffset - 1);
}
}
}
I am unsure if it works in IE/Edge but Chrome seems happy with it.
You must be using a Windows machine. And so you are seeing this issue. It doesnt exists on ubuntu machine.
Also if you need to remove the trailing space with Js
Try something like this.
document.ondblclick = function () {
var sel = (document.selection && document.selection.createRange().text) ||
(window.getSelection && window.getSelection().toString());
var selection = window.getSelection();
$('#new_word').text(sel.trim());
};
Check this jsFiddle http://jsfiddle.net/shinde87sagar/FvkwV/
Thanks to #James Bellaby
It worked for me using Chrome 83, this is the code for removing leading and trailing spaces from the selected range.
function fix_selection(range) {
var selection = window.getSelection();
var selected = range.toString();
range = selection.getRangeAt(0);
let start = selection.anchorOffset;
let end = selection.focusOffset;
if (!selection.isCollapsed) {
if (/\s+$/.test(selected)) { // Removes leading spaces
if (start > end) {
range.setEnd(selection.focusNode, --start);
} else {
range.setEnd(selection.anchorNode, --end);
}
}
if (/^\s+/.test(selected)) { // Removes trailing spaces
if (start > end) {
range.setStart(selection.anchorNode, ++end);
} else {
range.setStart(selection.focusNode, ++start);
}
}
}
return range
}
just put the text inside a label and the problem will be solved
Javascript has a trim() method which trims tailing (end) and leading spaces.
e.g.
var str = " Hello World! ";
str = str.trim(); // returns "Hello World"
Ref: http://www.w3schools.com/jsref/jsref_trim_string.asp
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.
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);
}
}
have this html:
<div id="editable" contentEditable="true" >
<span contentEditable="false" >Text to delete</span>
</div>
need that the span (and all text inside) is removed with a single backspace, is it possible?
This turned out to be more complicated than I thought. Or I've made it more complicated than it needs to be. Anyway, this should work in all the big browsers:
function getLastTextNodeIn(node) {
while (node) {
if (node.nodeType == 3) {
return node;
} else {
node = node.lastChild;
}
}
}
function isRangeAfterNode(range, node) {
var nodeRange, lastTextNode;
if (range.compareBoundaryPoints) {
nodeRange = document.createRange();
lastTextNode = getLastTextNodeIn(node);
nodeRange.selectNodeContents(lastTextNode);
nodeRange.collapse(false);
return range.compareBoundaryPoints(range.START_TO_END, nodeRange) > -1;
} else if (range.compareEndPoints) {
if (node.nodeType == 1) {
nodeRange = document.body.createTextRange();
nodeRange.moveToElementText(node);
nodeRange.collapse(false);
return range.compareEndPoints("StartToEnd", nodeRange) > -1;
} else {
return false;
}
}
}
document.getElementById("editable").onkeydown = function(evt) {
var sel, range, node, nodeToDelete, nextNode, nodeRange;
evt = evt || window.event;
if (evt.keyCode == 8) {
// Get the DOM node containing the start of the selection
if (window.getSelection && window.getSelection().getRangeAt) {
range = window.getSelection().getRangeAt(0);
} else if (document.selection && document.selection.createRange) {
range = document.selection.createRange();
}
if (range) {
node = this.lastChild;
while (node) {
if ( isRangeAfterNode(range, node) ) {
nodeToDelete = node;
break;
} else {
node = node.previousSibling;
}
}
if (nodeToDelete) {
this.removeChild(nodeToDelete);
}
}
return false;
}
};
Because you want to delete the whole element, it's better to make it contenteditable="false" so that browser won't let the contents of an element to be deleted.
Then you can use this attribute for tests in event handler as follows:
$('#editable').on('keydown', function (event) {
if (window.getSelection && event.which == 8) { // backspace
// fix backspace bug in FF
// https://bugzilla.mozilla.org/show_bug.cgi?id=685445
var selection = window.getSelection();
if (!selection.isCollapsed || !selection.rangeCount) {
return;
}
var curRange = selection.getRangeAt(selection.rangeCount - 1);
if (curRange.commonAncestorContainer.nodeType == 3 && curRange.startOffset > 0) {
// we are in child selection. The characters of the text node is being deleted
return;
}
var range = document.createRange();
if (selection.anchorNode != this) {
// selection is in character mode. expand it to the whole editable field
range.selectNodeContents(this);
range.setEndBefore(selection.anchorNode);
} else if (selection.anchorOffset > 0) {
range.setEnd(this, selection.anchorOffset);
} else {
// reached the beginning of editable field
return;
}
range.setStart(this, range.endOffset - 1);
var previousNode = range.cloneContents().lastChild;
if (previousNode && previousNode.contentEditable == 'false') {
// this is some rich content, e.g. smile. We should help the user to delete it
range.deleteContents();
event.preventDefault();
}
}
});
demo on jsfiddle