5

Similar questions might have been asked already, but please read the details carefully.

I am using kind of a self-made autocomplete, and now I want to highlight the search term inside the result set. This works so far, but only on plain text. The problem is: I need to keep the html structure, if there is one in the result div. Please see my sample: currently I am loosing the included span's with the class bold. How can I keep them?

Thank you for any advice!

$('#box').keyup(function () {
  const valThis = this.value;
  const length  = this.value.length;

  $('.objType').each(function () {
    const text  = $(this).text();
    const textL = text.toLowerCase();
    const position = textL.indexOf(valThis.toLowerCase());

    if (position !== -1) {
      const matches = text.substring(position, (valThis.length + position));
      const regex = new RegExp(matches, 'ig');
      const highlighted = text.replace(regex, `<mark>${matches}</mark>`);

      $(this).html(highlighted).show();
    } else {
     $(this).text(text);
      $(this).hide();
    }
  });

});
input[type="text"] { 
    width: 50%;
    margin:10px;
    padding: 5px;
    float:left;
    clear:left;
}
div{
  float:left;
  clear:left;
  margin:10px 10px;
}
.bold {
  font-weight: 700;
}
table td {
  border: solid 1px #ccc;
  padding: 3px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.0/jquery.min.js"></script>
<input placeholder="Filter results" id="box" type="text" />

<div class="objType" id="item1">
  <span class="bold">Accepted</span> Event Relation
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item2">
  Case <span class="bold">Status</span> Value
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item3">
  External <span class="bold">Data Source</span>
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item4">
  Navigation <span class="bold">Link</span> Set
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>

PS: a JSFiddle for this in addition might be helpful => https://jsfiddle.net/SchweizerSchoggi/6x3ak5d0/7/

JonSnow
  • 573
  • 14
  • 48
  • 2
    one way you can do that is using the getClientRects method of the Range object. See https://stackoverflow.com/questions/58553501/how-to-highlight-search-text-from-string-of-html-content-without-breaking/58721469#58721469 – Julien Grégoire Dec 11 '19 at 14:10

5 Answers5

3

Using mark.js plugin

$('#box').on('input', function() {
  const valThis = this.value;

  const options = {
    filter: function(node, term, totalCounter, counter) {
      $(node).parents('.objType').show()
      return true
    }
  };

  $('.objType').unmark({
    done: function() {
      $('.objType')
        .toggle(valThis.length === 0)
        .mark(valThis, options);
    }
  })
});
input[type="text"] {
  width: 50%;
  margin: 10px;
  padding: 5px;
  float: left;
  clear: left;
}

div {
  float: left;
  clear: left;
  margin: 10px 10px;
}

.bold {
  font-weight: 700;
}

table td {
  border: solid 1px #ccc;
  padding: 3px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js" integrity="sha256-4HLtjeVgH0eIB3aZ9mLYF6E8oU5chNdjU6p6rrXpl9U=" crossorigin="anonymous"></script>

<input placeholder="Filter results" id="box" type="text" />

<div class="objType" id="item1">
  <span class="bold">Accepted</span> Event Relation
  <table>
    <tr>
      <td>Lorem</td>
      <td>ipsum</td>
    </tr>
  </table>
</div>
<div class="objType" id="item2">
  Case <span class="bold">Status</span> Value
  <table>
    <tr>
      <td>Lorem</td>
      <td>ipsum</td>
    </tr>
  </table>
</div>
<div class="objType" id="item3">
  External <span class="bold">Data Source</span>
  <table>
    <tr>
      <td>Lorem</td>
      <td>ipsum</td>
    </tr>
  </table>
</div>
<div class="objType" id="item4">
  Navigation <span class="bold">Link</span> Set
  <table>
    <tr>
      <td>Lorem</td>
      <td>ipsum</td>
    </tr>
  </table>
</div>
User863
  • 19,346
  • 2
  • 17
  • 41
3

Here is a possible base using only native javascript. This behave somewhat like CTRL+F.

This seems to preserve the <td> elements.

The clear function replace the mark elements by a wbr element:

On UTF-8 encoded pages, <wbr> behaves like the U+200B ZERO-WIDTH SPACE code point. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/wbr

function mark(it){
  clearIt()
  if (it.length > 2){
    let c = new RegExp(it, "ig") 
    main.innerHTML = main.innerHTML.replace(c,"<mark>"+it+"</mark>")
  }  
}

function clearIt(){
  let b = new RegExp("mark>", "ig") 
  main.innerHTML = main.innerHTML.replace(b,"wbr>")
}

mark(search.value)
input[type="text"] { 
    width: 50%;
    margin:10px;
    padding: 5px;
    float:left;
    clear:left;
}
div{
  float:left;
  clear:left;
  margin:10px 10px;
}
.bold {
  font-weight: 700;
}
table td {
  border: solid 1px #ccc;
  padding: 3px;
}
<input onfocusout="clearIt()" oninput="mark(this.value)"  value="Lorem" id="search" placeholder="Lorem">
<button onclick="mark(search.value)">SEARCH</button>
<button onclick="clearIt()">CLEAR</button>

<div id="main">
<div class="objType" id="item1">
  <span class="bold">Accepted</span> Event Relation
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item2">
  Case <span class="bold">Status</span> Value
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item3">
  External <span class="bold">Data Source</span>
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item4">
  Navigation <span class="bold">Link</span> Set
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>

</div>

By the way the restored/cleared markup isn't the original, to fully restore it, you might need to copy the whole html before marking it up.

NVRM
  • 11,480
  • 1
  • 88
  • 87
  • 1
    This might be the closest that we can get without a plugin. Unfortunately I cannot search for "Accepted event" (is there a chance to solve that?). Otherwise that's a nice soluton. Thx – JonSnow Dec 18 '19 at 09:23
2

To keep everything simple, I've created the functionality in vue file (because, the functions are easy to implement and variables to interpolate):

<template>
  <div class="container">
    <div class="position-relative">
      <input
        v-model="inputValue"
        type="search"
        autocomplete="off"
        class="form-control"
        @input="onInput" />
    </div>
    <pre v-html="results" />
  </div>
</template>

<script>
export default {
  name: 'Typeahead',
  data() {
    return {
      inputValue: '',
      items: [
        { value: '' },
        { value: '' },
        { value: '' },
        // and so on (Value is the field to be searched).
      ],
      results: [],
    };
  },

  created() {
    this.results = this.items; // initially assign all the items as results
  },

  methods: {
    onInput() { // input event (use event target value for vanilla js.)
      const value = this.inputValue;
      if (!value) {
        this.results = this.items;
        return;
      }
      const escapedQuery = this.escapeRegExp(value); // escape any special characters
      const queryReg = new RegExp(escapedQuery, 'gi'); // create a regular expression out of it
      this.results = this.matchItem(this.items, queryReg) // call match function
        .map(item => ({
          ...item,
          value: this.highlight(item.value, queryReg), // highlight the matched text range
        }));
    },

    escapeHtml(text) {
      return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
    },

    escapeRegExp(text) {
      return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    },

    matchItem(items, queryReg) {
      return items
        .filter(i => i.value.match(queryReg) !== null)
        .sort((a, b) => {
          const aIndex = a.value.indexOf(a.value.match(queryReg)[0]);
          const bIndex = b.value.indexOf(b.value.match(queryReg)[0]);
          if (aIndex < bIndex) return -1;
          if (aIndex > bIndex) return 1;
          return 0;
        });
    },

    highlight(text, queryReg) {
      const escaped = this.escapeHtml(text);
      return escaped.replace(queryReg, '<b>$&</b>');
    },
  },
};
</script>

Check this fiddle I've created

Basically, what it does escapes any special symbols from the input text and create a regular expression out of it, then filter out the records matching that regular expression. The elements are then sorted according to the matched strength. Now, the matched part(value in record) is highlighted using either strong or b html tag(I've used bold tag here), which can be easily embedded to html that yields the highlighted output as expected.

I've used pre tags for displaying result. You can create table structure as per your requirement.

The methods are in vanilla javascript and not much vue going on in it(except for this references).

I hope it helps :)

Vectrobyte
  • 1,408
  • 12
  • 30
1

I have tried to work up on your approach and came with the following solution. It is working fine for most of the cases.

/*
This function will get all the indices of searched term in the html in array format
e.g. if html is <span>Accept</span> and user types a
Input: getAllIndicesOf(a, "<span>Accept</span>", false)
Output: [3,6,16]
*/
function getAllIndicesOf(searchStr, str, caseSensitive) {
    var searchStrLen = searchStr.length;
    if (searchStrLen == 0) {
        return [];
    }
    var startIndex = 0, index, indices = [];
    if (!caseSensitive) {
        str = str.toLowerCase();
        searchStr = searchStr.toLowerCase();
    }
    while ((index = str.indexOf(searchStr, startIndex)) > -1) {
        indices.push(index);
        startIndex = index + searchStrLen;
    }
    return indices;
}

/*
What ever values I am getting from getAllIndicesOf, here I try to find if the searched value is not present inside html tag.
e.g. if html is <span>Accept</span> and user types a
getAllIndicesOf will output [3,6,16]
Input: findValidMatches([3,6,16], "a")
Output: [6]
Logic: The valid matching text will lie between > and <. If it lies between < and >, it is a html tag. 
*/
function findValidMatches(pseudoPosition, str) {
    const pos = []
    for (let i = 0; i<pseudoPosition.length; ++i) {
     const splitText = str.substr(pseudoPosition[i])
      const indexOfLT = splitText.indexOf("<")
      const indexOfGT = splitText.indexOf(">")
      if (indexOfLT > -1 && indexOfGT > -1 && indexOfLT < indexOfGT) {
        pos.push(pseudoPosition[i])
      }
      else if (indexOfLT === -1 && indexOfGT > -1 && indexOfGT < 0) {
        pos.push(pseudoPosition[i])
      }
      else if (indexOfGT === -1 && indexOfLT > -1 && indexOfLT > 0) {
        pos.push(pseudoPosition[i])
      }
      else if (indexOfLT === -1 && indexOfGT === -1) {
        pos.push(pseudoPosition[i])
      }
    }
    return pos
}

/*
This will replace the matched valid string with <mark>text</mark> to highlight
if html is <span>Accept</span> and user types a
getAllIndicesOf will output [3,6,16] -> findValidMatches will output [6] -> input to replaceText

replaceText("<span>Accept</span>", [6], "a") will output <span><mark>A</mark></span>
*/
function replaceText(text, correctPositions, valueToReplace) {
  let copyText = text
  for (let i = 0; i<correctPositions.length; ++i) {
    const upValue = correctPositions[i] + 13*i
    const firstPart = copyText.slice(0, upValue)
    const lastPart = copyText.slice(upValue + valueToReplace.length, copyText.length)
    const valueWithCase = copyText.substr(upValue, valueToReplace.length)
    copyText = firstPart + "<mark>" + valueWithCase +"</mark>" + lastPart
  }
  return copyText
}

$('#box').keyup(function () {
  const valThis = this.value;

  $('.objType').each(function () {
    const text  = $(this).html().replace(/<mark>/gi, "").replace(/<\/mark>/gi, "");
    const position = getAllIndicesOf(valThis, text) //Get all indices of valThis in the html
    const correctPositions = findValidMatches(position, text) //Filter only those indices which indicate that they are text and not html
    const updatedText = replaceText(text, correctPositions, valThis) //Get the updated text with mark tags
    if (correctPositions.length > 0) {
      $(this).html(updatedText)
      $(this).show();
    } else {
      if (valThis.length > 0) $(this).hide();
      else {
        $(this).html(text)
        $(this).show();
      }
    }
  });

});
input[type="text"] { 
    width: 50%;
    margin:10px;
    padding: 5px;
    float:left;
    clear:left;
}
div{
  float:left;
  clear:left;
  margin:10px 10px;
}
.bold {
  font-weight: 700;
}
table td {
  border: solid 1px #ccc;
  padding: 3px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<input placeholder="Filter results" id="box" type="text" />

<div class="objType" id="item1">
  <span class="bold">Accepted</span> Event Relation
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item2">
  Case <span class="bold">Status</span> Value
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item3">
  External <span class="bold">Data Source</span>
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>
<div class="objType" id="item4">
  Navigation <span class="bold">Link</span> Set
  <table>
  <tr>
    <td>Lorem</td>
    <td>ipsum</td>
  </tr>
  </table>
</div>

The approach which I have followed is the direct search on the html part and then update.

Approach

  1. Take the whole html as string (const text = $(this).html().replace(/<mark>/gi, "").replace(/<\/mark>/gi, "");)
  2. Find all the occurrences of the searched word (using getAllIndicesOf)
  3. Pick only those occurrences which are not inside html tags (using findValidMatches)
  4. Reconstruct the html by inserting <mark></mark> tag at appropriate places (using replaceText)
  5. Reinsert the string as html

There might be lot of issues which will get associated (such as if event handlers are there and search will fail if you try to search for text with html tags such as try searching for AcceptedEvent). I will try to update on that.

Hope it helps.

Sunil Chaudhary
  • 4,481
  • 3
  • 22
  • 41
  • This looks good and on first trial it seems to work as expected. You wrote "It is working fine for most of the cases.". Which case do you know where it is NOT working fine? And, well, I do not really understand all of the part up there... :-D – JonSnow Dec 11 '19 at 13:19
  • Try searching for `Accepted Event` or `Status Value`. It fails there and I am still figuring out how to solve those kind of cases – Sunil Chaudhary Dec 11 '19 at 13:30
  • Late to the party, but the reason it fails might be due to the replace function reading the html tags as text. When you search for `Accepted Event` it is only seeing `Accepted Event`. Is there a way to make it ignore the tags, because that seems to be the only hurdle left to jump on your solution? – Joel Trauger Dec 16 '19 at 15:28
1

Following @Sunil solution what causes the problem is the <span> tag you need to remove it, so i've edit this part of code :

$('.objType').each(function () {

    const text  = $(this).html().replace(/<mark>/gi, "").replace(/<\/mark>/gi, "");
    const text2  = text.replace(/<span class=\"bold\">/gi, "").replace(/<\/span>/gi, "");

    const position = getAllIndicesOf(valThis, text2) //Get all indices of valThis in the html
    const correctPositions = findValidMatches(position, text2) //Filter only those indices which indicate that they are text and not html
    const updatedText = replaceText(text2, correctPositions, valThis) //Get the updated text with mark tags
    if (correctPositions.length > 0) {
      $(this).html(updatedText)

      $(this).show();
    } else {
      if (valThis.length > 0) $(this).hide();
      else {
        $(this).html(text2)
        $(this).show();
      }
    }
  });
Zak
  • 69
  • 1
  • 6