matched-text.js 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. import React from 'react';
  2. const TRUNCATE_CONTEXT = 6;
  3. const TRUNCATE_ELLIPSIS = '…';
  4. /**
  5. * Returns an array with chunks that cover the whole text via {start, length}
  6. * objects.
  7. *
  8. * `('text', {start: 2, length: 1}) => [{text: 'te'}, {text: 'x', match: true}, {text: 't'}]`
  9. */
  10. function chunkText(text, { start, length }) {
  11. if (text && !window.isNaN(start) && !window.isNaN(length)) {
  12. const chunks = [];
  13. // text chunk before match
  14. if (start > 0) {
  15. chunks.push({text: text.substr(0, start)});
  16. }
  17. // matching chunk
  18. chunks.push({match: true, offset: start, text: text.substr(start, length)});
  19. // text after match
  20. const remaining = start + length;
  21. if (remaining < text.length) {
  22. chunks.push({text: text.substr(remaining)});
  23. }
  24. return chunks;
  25. }
  26. return [{ text }];
  27. }
  28. /**
  29. * Truncates chunks with ellipsis
  30. *
  31. * First chunk is truncated from left, second chunk (match) is truncated in the
  32. * middle, last chunk is truncated at the end, e.g.
  33. * `[{text: "...cation is a "}, {text: "useful...or not"}, {text: "tool..."}]`
  34. */
  35. function truncateChunks(chunks, text, maxLength) {
  36. if (chunks && chunks.length === 3 && maxLength && text && text.length > maxLength) {
  37. const res = chunks.map(c => Object.assign({}, c));
  38. let needToCut = text.length - maxLength;
  39. // trucate end
  40. const end = res[2];
  41. if (end.text.length > TRUNCATE_CONTEXT) {
  42. needToCut -= end.text.length - TRUNCATE_CONTEXT;
  43. end.text = `${end.text.substr(0, TRUNCATE_CONTEXT)}${TRUNCATE_ELLIPSIS}`;
  44. }
  45. if (needToCut) {
  46. // truncate front
  47. const start = res[0];
  48. if (start.text.length > TRUNCATE_CONTEXT) {
  49. needToCut -= start.text.length - TRUNCATE_CONTEXT;
  50. start.text = `${TRUNCATE_ELLIPSIS}`
  51. + `${start.text.substr(start.text.length - TRUNCATE_CONTEXT)}`;
  52. }
  53. }
  54. if (needToCut) {
  55. // truncate match
  56. const middle = res[1];
  57. if (middle.text.length > 2 * TRUNCATE_CONTEXT) {
  58. middle.text = `${middle.text.substr(0, TRUNCATE_CONTEXT)}`
  59. + `${TRUNCATE_ELLIPSIS}`
  60. + `${middle.text.substr(middle.text.length - TRUNCATE_CONTEXT)}`;
  61. }
  62. }
  63. return res;
  64. }
  65. return chunks;
  66. }
  67. /**
  68. * Renders text with highlighted search match.
  69. *
  70. * A match object is of shape `{text, label, match}`.
  71. * `match` is a text match object of shape `{start, length}`
  72. * that delimit text matches in `text`. `label` shows the origin of the text.
  73. */
  74. export default class MatchedText extends React.PureComponent {
  75. render() {
  76. const {
  77. match, text, truncate, maxLength
  78. } = this.props;
  79. const showFullValue = !truncate || (match && (match.start + match.length) > truncate);
  80. const displayText = showFullValue ? text : text.slice(0, truncate);
  81. if (!match) {
  82. return <span>{displayText}</span>;
  83. }
  84. const chunks = chunkText(displayText, match);
  85. return (
  86. <span className="matched-text" title={text}>
  87. {truncateChunks(chunks, displayText, maxLength).map((chunk) => {
  88. if (chunk.match) {
  89. return (
  90. <span className="match" key={chunk.offset}>
  91. {chunk.text}
  92. </span>
  93. );
  94. }
  95. return chunk.text;
  96. })}
  97. </span>
  98. );
  99. }
  100. }