I was testing something and I came across a weird bug (I don't know if it's really a bug, although).
Take this simple document
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<script>
window.onload = function() {
var p = document.createElement('p');
p.appendChild( document.createTextNode('x') );
p.appendChild( document.createTextNode('y') );
p.appendChild( document.createTextNode('z') );
document.body.appendChild(p);
console.log( p.childNodes.length );
p.normalize();
console.log( p.childNodes.length );
};
</script></head><body></body></html>
In all browsers but IE 9, the console output is first 3 then 1. In IE-9 it is 3 both times -- which means that normalize() is not doing its thing. What's more surprising is that if I change the "Document mode" to IE 7 or 8 or even quirks mode, the console output is 3 and 1.
Is this some bug in IE or am I getting something wrong?
-- UPDATE --
Strangely, if the element is NOT added to the DOM, IE 9 also behaves in the right way. That is, in the above code, if I remove the line that adds the paragraph into the body:
document.body.appendChild(p);
then, the IE 9 console also displays first 3 then 1.
-- UPDATE 2 --
A simple script to check if your browser does normalize() properly or not:
var p = document.createElement('p');
// you can not put empty strings -- put blank strings instead
p.appendChild( document.createTextNode(' ') );
p.appendChild( document.createTextNode(' ') );
p.appendChild( document.createTextNode(' ') );
var normalizeOk;
document.body.appendChild(p);
p.normalize();
normalizeOk = p.childNodes.length == 1;
document.body.removeChild(p);
console.log("normalize OK: ", normalizeOk);
I experienced this issue on IE 11, but after I tried "Reset Internet Explorer Settings", normalize() was behaving right. In any case, I wrote a function that does a "normalize" if IE is broken.
(function () {
function checkIfNormalizeOk() {
var p = document.createElement('p');
// you can not put empty strings -- put blank strings instead
p.appendChild( document.createTextNode(' ') );
p.appendChild( document.createTextNode(' ') );
p.appendChild( document.createTextNode(' ') );
document.getElementsByTagName('head')[0].appendChild(p);
p.normalize();
var isNormalizeOk = (p.childNodes.length === 1);
document.getElementsByTagName('head')[0].removeChild(p);
return isNormalizeOk;
}
function getNextNode(node, ancestor, isOpenTag) {
if (typeof isOpenTag === 'undefined') {
isOpenTag = true;
}
var next;
if (isOpenTag) {
next = node.firstChild;
}
next = next || node.nextSibling;
if (!next && node.parentNode && node.parentNode !== ancestor) {
return getNextNode(node.parentNode, ancestor, false);
}
return next;
}
var isNormalizeOk = checkIfNormalizeOk();
window.normalizeEl = function (el) {
if (isNormalizeOk) {
el.normalize();
} else {
var adjTextNodes = [], nodes, node = el;
while ((node = getNextNode(node, el))) {
if (node.nodeType === 3 && node.previousSibling && node.previousSibling.nodeType === 3) {
if (!nodes) {
nodes = [node.previousSibling];
}
nodes.push(node);
} else if (nodes) {
adjTextNodes.push(nodes);
nodes = null;
}
}
adjTextNodes.forEach(function (nodes) {
var first;
nodes.forEach(function (node, i) {
if (i > 0) {
first.nodeValue += node.nodeValue;
node.parentNode.removeChild(node);
} else {
first = node;
}
});
});
}
};
}());
Related
General Info
Working on my own implementation of the operational transformation algorithm. For those that don't know what this is: When multiple users work on the same document at the same time, this algorithm attempts to preserve each users intention and make sure all users end up with the same document.
The problem
To begin, I need a proper way of detecting text operations. Like insert and delete. Obviously I need to know exactly at which position this is happening so each operation can be correctly transformed by the server to preserve the intention of other users.
My code so far is doing a pretty decent job at this. But it gets in trouble when selecting a text range and replacing it with another. I rely on the input event for this, and it seems to be unable to detect both delete and insert operations at the same time. When doing this, it detects a delete operation on the selected text. But it does not detect the insert operation of the text pasted in from the clipboard.
My question is: How can I solve this issue?
My code (so far)
let txtArea = {};
let cursorPos = {};
let clientDoc = ""; // Shadow DOC
document.addEventListener("DOMContentLoaded", function(event){
txtArea = document.getElementById("test");
clientDoc = txtArea.value;
txtArea.addEventListener("input", function(){ handleInput(); });
txtArea.addEventListener("click", function(){ handleSelect(); });
});
/* Gets cursor position / selected text range */
function handleSelect(){
cursorPos = getCursorPos(txtArea);
}
/* Check whether the operation is insert or delete */
function handleInput(){
if(txtArea.value > clientDoc){
handleOperation("insert");
} else {
handleOperation("delete");
}
}
/* Checks text difference to know exactly what happened */
function handleOperation(operation){
let lines = "";
if(operation === "insert"){
lines = getDifference(clientDoc, txtArea.value);
} else if(operation === "delete"){
lines = getDifference(txtArea.value, clientDoc);
}
const obj = {
operation: operation,
lines: lines,
position: cursorPos
};
clientDoc = txtArea.value;
console.log(obj);
}
/* Simple function to get difference between 2 strings */
function getDifference(a, b)
{
let i = 0;
let j = 0;
let result = "";
while (j < b.length)
{
if (a[i] != b[j] || i == a.length){
result += b[j];
} else {
i++;
}
j++;
}
return result;
}
/* Function to get cursor position / selection range */
function getCursorPos(input) {
if ("selectionStart" in input && document.activeElement == input) {
return {
start: input.selectionStart,
end: input.selectionEnd
};
}
else if (input.createTextRange) {
var sel = document.selection.createRange();
if (sel.parentElement() === input) {
var rng = input.createTextRange();
rng.moveToBookmark(sel.getBookmark());
for (var len = 0;
rng.compareEndPoints("EndToStart", rng) > 0;
rng.moveEnd("character", -1)) {
len++;
}
rng.setEndPoint("StartToStart", input.createTextRange());
for (var pos = { start: 0, end: len };
rng.compareEndPoints("EndToStart", rng) > 0;
rng.moveEnd("character", -1)) {
pos.start++;
pos.end++;
}
return pos;
}
}
return -1;
}
#test {
width: 600px;
height: 400px;
}
<textarea id="test">test</textarea>
Managed to solve the problem myself, though not entirely sure if it's the best solution. I've used comments within the code to explain how I solved it:
function handleOperation(operation){
let lines = "";
if(operation === "insert"){
lines = getDifference(clientDoc, txtArea.value);
} else if(operation === "delete"){
lines = getDifference(txtArea.value, clientDoc);
}
// This handles situations where text is being selected and replaced
if(operation === "delete"){
// Create temporary shadow doc with the delete operation finished
const tempDoc = clientDoc.substr(0, cursorPos.start) + clientDoc.substr(cursorPos.end);
// In case the tempDoc is different from the actual textarea value, we know for sure we missed an insert operation
if(tempDoc !== txtArea.value){
let foo = "";
if(tempDoc.length > txtArea.value.length){
foo = getDifference(txtArea.value, tempDoc);
} else {
foo = getDifference(tempDoc, txtArea.value);
}
console.log("char(s) replaced detected: "+foo);
}
} else if(operation === "insert"){
// No need for a temporary shadow doc. Insert will always add length to our shadow doc. So if anything is replaced,
// the actual textarea length will never match
if(clientDoc.length + lines.length !== txtArea.value.length){
let foo = "";
if(clientDoc.length > txtArea.value.length){
foo = getDifference(txtArea.value, clientDoc);
} else {
foo = getDifference(clientDoc, txtArea.value);
}
console.log("char(s) removed detected: "+foo);
}
}
const obj = {
operation: operation,
lines: lines,
position: cursorPos
};
// Update our shadow doc
clientDoc = txtArea.value;
// Debugging
console.log(obj);
}
I'm still very much open to better solutions / tips / advise if you can give it to me.
HTML
<body>
<div class="lol">
<a class="rightArrow" href="javascriptVoid:(0);" title"Next image">
</div>
</body>
Pseudo Code
$(".rightArrow").click(function() {
rightArrowParents = this.dom(); //.dom(); is the pseudo function ... it should show the whole
alert(rightArrowParents);
});
Alert message would be:
body div.lol a.rightArrow
How can I get this with javascript/jquery?
Here is a native JS version that returns a jQuery path. I'm also adding IDs for elements if they have them. This would give you the opportunity to do the shortest path if you see an id in the array.
var path = getDomPath(element);
console.log(path.join(' > '));
Outputs
body > section:eq(0) > div:eq(3) > section#content > section#firehose > div#firehoselist > article#firehose-46813651 > header > h2 > span#title-46813651
Here is the function.
function getDomPath(el) {
var stack = [];
while ( el.parentNode != null ) {
console.log(el.nodeName);
var sibCount = 0;
var sibIndex = 0;
for ( var i = 0; i < el.parentNode.childNodes.length; i++ ) {
var sib = el.parentNode.childNodes[i];
if ( sib.nodeName == el.nodeName ) {
if ( sib === el ) {
sibIndex = sibCount;
}
sibCount++;
}
}
if ( el.hasAttribute('id') && el.id != '' ) {
stack.unshift(el.nodeName.toLowerCase() + '#' + el.id);
} else if ( sibCount > 1 ) {
stack.unshift(el.nodeName.toLowerCase() + ':eq(' + sibIndex + ')');
} else {
stack.unshift(el.nodeName.toLowerCase());
}
el = el.parentNode;
}
return stack.slice(1); // removes the html element
}
Using jQuery, like this (followed by a solution that doesn't use jQuery except for the event; lots fewer function calls, if that's important):
$(".rightArrow").click(function () {
const rightArrowParents = [];
$(this)
.parents()
.addBack()
.not("html")
.each(function () {
let entry = this.tagName.toLowerCase();
const className = this.className.trim();
if (className) {
entry += "." + className.replace(/ +/g, ".");
}
rightArrowParents.push(entry);
});
console.log(rightArrowParents.join(" "));
return false;
});
Live example:
$(".rightArrow").click(function () {
const rightArrowParents = [];
$(this)
.parents()
.addBack()
.not("html")
.each(function () {
let entry = this.tagName.toLowerCase();
const className = this.className.trim();
if (className) {
entry += "." + className.replace(/ +/g, ".");
}
rightArrowParents.push(entry);
});
console.log(rightArrowParents.join(" "));
return false;
});
<div class=" lol multi ">
Click here
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
(In the live examples, I've updated the class attribute on the div to be lol multi to demonstrate handling multiple classes.)
That uses parents to get the ancestors of the element that was clicked, removes the html element from that via not (since you started at body), then loops through creating entries for each parent and pushing them on an array. Then we use addBack to add the a back into the set, which also changes the order of the set to what you wanted (parents is special, it gives you the parents in the reverse of the order you wanted, but then addBack puts it back in DOM order). Then it uses Array#join to create the space-delimited string.
When creating the entry, we trim className (since leading and trailing spaces are preserved, but meaningless, in the class attribute), and then if there's anything left we replace any series of one or more spaces with a . to support elements that have more than one class (<p class='foo bar'> has className = "foo bar", so that entry ends up being p.foo.bar).
Just for completeness, this is one of those places where jQuery may be overkill, you can readily do this just by walking up the DOM:
$(".rightArrow").click(function () {
const rightArrowParents = [];
for (let elm = this; elm; elm = elm.parentNode) {
let entry = elm.tagName.toLowerCase();
if (entry === "html") {
break;
}
const className = elm.className.trim();
if (className) {
entry += "." + className.replace(/ +/g, ".");
}
rightArrowParents.push(entry);
}
rightArrowParents.reverse();
console.log(rightArrowParents.join(" "));
return false;
});
Live example:
$(".rightArrow").click(function () {
const rightArrowParents = [];
for (let elm = this; elm; elm = elm.parentNode) {
let entry = elm.tagName.toLowerCase();
if (entry === "html") {
break;
}
const className = elm.className.trim();
if (className) {
entry += "." + className.replace(/ +/g, ".");
}
rightArrowParents.push(entry);
}
rightArrowParents.reverse();
console.log(rightArrowParents.join(" "));
return false;
});
<div class=" lol multi ">
Click here
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
There we just use the standard parentNode property (or we could use parentElement) of the element repeatedly to walk up the tree until either we run out of parents or we see the html element. Then we reverse our array (since it's backward to the output you wanted), and join it, and we're good to go.
I needed a native JS version, that returns CSS standard path (not jQuery), and deals with ShadowDOM. This code is a minor update on Michael Connor's answer, just in case someone else needs it:
function getDomPath(el) {
if (!el) {
return;
}
var stack = [];
var isShadow = false;
while (el.parentNode != null) {
// console.log(el.nodeName);
var sibCount = 0;
var sibIndex = 0;
// get sibling indexes
for ( var i = 0; i < el.parentNode.childNodes.length; i++ ) {
var sib = el.parentNode.childNodes[i];
if ( sib.nodeName == el.nodeName ) {
if ( sib === el ) {
sibIndex = sibCount;
}
sibCount++;
}
}
// if ( el.hasAttribute('id') && el.id != '' ) { no id shortcuts, ids are not unique in shadowDom
// stack.unshift(el.nodeName.toLowerCase() + '#' + el.id);
// } else
var nodeName = el.nodeName.toLowerCase();
if (isShadow) {
nodeName += "::shadow";
isShadow = false;
}
if ( sibCount > 1 ) {
stack.unshift(nodeName + ':nth-of-type(' + (sibIndex + 1) + ')');
} else {
stack.unshift(nodeName);
}
el = el.parentNode;
if (el.nodeType === 11) { // for shadow dom, we
isShadow = true;
el = el.host;
}
}
stack.splice(0,1); // removes the html element
return stack.join(' > ');
}
Here is a solution for exact matching of an element.
It is important to understand that the selector (it is not a real one) that the chrome tools show do not uniquely identify an element in the DOM. (for example it will not distinguish between a list of consecutive span elements. there is no positioning/indexing info)
An adaptation from a similar (about xpath) answer
$.fn.fullSelector = function () {
var path = this.parents().addBack();
var quickCss = path.get().map(function (item) {
var self = $(item),
id = item.id ? '#' + item.id : '',
clss = item.classList.length ? item.classList.toString().split(' ').map(function (c) {
return '.' + c;
}).join('') : '',
name = item.nodeName.toLowerCase(),
index = self.siblings(name).length ? ':nth-child(' + (self.index() + 1) + ')' : '';
if (name === 'html' || name === 'body') {
return name;
}
return name + index + id + clss;
}).join(' > ');
return quickCss;
};
And you can use it like this
console.log( $('some-selector').fullSelector() );
Demo at http://jsfiddle.net/gaby/zhnr198y/
The short vanilla ES6 version I ended up using:
Returns the output I'm used to read in Chrome inspector e.g body div.container input#name
function getDomPath(el) {
let nodeName = el.nodeName.toLowerCase();
if (el === document.body) return 'body';
if (el.id) nodeName += '#' + el.id;
else if (el.classList.length)
nodeName += '.' + [...el.classList].join('.');
return getDomPath(el.parentNode) + ' ' + nodeName;
};
I moved the snippet from T.J. Crowder to a tiny jQuery Plugin. I used the jQuery version of him even if he's right that this is totally unnecessary overhead, but i only use it for debugging purpose so i don't care.
Usage:
Html
<html>
<body>
<!-- Two spans, the first will be chosen -->
<div>
<span>Nested span</span>
</div>
<span>Simple span</span>
<!-- Pre element -->
<pre>Pre</pre>
</body>
</html>
Javascript
// result (array): ["body", "div.sampleClass"]
$('span').getDomPath(false)
// result (string): body > div.sampleClass
$('span').getDomPath()
// result (array): ["body", "div#test"]
$('pre').getDomPath(false)
// result (string): body > div#test
$('pre').getDomPath()
Repository
https://bitbucket.org/tehrengruber/jquery.dom.path
I've been using Michael Connor's answer and made a few improvements to it.
Using ES6 syntax
Using nth-of-type instead of nth-child, since nth-of-type looks for children of the same type, rather than any child
Removing the html node in a cleaner way
Ignoring the nodeName of elements with an id
Only showing the path until the closest id, if any. This should make the code a bit more resilient, but I left a comment on which line to remove if you don't want this behavior
Use CSS.escape to handle special characters in IDs and node names
~
export default function getDomPath(el) {
const stack = []
while (el.parentNode !== null) {
let sibCount = 0
let sibIndex = 0
for (let i = 0; i < el.parentNode.childNodes.length; i += 1) {
const sib = el.parentNode.childNodes[i]
if (sib.nodeName === el.nodeName) {
if (sib === el) {
sibIndex = sibCount
break
}
sibCount += 1
}
}
const nodeName = CSS.escape(el.nodeName.toLowerCase())
// Ignore `html` as a parent node
if (nodeName === 'html') break
if (el.hasAttribute('id') && el.id !== '') {
stack.unshift(`#${CSS.escape(el.id)}`)
// Remove this `break` if you want the entire path
break
} else if (sibIndex > 0) {
// :nth-of-type is 1-indexed
stack.unshift(`${nodeName}:nth-of-type(${sibIndex + 1})`)
} else {
stack.unshift(nodeName)
}
el = el.parentNode
}
return stack
}
All the examples from other ответов did not work very correctly for me, I made my own, maybe my version will be more suitable for the rest
const getDomPath = element => {
let templateElement = element
, stack = []
for (;;) {
if (!!templateElement) {
let attrs = ''
for (let i = 0; i < templateElement.attributes.length; i++) {
const name = templateElement.attributes[i].name
if (name === 'class' || name === 'id') {
attrs += `[${name}="${templateElement.getAttribute(name)}"]`
}
}
stack.push(templateElement.tagName.toLowerCase() + attrs)
templateElement = templateElement.parentElement
} else {
break
}
}
return stack.reverse().slice(1).join(' > ')
}
const currentElement = document.querySelectorAll('[class="serp-item__thumb justifier__thumb"]')[7]
const path = getDomPath(currentElement)
console.log(path)
console.log(document.querySelector(path))
console.log(currentElement)
var obj = $('#show-editor-button'),
path = '';
while (typeof obj.prop('tagName') != "undefined"){
if (obj.attr('class')){
path = '.'+obj.attr('class').replace(/\s/g , ".") + path;
}
if (obj.attr('id')){
path = '#'+obj.attr('id') + path;
}
path = ' ' +obj.prop('tagName').toLowerCase() + path;
obj = obj.parent();
}
console.log(path);
hello this function solve the bug related to current element not show in the path
check this now
$j(".wrapper").click(function(event) {
selectedElement=$j(event.target);
var rightArrowParents = [];
$j(event.target).parents().not('html,body').each(function() {
var entry = this.tagName.toLowerCase();
if (this.className) {
entry += "." + this.className.replace(/ /g, '.');
}else if(this.id){
entry += "#" + this.id;
}
entry=replaceAll(entry,'..','.');
rightArrowParents.push(entry);
});
rightArrowParents.reverse();
//if(event.target.nodeName.toLowerCase()=="a" || event.target.nodeName.toLowerCase()=="h1"){
var entry = event.target.nodeName.toLowerCase();
if (event.target.className) {
entry += "." + event.target.className.replace(/ /g, '.');
}else if(event.target.id){
entry += "#" + event.target.id;
}
rightArrowParents.push(entry);
// }
where $j = jQuery Variable
also solve the issue with .. in class name
here is replace function :
function escapeRegExp(str) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
function replaceAll(str, find, replace) {
return str.replace(new RegExp(escapeRegExp(find), 'g'), replace);
}
Thanks
$(".rightArrow")
.parents()
.map(function () {
var value = this.tagName.toLowerCase();
if (this.className) {
value += '.' + this.className.replace(' ', '.', 'g');
}
return value;
})
.get().reverse().join(", ");
How do I search the DOM for a certain string in the document's text (say, "cheese") then insert some HTML immediately after that string (say, "< b >is fantastic< /b >").
I have tried the following:
for (var tag in document.innerHTML) {
if (tag.matches(/cheese/) != undefined) {
document.innerHTML.append(<b>is fantastic</b>
}
}
(The above is more of an illustration of what I have tried, not the actual code. I expect the syntax is horribly wrong so please excuse any errors, they are not the problem).
Cheers,
Pete
There are native methods for finding text inside a document:
MSIE:textRange.findText()
Others: window.find()
Manipulate the given textRange if something was found.
Those methods should provide much more performance than the traversing of the whole document.
Example:
<html>
<head>
<script>
function fx(a,b)
{
if(window.find)
{
while(window.find(a))
{
var node=document.createElement('b');
node.appendChild(document.createTextNode(b));
var rng=window.getSelection().getRangeAt(0);
rng.collapse(false);
rng.insertNode(node);
}
}
else if(document.body.createTextRange)
{
var rng=document.body.createTextRange();
while(rng.findText(a))
{
rng.collapse(false);
rng.pasteHTML('<b>'+b+'</b>');
}
}
}
</script>
</head>
<body onload="fx('cheese','is wonderful')">
<p>I've made a wonderful cheesecake with some <i>cheese</i> from my <u>chees</u>e-factory!</p>
</body>
</html>
This is crude and not the way to do it, but;
document.body.innerHTML = document.body.innerHTML.replace(/cheese/, 'cheese <b>is fantastic</b>');
You can use this with JQuery:
$('*:contains("cheese")').each(function (idx, elem) {
var changed = $(elem).html().replace('cheese', 'cheese <b>is fantastic</b>');
$(elem).html(changed);
});
I haven't tested this, but something along these lines should work.
Note that * will match all elements, even html, so you may want to use body *:contains(...) instead to make sure only elements that are descendants of the document body are looked at.
Sample Solution:
<ul>
<li>cheese</li>
<li>cheese</li>
<li>cheese</li>
</ul>
Jquery codes:
$('ul li').each(function(index) {
if($(this).text()=="cheese")
{
$(this).text('cheese is fantastic');
}
});
The way to do this is to traverse the document and search each text node for the desired text. Any way involving innerHTML is hopelessly flawed.
Here's a function that works in all browsers and recursively traverses the DOM within the specified node and replaces occurrences of a piece of text with nodes copied from the supplied template node replacementNodeTemplate:
function replaceText(node, text, replacementNodeTemplate) {
if (node.nodeType == 3) {
while (node) {
var textIndex = node.data.indexOf(text), currentNode = node;
if (textIndex == -1) {
node = null;
} else {
// Split the text node after the text
var splitIndex = textIndex + text.length;
var replacementNode = replacementNodeTemplate.cloneNode(true);
if (splitIndex < node.length) {
node = node.splitText(textIndex + text.length);
node.parentNode.insertBefore(replacementNode, node);
} else {
node.parentNode.appendChild(replacementNode);
node = null;
}
currentNode.deleteData(textIndex, text.length);
}
}
} else {
var child = node.firstChild, nextChild;
while (child) {
nextChild = child.nextSibling;
replaceText(child, text, replacementNodeTemplate);
child = nextChild;
}
}
}
Here's an example use:
replaceText(document.body, "cheese", document.createTextNode("CHEESE IS GREAT"));
If you prefer, you can create a wrapper function to allow you to specify the replacement content as a string of HTML instead:
function replaceTextWithHtml(node, text, html) {
var div = document.createElement("div");
div.innerHTML = html;
var templateNode = document.createDocumentFragment();
while (div.firstChild) {
templateNode.appendChild(div.firstChild);
}
replaceText(node, text, templateNode);
}
Example:
replaceTextWithHtml(document.body, "cheese", "cheese <b>is fantastic</b>");
I've incorporated this into a jsfiddle example: http://jsfiddle.net/timdown/azZsa/
Works in all browsers except IE I think, need confirmation though.
This supports content in iframes as well.
Note, other examples I have seen, like the one above, are RECURSIVE which is potentially bad in javascript which can end in stack overflows, especially in a browser client which has limited memory for such things. Too much recursion can cause javascript to stop executing.
If you don't believe me, try the examples here yourself...
If anyone would like to contribute, the code is here.
function grepNodes(searchText, frameId) {
var matchedNodes = [];
var regXSearch;
if (typeof searchText === "string") {
regXSearch = new RegExp(searchText, "g");
}
else {
regXSearch = searchText;
}
var currentNode = null, matches = null;
if (frameId && !window.frames[frameId]) {
return null;
}
var theDoc = (frameId) ? window.frames[frameId].contentDocument : document;
var allNodes = (theDoc.all) ? theDoc.all : theDoc.getElementsByTagName('*');
for (var nodeIdx in allNodes) {
currentNode = allNodes[nodeIdx];
if (!currentNode.nodeName || currentNode.nodeName === undefined) {
break;
}
if (!(currentNode.nodeName.toLowerCase().match(/html|script|head|meta|link|object/))) {
matches = currentNode.innerText.match(regXSearch);
var totalMatches = 0;
if (matches) {
var totalChildElements = 0;
for (var i=0;i<currentNode.children.length;i++) {
if (!(currentNode.children[i].nodeName.toLowerCase().match(/html|script|head|meta|link|object/))) {
totalChildElements++;
}
}
matchedNodes.push({node: currentNode, numMatches: matches.length, childElementsWithMatch: 0, nodesYetTraversed: totalChildElements});
}
for (var i = matchedNodes.length - 1; i >= 0; i--) {
previousElement = matchedNodes[i - 1];
if (!previousElement) {
continue;
}
if (previousElement.nodesYetTraversed !== 0 && previousElement.numMatches !== previousElement.childElementsWithMatch) {
previousElement.childElementsWithMatch++;
previousElement.nodesYetTraversed--;
}
else if (previousElement.nodesYetTraversed !== 0) {
previousElement.nodesYetTraversed--;
}
}
}
}
var processedMatches = [];
for (var i =0; i < matchedNodes.length; i++) {
if (matchedNodes[i].numMatches > matchedNodes[i].childElementsWithMatch) {
processedMatches.push(matchedNodes[i].node);
}
}
return processedMatches;
};
Given the following HTML-Fragment:
<div>
<p>
abc <span id="x">[</span> def <br /> ghi
</p>
<p>
<strong> jkl <span id="y">]</span> mno </strong>
</p>
</div>
I need an algorithm to fetch all nodes of type Text between #x and #y with Javascript. Or is there a JQuery function that does exactly that?
The resulting Text nodes (whitespace nodes ignored) for the example above would then be:
['def', 'ghi', 'jkl']
The following works in all major browsers using DOM methods and no library. It also ignores whitespace text nodes as mentioned in the question.
Obligatory jsfiddle: http://jsfiddle.net/timdown/a2Fm6/
function getTextNodesBetween(rootNode, startNode, endNode) {
var pastStartNode = false, reachedEndNode = false, textNodes = [];
function getTextNodes(node) {
if (node == startNode) {
pastStartNode = true;
} else if (node == endNode) {
reachedEndNode = true;
} else if (node.nodeType == 3) {
if (pastStartNode && !reachedEndNode && !/^\s*$/.test(node.nodeValue)) {
textNodes.push(node);
}
} else {
for (var i = 0, len = node.childNodes.length; !reachedEndNode && i < len; ++i) {
getTextNodes(node.childNodes[i]);
}
}
}
getTextNodes(rootNode);
return textNodes;
}
var x = document.getElementById("x"),
y = document.getElementById("y");
var textNodes = getTextNodesBetween(document.body, x, y);
console.log(textNodes);
The following example uses jQuery to find any two elements that are next to each other and may or may not have text nodes between them. This foreach loop will check the resulted elements to find any text nodes and add them to the list.
function getTextNodes() {
var list = [];
$(document.body).find("*+*").toArray().forEach(function (el) {
var prev = el.previousSibling;
while (prev != null && prev.nodeType == 3) {
list.push(prev);
prev = prev.previousSibling;
}
});
return list;
}
When selecting a block of text (possibly spanning across many DOM nodes), is it possible to extract the selected text and nodes using Javascript?
Imagine this HTML code:
<h1>Hello World</h1><p>Hi <b>there!</b></p>
If the user initiated a mouseDown event starting at "World..." and then a mouseUp even right after "there!", I'm hoping it would return:
Text : { selectedText: "WorldHi there!" },
Nodes: [
{ node: "h1", offset: 6, length: 5 },
{ node: "p", offset: 0, length: 16 },
{ node: "p > b", offset: 0, length: 6 }
]
I've tried putting the HTML into a textarea but that will only get me the selectedText. I haven't tried the <canvas> element but that may be another option.
If not JavaScript, is there a way this is possible using a Firefox extension?
You are in for a bumpy ride, but this is quite possible. The main problem is that IE and W3C expose completely different interfaces to selections so if you want cross browser functionality then you basically have to write the whole thing twice. Also, some basic functionality is missing from both interfaces.
Mozilla developer connection has the story on W3C selections. Microsoft have their system documented on MSDN. I recommend starting at PPK's introduction to ranges.
Here are some basic functions that I believe work:
// selection objects will differ between browsers
function getSelection () {
return ( msie )
? document.selection
: ( window.getSelection || document.getSelection )();
}
// range objects will differ between browsers
function getRange () {
return ( msie )
? getSelection().createRange()
: getSelection().getRangeAt( 0 )
}
// abstract getting a parent container from a range
function parentContainer ( range ) {
return ( msie )
? range.parentElement()
: range.commonAncestorContainer;
}
My Rangy library will get your part of the way there by unifying the different APIs in IE < 9 and all other major browsers, and by providing a getNodes() function on its Range objects:
function getSelectedNodes() {
var selectedNodes = [];
var sel = rangy.getSelection();
for (var i = 0; i < sel.rangeCount; ++i) {
selectedNodes = selectedNodes.concat( sel.getRangeAt(i).getNodes() );
}
return selectedNodes;
}
Getting the selected text is pretty easy in all browsers. In Rangy it's just
var selectedText = rangy.getSelection().toString();
Without Rangy:
function getSelectedText() {
var sel, text = "";
if (window.getSelection) {
text = "" + window.getSelection();
} else if ( (sel = document.selection) && sel.type == "Text") {
text = sel.createRange().text;
}
return text;
}
As for the character offsets, you can do something like this for any node node in the selection. Note this does not necessarily represent the visible text in the document because it takes no account of collapsed spaces, text hidden via CSS, text positioned outside the normal document flow via CSS, line breaks implied by <br> and block elements, plus other subtleties.
var sel = rangy.getSelection();
var selRange = sel.getRangeAt(0);
var rangePrecedingNode = rangy.createRange();
rangePrecedingNode.setStart(selRange.startContainer, selRange.startOffset);
rangePrecedingNode.setEndBefore(node);
var startIndex = rangePrecedingNode.toString().length;
rangePrecedingNode.setEndAfter(node);
var endIndex = rangePrecedingNode.toString().length;
alert(startIndex + ", " + endIndex);
This returns the selected nodes as I understand it:
When I have
<p> ... </p><p> ... </p><p> ... </p><p> ... </p><p> ... </p>...
<p> ... </p><p> ... </p><p> ... </p><p> ... </p><p> ... </p>
a lot of nodes and I select only a few then I want only these nodes to be in the list.
function getSelectedNodes() {
// from https://developer.mozilla.org/en-US/docs/Web/API/Selection
var selection = window.getSelection();
if (selection.isCollapsed) {
return [];
};
var node1 = selection.anchorNode;
var node2 = selection.focusNode;
var selectionAncestor = get_common_ancestor(node1, node2);
if (selectionAncestor == null) {
return [];
}
return getNodesBetween(selectionAncestor, node1, node2);
}
function get_common_ancestor(a, b)
{
// from http://stackoverflow.com/questions/3960843/how-to-find-the-nearest-common-ancestors-of-two-or-more-nodes
$parentsa = $(a).parents();
$parentsb = $(b).parents();
var found = null;
$parentsa.each(function() {
var thisa = this;
$parentsb.each(function() {
if (thisa == this)
{
found = this;
return false;
}
});
if (found) return false;
});
return found;
}
function isDescendant(parent, child) {
// from http://stackoverflow.com/questions/2234979/how-to-check-in-javascript-if-one-element-is-a-child-of-another
var node = child;
while (node != null) {
if (node == parent) {
return true;
}
node = node.parentNode;
}
return false;
}
function getNodesBetween(rootNode, node1, node2) {
var resultNodes = [];
var isBetweenNodes = false;
for (var i = 0; i < rootNode.childNodes.length; i+= 1) {
if (isDescendant(rootNode.childNodes[i], node1) || isDescendant(rootNode.childNodes[i], node2)) {
if (resultNodes.length == 0) {
isBetweenNodes = true;
} else {
isBetweenNodes = false;
}
resultNodes.push(rootNode.childNodes[i]);
} else if (resultNodes.length == 0) {
} else if (isBetweenNodes) {
resultNodes.push(rootNode.childNodes[i]);
} else {
return resultNodes;
}
};
if (resultNodes.length == 0) {
return [rootNode];
} else if (isDescendant(resultNodes[resultNodes.length - 1], node1) || isDescendant(resultNodes[resultNodes.length - 1], node2)) {
return resultNodes;
} else {
// same child node for both should never happen
return [resultNodes[0]];
}
}
The code should be available at: https://github.com/niccokunzmann/spiele-mit-kindern/blob/gh-pages/javascripts/feedback.js
I posted this answer here because I would have liked to find it here.
There is a much shorter way if you just want the range.
function getRange(){
return (navigator.appName=="Microsoft Internet Explorer")
? document.selection.createRange().parentElement()
: (getSelection||document.getSelection)().getRangeAt(0).commonAncestorContainer
}
All standards compliant code that works in IE11+.
Text String
window.getSelection().getRangeAt(0).toString()
The start node (even if the text is selected backwards):
window.getSelection().anchorNode
The end node (even if the text is selected backwards):
window.getSelection().focusNode
Would you like to know more? Select some text and run the following JavaScript in the console:
console.log(window.getSelection());
console.log(window.getSelection().getRangeAt(0));