I have a function, getTextNodes, that searches text nodes recursively. Then I use a addHighlight function to highlight the text with <mark> tags:
const buttonEl = `<button>
<span>
Icon
</span>
Text
</button>
`;
document.body.innerHTML = buttonEl;
const foundButtonEl = document.querySelector("button");
const elements = [];
elements.push(foundButtonEl);
addHighlight(elements, "T");
function addHighlight(elements, text) {
elements.forEach((element, index) => {
const textNodes = getTextNodes(document.body);
const matchingNode = textNodes.find(node => node.textContent.includes(text));
const markElement = document.createElement('mark');
markElement.innerHTML = matchingNode.textContent;
matchingNode.replaceWith(markElement);
});
}
function getTextNodes(node) {
let textNodes = [];
if (node.nodeType === Node.TEXT_NODE) {
textNodes.push(node);
}
node.childNodes.forEach(childNode => {
textNodes.push(...getTextNodes(childNode));
});
return textNodes;
}
The problem is that addHighlight is highlighing the whole text (in the example, Text), instead of the matched text (in the example, T).
How to change this code so that only the matched text is highlighted (text)?
matchingNode is the whole node so you're replacing everything. If you want to match just part of it, you need to iterate though the textnode and find the index position of the substring that you're searching for.
Start by splitting the node into an array
matchingNode.wholeText.split("")
Then find the index position, insert markElement at that position, and go from there.
The problem is that the node you match is the element of which the innerContent contains the string you want to highlight.
What you should do instead of :
markElement.innerHTML = matchingNode.textContent;
matchingNode.replaceWith(markElement);
is probably something like
markElement.innerHTML = text;
matchingNode.replaceTextWithHTML(text, markElement);
replaceTextWithHTML is a fictive function :)
Related
I am trying to do something that I thought would be simple but been stuck on this for a while I want to find all instances of a word in a paragraph and insert a link next to it.
I dont want to use innerHTML and destroy the events. I also dont want to use jQuery ideally pure js.
I am looking to take this paragraph.
<p>red this is a sentence I want to change red and I want to change it for all instances the word red</p>
Find all index positions of the word red and change it too.
<p>red Some link this is a sentence I want to change red Some link and I want to change it for all instances the word red Some link</p>
So find every instance of the word red grab the index and then insert html not sure it can even be done the way I am doing it, it always only inserts it one time.
I have this so far.
var ps = document.querySelectorAll("p");
[].forEach.call(ps, function(p) {
const indexes = [...p.innerText.matchAll(new RegExp("red", "gi"))].map(
(a) => a.index
);
var link = document.createElement("a");
link.href = "";
link.innerHTML = `Changed`;
indexes.forEach((pos) => {
insertAtStringPos(p, pos, link);
})
});
function insertAtStringPos(el, pos, insertable) {
if (!el.children.length) {
var text = el.outerText;
var beginning = document.createTextNode(text.substr(0, pos));
var end = document.createTextNode(text.substr(pos - text.length));
while (el.hasChildNodes()) {
el.removeChild(el.firstChild);
}
el.appendChild(beginning);
el.appendChild(insertable);
el.appendChild(end);
}
}
I grabbed the insertAtStringPos function from another stackoverflow post.
I have an example here: https://jsbin.com/watopeteki/edit?html,js,console,output
Why do it always only insert once?
It can be an easier, you need just a split text and by a keyword insert a link.
function links() {
const ps = document.querySelectorAll('p');
return Array.from(ps).reduce((acc, p) => {
const links = p.querySelectorAll('a');
const isUpdate = Boolean(links?.length);
const text = p.innerHTML;
let index = 0;
const splitted = text.split(/(red)/gi);
splitted.forEach((txt) => {
const el = document.createTextNode(txt);
acc.appendChild(el);
if (txt === 'red') {
let link;
if (isUpdate) {
link = links[index++];
link.href = '';
link.innerHTML = `Changed after update`;
} else {
link = document.createElement('a');
link.href = '';
link.innerHTML = `Changed`;
acc.appendChild(link);
}
}
});
return acc;
}, document.createElement('p'));
}
const element = links(); // creates links
document.body.appendChild(element);
links(); // updates current links
If I understood correct, you need a function which updates your existing links. I have update stackblitz and example, check this out.
Stackblitz
I see a few problems with your code.
Your insertAtStringPos() function mutates the paragraph element contents, invalidating the remaining indexes in the indexes array. Reversing the indexes array before looping, and inserting from the end toward the beginning of the text, overcomes this problem.
You're passing the link element to the insertAtStringPos(). This same element gets inserted then moved with each subsequent insertion. Passing a cloned link element with each indexes iteration solves this problem.
outerText, in var text = el.outerText;, returns undefined in my version of Firefox (78.15, October 5, 2021).
To search for a word in one or more paragraphs, and insert a link node after that string, loop over each paragraph, search for occurrences of the string, and build an index array. Then loop the index array but first reverse it to start insertion at the end of the string. Also copy the link node before passing to the insertion function, otherwise the link node will simply be moved from one position to the next.
const ps = document.querySelectorAll("p");
const word = "red";
const link = document.createElement("a");
link.href = "";
link.innerHTML = "Changed";
[].forEach.call(ps, function(p) {
const indexes = [...p.innerText.matchAll(new RegExp(word, "gi"))].map(
// add word length to position, making sure position is not beyond end of text
(a) => (p.innerText.length > a.index + word.length)
? a.index + word.length // add word length to position
: p.innerText.length // word is at end of text
);
// execute insertion function for each position
// first reverse index array to start at the end of the text and work towards the beginning
indexes.reverse().forEach((pos) => {
// clone node before passing to insertion, otherwise same node simply gets moved
insertAtStringPos(p, pos, link.cloneNode(true)); // <-- clone node
})
});
function insertAtStringPos(el, pos, insertable) {
const text = el.childNodes[0].textContent;
const beginning = document.createTextNode(text.substr(0, pos) + " "); // text before and including word, plus a space
const end = document.createTextNode(
// if position is at end of text, create empty text node
(text.length > pos)
? " " + text.substr(pos - text.length) // a space, and text after word
: "" // empty text node
);
el.removeChild(el.childNodes[0]);
el.insertBefore(end, el.childNodes[0]);
el.insertBefore(insertable, el.childNodes[0]);
el.insertBefore(beginning, el.childNodes[0]);
}
<p>red this is a sentence I want to change red and I want to change it for all instances the word red</p>
what I'm trying to achieve is when I split the inner contents of an element, I get each item seperate, but the html element needs to be 1 element in the split.
For example:
<p id="name1" class=""> We deliver
<span class="color-secondary">software</span> &
<span class="color-secondary">websites</span> for your organization<span class="color-secondary">.</span>
</p>
Like in the example above, I want to make anything inside the <span> 1 array item after splitting the inner contents of #name1.
So in other words, I want the split array to look like this:
[
'we',
'deliver',
'<span class="color-secondary">software</span>',
'&',
'<span class="color-secondary">websites</span>'
... etc.
]
Currently this is what I have. But this does not work since it ignores the text inside of the html element and therefore splits it halfway through the element. I would also like it to be any html element, and not just limited to <span>.
let sentence = el.innerHTML; // el being #name1 in this case
let words = sentence.split(/\s(?=<span)/i);
How would I be able to achieve this with regex? Is this possible? Thank you for any help.
Here is a DOMParser based solution which parses the HTML and then iterates over the top node's children, pushing the HTML into the result array if the node is an element, or splitting the text on space (if it is a text element) and adding those values to the result array:
const html = `<p id="name1" class=""> We deliver
<span class="color-secondary">software</span> &
<span class="color-secondary">websites</span> for your organization<span class="color-secondary">.</span>
</p>`
const parser = new DOMParser();
const s = parser.parseFromString(html, 'text/html');
let result = [];
for (el of s.body.firstChild.childNodes) {
if (el.nodeType == 3 /* TEXT_NODE */ ) {
result = result.concat(el.nodeValue.trim().split(' ').filter(Boolean));
}
else if (el.nodeType == 1 /* ELEMENT_NODE */ ) {
result.push(el.outerHTML);
}
}
console.log(result);
Details are commented in example below
const nodeSplitter = (mainNode) => {
let scan;
/*
Check if initial node has text or elements
*/
if (mainNode.hasChildNodes) {
scan =
/*
Collect all elements, text, and comments
into an array
*/
Array.from(mainNode.childNodes)
/*
If node is an element, return it...
...if node is text, use `.matchAll()` to
find each word and add to array...
.filter() any falsy values and flatten
the array and then return it
*/
.flatMap(node => {
if (node.nodeType === 1) {
return node;
} else if (node.nodeType === 3) {
const rgx = new RegExp(/[\w\\\-\.\]\&]+/, 'g');
let strings = [...node.textContent.matchAll(rgx)]
.filter(node => node).flat()
return strings;
} else {
/*
Otherwise, return empty array which is
basically nothing since .flatMap()
flattens an array as default
*/
return [];
}
});
} else {
// Return if mainNode is empty
return;
}
// return results
return scan;
}
const main = document.getElementById('name1');
console.log(nodeSplitter(main));
<p id="name1" class=""> We deliver
<span class="color-secondary">software</span> &
<span class="color-secondary">websites</span> for your organization
<span class="color-secondary">.</span>
</p>
I have this XML string which I am displaying as a text in a document:
<p>The new strain of <s alias="coronavirus">COVID</s>seems to be having a greater spread rate.</p>
The following function returns the text form of the XML:
function stripHtml(html) {
// Create a new div element
var temporalDivElement = document.createElement("div");
// Set the HTML content with the providen
temporalDivElement.innerHTML = html;
// Retrieve the text property of the element (cross-browser support)
return temporalDivElement.textContent || temporalDivElement.innerText || "";
}
The problem is, this function returns the following string:
The new strain of COVIDseems to be having a greater spread rate.
which is nearly what I want, but there is no space between the word COVID and seems. Is it possible that I can add a space between contents of two tags if it doesn't exist?
One option is to iterate over text nodes and insert spaces at the beginning if they don't exist, something like:
const getTextNodes = (parent) => {
var walker = document.createTreeWalker(
parent,
NodeFilter.SHOW_TEXT,
null,
false
);
var node;
var textNodes = [];
while(node = walker.nextNode()) {
textNodes.push(node);
}
return textNodes;
}
function stripHtml(html) {
// Create a new div element
var temporalDivElement = document.createElement("div");
// Set the HTML content with the providen
temporalDivElement.innerHTML = html;
// Retrieve the text property of the element (cross-browser support)
for (const node of getTextNodes(temporalDivElement)) {
node.nodeValue = node.nodeValue.replace(/^(?!\s)/, ' ');
}
return temporalDivElement.textContent.replace(/ +/g, ' ').trim();
}
console.log(stripHtml(`<p>The new strain of <s alias="coronavirus">COVID</s>seems to be having a greater spread rate.</p>`));
I have added span tag for all comma using jquery to adding class for css. unfortunately It adds span tag inside script and get collapsed.I want to check the content is not in script tag and add span tag.I want to replace all comma (,) except the content in script tag.
if ($("#overview").length > 0) {
$("#overview").html( $("#overview").html().replace(/,/g,"<span class='comma'>,</span>"));
}
You need to be careful because the html attributes can also contain text with ','.
One way to solve this problem is iterate by the TextNodes, and for each one split the value in multiples TextNodes separated by a SpanNode.
// get all text nodes excluding the script
function getTextNodes(el) {
if (el.nodeType == 3) { // nodeType 3 is a TextNode
return el.parentElement && el.parentElement.nodeName == "SCRIPT" ? [] : [el];
}
return Array.from(el.childNodes)
.reduce((acc, item) => acc.concat(getTextNodes(item)), []);
}
// this will replace the TextNode with the necessary Span and Texts nodes
function replaceComma(textNode) {
const parent = textNode.parentElement;
const subTexts = textNode.textContent.split(','); // get all the subtexts separated by ,
// for each item in subtext it will insert a new TextNode with a SpanNode
// (iterate from end to beginning to use the insertBefore function)
textNode.textContent = subTexts[subTexts.length - 1];
let currentNode = textNode;
for(var i = subTexts.length - 2; i>= 0 ; i --) {
const spanEl = createCommaEl();
parent.insertBefore(spanEl, currentNode);
currentNode = document.createTextNode(subTexts[i]);
parent.insertBefore(currentNode, spanEl)
}
}
// create the html node: <span class="comma">,</span>
// you can do this more easy with JQUERY
function createCommaEl() {
const spanEl = document.createElement('span');
spanEl.setAttribute('class', 'comma');
spanEl.textContent = ',';
return spanEl;
}
// then, if you want to replace all comma text from the element with id 'myId'
// you can do
getTextNodes(document.getElementById('myId'))
.forEach(replaceComma);
I have the following code that puts bold style some keywords in a whole google document:
function boldKeywords() {
// Words that will be put in bold:
var keywords = ["end", "proc", "fun"];
var document = DocumentApp.getActiveDocument();
var body = document.getBody();
var Style = {};
Style[DocumentApp.Attribute.BOLD] = true;
for (j in keywords) {
var found = body.findText(keywords[j]);
while(found != null) {
var foundText = found.getElement().asText();
var start = found.getStartOffset();
var end = found.getEndOffsetInclusive();
foundText.setAttributes(start, end, Style)
found = body.findText(keywords[j], found);
}
}
}
But I would like the code to put the keywords in bold only in the selected area of the document, for doing that, I tried using the function getSelection(), but I have the problem that this function returns a Range, but for applying findText I need a Body, somebody knows what could I do?
Modified Script
function boldKeywordsInSelection() {
const keywords = ["end", "proc", "fun"];
const document = DocumentApp.getActiveDocument();
const selection = document.getSelection();
// get a list of all the different range elements
const rangeElements = selection.getRangeElements();
const Style = {};
Style[DocumentApp.Attribute.BOLD] = true;
// forEach used here because for in was giving me trouble...
rangeElements.forEach(rangeElement => {
// Each range element has a corresponding element (e.g. paragraph)
const parentElement = rangeElement.getElement();
// fixing the limits of the bold operations depending on the selection
const startLimit = rangeElement.getStartOffset();
const endLimit = rangeElement.getEndOffsetInclusive();
for (j in keywords) {
let found = parentElement.findText(keywords[j]);
// wrapping in try catch to escape the for loop from within the while loop
try {
while (found != null) {
const foundText = found.getElement().asText();
const start = found.getStartOffset();
// Checking if the start of the word is after the start of the selection
if (start < startLimit) {
// If so, then skip to next word
found = parentElement.findText(keywords[j], found);
continue;
}
// Checking if the start of the word is after the end of the selection
// if so, go to next element
if (start > endLimit) throw "out of selection";
const end = found.getEndOffsetInclusive();
foundText.setAttributes(start, end, Style)
found = parentElement.findText(keywords[j], found);
}
} catch (e) {
Logger.log(e)
continue;
}
}
})
}
See the comments in the code for more details.
A getSelection produces a Range object, which contains within it various instances of RangeElement. Each RangeElement makes reference to a parent element, and the index positions within that parent. The parent is the element that the range element is a part of. For example:
This selection spans 3 elements. Therefore the selection has 3 range elements. You can only use the findText method on the whole element, not the range element.
This means that the flow of the script is generally the same, except that you need to go through each element and find the text within each element. Since this will return elements that are outside the selection, you need to keep track of the index positions of the selection and the found element and make sure the found element is within the selection.
References
Range
RangeElement
getSelection()