6

I have a string like this:

"Size: 40; Color: 30"

I want to create tooltips for them such that it looks like this:

 <span class='tooltip' data-tooltip='The Size of a Unit is controlled by the Color of the Unit.'>Size</span>: 40; <span class='tooltip' data-tooltip='The Color of a Unit is a standard Setting.'>Color</span>: 30

Using a naive replacement however I end up with this:

 <span class='tooltip' data-tooltip='The Size of a Unit is controlled by the <span class='tooltip' data-tooltip='The Color of a Unit is a standard Setting.'>Color</span> of the Unit.'>Size</span>: 40; <span class='tooltip' data-tooltip='The Color of a Unit is a standard Setting.'>Color</span>: 30

Which is not what I want. How do I write a regex or do a replacement in such a way that it doesn't replace text that's already part of the tooltip?

Edit: I didn't make it clear that the replacements are not Size and Color, they're just examples. I'm adding an arbitrary amount, usually 20+ tooltips to any string.

Here are some testables:

var tooltips = {
  "Size":"The Size of a Unit is controlled by the Color",
  "Color": "bar",
  "Time and Size": "foo"
}

"Here we have something of <b>Size</b> 20 and Color red. it's very important that the Time and Size of the work and kept in sync."

Should result in:

"Here we have something of <b><span class='tooltip' data-tooltip='The Size of a Unit is controlled by the Color'>Size<span></b> 20 and <span class='tooltip' data-tooltip='bar'>Color<span> red. it's very important that the <span class='tooltip' data-tooltip='foo'>Time and Size<span> of the work and kept in sync."

The longer match should take precedence over shorter matches. It should match on only whole words and not parts of words.

Edit: Forgot to state yet another requirement.

It should still match strings that are wrapped with tags that are not tooltips.

Harry
  • 52,711
  • 71
  • 177
  • 261
  • Why don't you replace `Color` first and then `Size`? – thefourtheye Jan 11 '14 at 10:06
  • 2
    As you said that Size and Color is just samples how do you know the text to put on the data-tooltip ? – Jorge Campos Jan 13 '14 at 10:26
  • @JorgeCampos Oh it comes from user input. User gives the tooltip text and the keyword(s) to replace. – Harry Jan 13 '14 at 10:31
  • Ok, another question, if it is a user input does the user provide some sort of replacement string to put the words (eg: Color) or they just put the text for each string like: The string is `"XPTO: 40; TUFF: 30"` and user the provide: 'bla XPTO blah TUFF bleh' and 'The bleh blah bli TUFF' ? You should provide on your question some data input samples from the user, so we can analyse and help you in correct way. Otherwise you will get all kinds of answer trying to responde to an incomplete question. – Jorge Campos Jan 13 '14 at 10:39
  • @JorgeCampos Everything is a user input. Sorry I should get better at asking questions, you'd think I'd be pretty good at it by now with 3k points but heh. – Harry Jan 13 '14 at 10:43
  • @harry and a last question, replace all matches or the first ? (*also case sensitive or not ?*) – Gabriele Petrioli Jan 13 '14 at 10:44
  • @GabyakaG.Petrioli All matches and case sensitive, thank you. – Harry Jan 13 '14 at 10:46
  • Think, You shold use different markup for this, checkout http://jsfiddle.net/iegik/MdkwL/ - and this may be other solution for You. More typographic solution ;) – iegik Jan 14 '14 at 09:24

8 Answers8

2

I'd use the following:

//usual regex
var re = new RegExp("("+key+")(?!(?:<|.+?'>))", "g");

//regex to be applied on reversed string, same concept as regex above
var reRev = new RegExp("("+key.reverse()+")(?!(?:.+'=|>))", "g");

//start of the span tag including the tooltip
var repl = "<span class='tooltip' data-tooltip='"+value+"'>";
//end of the span tag
var replEnd = "</span>";

//first replacement
output = output.replace(re, repl+"$1"+replEnd);

//now reverse the whole string and do a second replacement,
//this, together with the reversed regex (which uses a lookahead) mimics a lookbehind (which is unfortunately not available in JS)
output = output.reverse().replace(reRev, replEnd.reverse()+"$1"+repl.reverse()).reverse();

Regex-Demo @ regex101
JS-Demo @ JSFiddle
See the JSFiddle as for the replacement of sentences you have to order the input-array first!

For every replacement the regex matches we replace it with the according span-tooltip-construct.
As JS has no lookbehind we have to mimic it to replace every occurence of the keywords, because the first regex will fail to match keywords before an opening <span>-tag, which could easily be solved with a lookbehind:
To mimic a lookbehind we use a lookahead, but simply reverse the whole text beforehand. The only downside is, that you have to reverse your regex, too... manually! For bigger expressions this will be a pain, but in this case it's fairly easy. If you reverse your input you don't want to match keywords if there comes a .+'= or a > afterwards.

For the regex itself:
Regular expression visualization
it matches only the keyword if it is not followed by a < (which would mark the </span> tag) and if it is not followed by .+'>, which means several chars and a '> which would mark the end of a data-tooltip attribute.
I made it case sensitive. If you want it to be case insensitive use the gi flags instead of only the g flag.

This way you're not limited to single words, you may replace "I am a sentence" with a tooltip of your choice, too. Same concept applies to the reversed regex.

You may need to adjust the replacement according to your datastructure. If it comes from user input, then an associative array may be right for you:

var replacements = new Array ();
replacements['Size'] = "The Size of a Unit is controlled by the Color of the Unit.";
replacements['Color']  = "The Color of a Unit is a standard Setting.";
KeyNone
  • 8,745
  • 4
  • 34
  • 51
  • @Harry it's a great way to simulate lookbehinds, although there are other ways to mimic them. If the regex is bigger I would use another way, as it can be a real pain to manually reverse the expression. – KeyNone Jan 13 '14 at 12:36
2

I think a single str.replace will do the work if got the right regexp pattern.

function replaceTooltips(str, tooltips) {

    //copy from https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
    function escapeRegExp(string) {

        return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
    }

    var keys = [];
    for ( var k in tooltips) {
        keys.push(escapeRegExp(k));
    }

    //create a regexp like (Size)|(Color)|(Time and Size)
    var ptn = new RegExp('(' + keys.join(')|(') + ')', 'g');

    str = str.replace(ptn, function(mat) {

        if (!tooltips[mat]) {
            return mat;
        }

        return '<span class="tooltip" data-tooltip="' + tooltips[mat].replace(/"/g, '&quot;') + '">' + mat + '</span>';
    });

    return str;

}

http://jsfiddle.net/rooseve/6wRUF/

Andrew
  • 5,290
  • 1
  • 19
  • 22
  • Glad it works. The keypoint here is do not replace one tip at one time(this will cause double-replace problem), but just use a single regexp to replace them all. – Andrew Jan 13 '14 at 11:14
  • Could you explain why you need escapeRegExp? Without that this answer looks like @tborychowski – Harry Jan 13 '14 at 11:33
  • @GabyakaG.Petrioli I'm looking for case sensitive. It does match multiple times for me? http://jsfiddle.net/6wRUF/1/ – Harry Jan 13 '14 at 11:36
  • @harry yes .. my test string had different case only once and thought that the multiple times failed as well.. my mistake.. To fix set the keys in the tooltip object to be lowercase and change all `tooltips[mat]` to `tooltips[mat.toLowerCase()]` – Gabriele Petrioli Jan 13 '14 at 11:40
  • oh and use `'gi'` flag instead of `'g'` in the regex – Gabriele Petrioli Jan 13 '14 at 11:41
  • @Harry,escapeRegExp will make sure tooltip key be treated as a whole string, not some regexp syntax. For example, if the tooltip key is "a.b", as "." has special meaning in the regexp, so "a.b" will not work properly, escapeRegExp will convert it to "a\.b" – Andrew Jan 13 '14 at 11:58
  • The resolution of the string depends on the order of the search strings. If there are 2 search strings such that one is prefix of the other, for example : "Size" and "Size and Color". Only the first will match in the text. To "Size and Color" to match it must be before "Size" in the list. – Zoltan.Tamasi Jan 13 '14 at 12:48
  • @Zoltan.Tamasi Hmm any solutions? Just sort the search terms by size? – Harry Jan 13 '14 at 12:56
  • If you have strings like this, and it's not a problem that you can only match the first, so you can leave the others completely out. But if it is a problem, and you have to deal with strings like these, I have a shrewd idea, that will need a completely different solution. – Zoltan.Tamasi Jan 13 '14 at 13:01
  • @Zoltan.Tamasi what's wrong with sorting the search terms? Also what do you think about the text node solution by Salman A? I'm only concerned about speed with one one, so many iterations seems like it wouldn't be smart. – Harry Jan 13 '14 at 13:19
  • Yes, I was wrong, you must sort the terms in descending order. – Zoltan.Tamasi Jan 13 '14 at 13:34
2

Many of the other answers are just band-aids on the core problem: sometimes the searches are found in the replacements of other strings.

For examples some of the other naive solutions that will break easily:

  • changing the order (doesn't work if both contain the other)
  • looking ahead or back (doesn't work if replace template changes, plus HTML regex parsing is notoriously hard)

But if we think back to the core problem there is an obvious solution:

Don't let the replacements be replaceable.

So, do two passes instead. Steps:

  1. Instead of a object/dictionary use an array of objects. Each item should be { search: "Size", tooltip: "Long description" }.
  2. Sort the array by the length of the search string (you said you wanted longer to take precedence, so this is how it happens)
  3. Iterate through array and replace in order all keywords with a unique string involving its index in the array. For example Size and color becomes ###replace0###.
  4. Iterate again and replace all unique strings with the tooltip, so ###replace0### becomes <span class='tooltip' data-tooltip='The size is one thing, the color is another.'>Size and color</span>.

This way the whole operation acts as one search/replace and does not have the possibility of replacing matches in other replacements.

Nicole
  • 32,841
  • 11
  • 75
  • 101
1

Simply loop through the words of your string and if a tooltip is found for a word - replace it with the tooltip span:

var s1 = 'Here we have something of <b>Size</b> 20 and Color red. it\'s very important that the Time and Size of the work and kept in sync.',

    // replace object
    rep = {
        size: '<span class="tooltip" data-tooltip="The Size of a Unit is controlled by the Color of the Unit.">Size</span>',
        color: '<span class="tooltip" data-tooltip="The Color of a Unit is a standard Setting.">Color</span>',
        'time and size': '<span class="tooltip" data-tooltip="Time and Size tooltip">Foo</span>'
    },

    // build RegExp out of replace object keys
    reg = new RegExp('(' + Object.keys(rep).join(')|(') + ')', 'ig');

    // replace
    s2 = s1.replace(reg, function(s) {
        return rep[s.toLowerCase()] ? rep[s.toLowerCase()] : s;
    });


console.log(s2);
Dziad Borowy
  • 12,368
  • 4
  • 41
  • 53
1

It could be this simple if there were no multi-word replacements.

var tooltips = {
  "Size":"The Size of a Unit is controlled by the Color",
  "Color": "bar",
  "Time and Size": "foo"
}

var text = "Here we have something of Size 20 and Color red. it's very important that the Time and Size of the work and kept in sync."

var replaced = text.split(" ").map(function(token) {
    return tooltips[token] || token;
}).join(" ");
Zoltan.Tamasi
  • 1,382
  • 13
  • 26
  • It is what I was doing, since you did it first. The multi word part you can solve it replacing the spaces on the tokens by an `_` then, after the final replacement you take the `_` off the tokens – Jorge Campos Jan 13 '14 at 11:01
  • Yes but you create an another problem with that. Finding where to put _ is not simpler than the inital question I think. – Zoltan.Tamasi Jan 13 '14 at 11:03
1

You can use jQuery to locate all text nodes inside an element. Afterwards, you can use DOM functions (instead of regex) to split the text around the specified word, then wrap the word inside tooltip. Here is an exmple:

function replaceTextWithSpan(node, text, options) {
    var searchText = text.toLowerCase(),
        currentNode = node,
        matchIndex,
        newTextNode,
        newSpanNode;
    while ((matchIndex = currentNode.data.toLowerCase().indexOf(searchText)) >= 0) {
        newTextNode = currentNode.splitText(matchIndex);
        currentNode = newTextNode.splitText(searchText.length);
        newSpanNode = document.createElement("span");
        newSpanNode.className = "tooltip";
        newSpanNode.setAttribute("data-tooltip", options["data-tooltip"]);
        currentNode.parentNode.insertBefore(newSpanNode, currentNode);
        newSpanNode.appendChild(newTextNode);
    }
}

And a test:

<div id="test">Size: 40; Color: 30; <b>Bold Size Test:</b> 20; <span>Another Size Test: 10</span></div>
$("#test, #test *").contents().filter(function () {
    return this.nodeType == this.TEXT_NODE;
}).each(function () {
    replaceTextWithSpan(this, "Size", { "data-tooltip": "The Size of a Unit is controlled by the Color of the Unit." });
});
$("#test, #test *").contents().filter(function () {
    return this.nodeType == this.TEXT_NODE;
}).each(function () {
    replaceTextWithSpan(this, "Color", { "data-tooltip": "The Color of a Unit is a standard Setting." });
});
alert($("#test").html());

And result:

<span class="tooltip" data-tooltip="The Size of a Unit is controlled by the Color of the Unit.">Size</span>: 40;
<span class="tooltip" data-tooltip="The Color of a Unit is a standard Setting.">Color</span>: 30;
<b>Bold <span class="tooltip" data-tooltip="The Size of a Unit is controlled by the Color of the Unit.">Size</span> Test:</b> 20;
<span>Another <span class="tooltip" data-tooltip="The Size of a Unit is controlled by the Color of the Unit.">Size</span> Test: 10</span>

Demo here


Original answer: Here is a solution that does not use RegEx:

  • Loop over the text/HTML child nodes inside the element
  • Skip HTML nodes and text nodes that do not contain the search string
  • Break the text node before and after the search string (so that you end up with three text nodes)
  • Wrap the middle node inside a span

Here is the code (I undestand that it is complicated):

function replaceTextWithSpan(node, text, options) {
    var searchText = text.toLowerCase(),
        currentNode = node.firstChild,
        matchIndex,
        newTextNode,
        newSpanNode;
    while (currentNode) {
        matchIndex = currentNode.nodeType === currentNode.TEXT_NODE
            ? currentNode.data.toLowerCase().indexOf(searchText)
            : -1;
        if (matchIndex >= 0) {
            newTextNode = currentNode.splitText(matchIndex);
            currentNode = newTextNode.splitText(searchText.length);
            newSpanNode = document.createElement("span");
            // the following line can be replaced with for...in
            // loop to assign multiple attributes to the span
            newSpanNode.className = options.className;
            currentNode.parentNode.insertBefore(newSpanNode, currentNode);
            newSpanNode.appendChild(newTextNode);
        } else {
            currentNode = currentNode.nextSibling;
        }
    }
}

Here is a test:

var node = document.createElement("div");
node.innerHTML = "Size: 40; Color: 30; Span: For testing";
replaceTextWithSpan(node, "Size", { className: "highlight" });
replaceTextWithSpan(node, "Color", { className: "highlight" });
replaceTextWithSpan(node, "Span", { className: "highlight" });
alert(node.innerHTML);

This produces following output (pretty printed):

<span class="highlight">Size</span>: 40;
<span class="highlight">Color</span>: 30;
<span class="highlight">Span</span>: For testing 

Demo here

Salman A
  • 262,204
  • 82
  • 430
  • 521
  • Wow quite educational, thanks for this. I really like this answer but it doesn't work with keywords that are already in tags like `Size` – Harry Jan 13 '14 at 11:46
  • You mean you want a "deep" search/replace? I think it can be done with recursion but the code would be uglier. – Salman A Jan 13 '14 at 11:47
  • Thanks I understand the concept. I think regex might be easier in this case but I'll be using this method for some other replacements. – Harry Jan 13 '14 at 11:50
  • Yes jQuery is used in this project. – Harry Jan 13 '14 at 12:01
  • Thanks a lot this is great. I learned a lot from this question, wish I could give everyone point more points. You think this is faster or slower than the regex? – Harry Jan 13 '14 at 12:43
  • Regex should be faster (I think). But it is better not to process HTML with regex (e.g. what if the word you are trying to replace is also a html tag, such as strong or article). – Salman A Jan 13 '14 at 12:58
  • That's definitely a good point, now I don't know what to do. I'm concerned about performance regarding this strategy though. – Harry Jan 13 '14 at 13:25
  • Will this solution provide the expected result with the test provided by Harry? ("Size", "Color","Time and Size") – Zoltan.Tamasi Jan 13 '14 at 13:26
  • @Zoltan.Tamasi: it should (or it can be made to match the expected results). I have posted the output in the answer as well. – Salman A Jan 13 '14 at 13:35
  • @Harry: use the one that is more readable and produces correct results. If performance is _really_ an issue, you can test the performance of different sets of code on [jsperf.com](http://jsperf.com) – Salman A Jan 13 '14 at 13:38
  • Yes, it does the expected, but only if terms are sorted in descending order, just as with Andrew's solution. – Zoltan.Tamasi Jan 13 '14 at 13:41
0

If they are always separated by ; then you should split the string there, replace each part by the appropriate string and then join them again..

Something like

var tooltips = {
        'size': 'The Size of a Unit is controlled by the Color of the Unit.',
        'color': 'The Color of a Unit is a standard Setting.'
        ..etc..
    },
    myString = "Size: 40; Color: 30",
    stringParts = myString.split(';');

for (var i = 0, len = stringParts.length; i < len; i++){
   var pair = stringParts[i].split(':'),
       key = pair[0].trim().toLowerCase(),
       tip = tooltips[key];

  if (tip){
     pair[0] = '<span class="tooltip" data-tooltip="'+ tip +'">' + key + '</span>';
  }
  }

  stringParts[i] = pair.join(':');
}

alert( stringParts.join('; ') );

if your browser does not natively support the .trim() function then find an implementation at Trim string in JavaScript?

Community
  • 1
  • 1
Gabriele Petrioli
  • 191,379
  • 34
  • 261
  • 317
0

You can use the following code :

var tooltips = {
  "Size":"The Size of a Unit is controlled by the Color",
  "Color": "bar",
  "Time and Size": "foo"
}

var str="Here we have something of <b>Size</b> 20 and Color red. it's very important that the Time and Size of the work and kept in sync."


    var len=0;
    var res=str;
    var rep="<span class='tooltip' data-tooltip='";
       $.each(tooltips, function(key, value) { 
           var patt1=new RegExp(key);
           var ar=patt1.exec(str);

           var repstr=rep+value+"'>"+key+"<span>";
               res=res.substr(0,ar.index+len)+repstr+res.substr(ar[0].length+ar.index+len);
           len+=repstr.length-key.length;


        });
    alert("result:" +res);
Sujith PS
  • 4,776
  • 3
  • 34
  • 61