I need to wrap specific terms in a string of text with <span> tags only if the term isn't already wrapped in a <span> tag.
For example I have a string of text:
Test string of text containing foo bar and baz.
And an object with key value pairs to search for in the string:
toolTips = {
foo: 'tooltip for foo',
bar: 'problematic tooltip that also contains baz',
baz: 'tooltip for baz'
}
I need to iterate over the object keys and wrap matching terms with <span> tags to add the tool tip text.
So after the first iteration of the loop the string would be:
Test string of text containing
<span class="tooltip">foo
<span class="tooltip-text">tooltip for foo</span>
</span>
bar and baz.
After the second it would be:
Test string of text containing
<span class="tooltip">foo
<span class="tooltip-text">tooltip for foo</span>
</span>
<span class="tooltip">bar
<span class="tooltip-text">problematic tooltip that also contains baz</span>
</span>
and baz.
And after the third it would be:
Test string of text containing
<span class="tooltip">foo
<span class="tooltip-text">tooltip for foo</span>
</span>
<span class="tooltip">bar
<span class="tooltip-text">problematic tooltip that also contains baz</span>
</span>
and
<span class="tooltip">baz
<span class="tooltip-text">tooltip for baz</span>
</span>
.
I've tried doing this with string.replace() and with various regex patterns but I haven't been able to get it to fully work. Either the text inside of a previously added <span> gets matched and replaced or I do a negative look ahead for a closing </span tag in the regex and then text that comes before any span doesn't get matched.
Would appreciate ideas on how to handle this.
It is not the most efficient idea, but you can try using placeholders, in order to not have phrases which overlap themselves.
let string = "Test string of text containing foo bar and baz.";
const toolTips = {
foo: 0,
bar: 1,
baz: 2
}
const toolTipsPlaceholders = {
0: {key: 'foo', value: 'tooltip for foo'},
1: {key: 'bar', value: 'problematic tooltip that also contains baz'},
2: {key: 'baz', value: 'tooltip for baz'}
}
const keys = Object.keys(toolTips)
keys.forEach(k => string = string.replaceAll(k, toolTips[k]))
const keysPlaceholders = Object.keys(toolTipsPlaceholders)
keysPlaceholders.forEach(k => string = string.replaceAll(k, `<span class="tooltip">${toolTipsPlaceholders[k].key}<span class="tooltip-text">${toolTipsPlaceholders[k].value}</span></span>`))
document.getElementById("test").innerHTML = string;
.tooltip {
color: red;
}
.tooltip-text {
color: blue;
}
<div id="test"></div>
Below is an approach that should let you take care of it.
You can use a really complicated regex to check if the key is already in elements, but that can be a pain to write, understand, and maintain.
Instead, the trick is to loop over each node in an element. If it is a text node, you know there is no HTML in there, so any replacements are safe. If it is an element node, then recurse through, looking for text nodes, and skip over tooltip elements.
This, in my opinion, makes it easier to understand and maintain going forward.
In the replacement, I just replaced the key with the tooltip value, which isn't exactly what you want, but can easily be tweaked to your liking.
document.querySelector('button').addEventListener('click', () => {
const root = document.querySelector('div');
applyTooltips(root);
});
const tooltips = {
foo: 'hello bar and baz',
bar: 'goodbye',
baz: 'cake and foo'
};
// Regex that can match all of the keys at the same time
// so we don't risk getting weirdness if one tooltip
// contains another key
const matchRegex = new RegExp(`(${Object.keys(tooltips).join('|')})`, 'g');
const tooltipSelector = '.tooltip';
const applyTooltips = element => {
// Loop over each childNode, which might be a text or element node.
[...element.childNodes].forEach(child => {
// If it is a text node, we'll apply the tooltip logic
if (child.nodeType === Node.TEXT_NODE) {
// Get the text
const text = child.wholeText;
// Replace the text with the HTML
newText = text.replaceAll(matchRegex, key => `<div class="tooltip">${tooltips[key]}</div>`);
// Create a temp element we can assign the
// HTML text to to get actual elements
const temp = document.createElement('div');
temp.innerHTML = newText;
// Apply each new node before the text child
[...temp.childNodes].forEach(node =>
element.insertBefore(node, child)
);
// Remove the old text child
element.removeChild(child);
} else if (!child.matches(tooltipSelector)) {
// If it is an element that isn't a tooltip element, we'll recurse on it.
applyTooltips(child);
}
});
};
.tooltip { color: #F00; }
<div>
Test string of text containing foo bar and baz.
This <div class="tooltip">foo</div> is already wrapped and won't get wrapped again.
</div>
<button>Apply</button>