local-search.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /* global CONFIG */
  2. window.addEventListener('DOMContentLoaded', () => {
  3. // Popup Window
  4. let isfetched = false;
  5. let datas;
  6. let isXml = true;
  7. // Search DB path
  8. let searchPath = CONFIG.path;
  9. if (searchPath.length === 0) {
  10. searchPath = 'search.xml';
  11. } else if (/json$/i.test(searchPath)) {
  12. isXml = false;
  13. }
  14. const path = CONFIG.root + searchPath;
  15. const input = document.querySelector('.search-input');
  16. const resultContent = document.getElementById('search-result');
  17. // Ref: https://github.com/ForbesLindesay/unescape-html
  18. const unescapeHtml = html => {
  19. return String(html)
  20. .replace(/"/g, '"')
  21. .replace(/'/g, '\'')
  22. .replace(/:/g, ':')
  23. // Replace all the other &#x; chars
  24. .replace(/&#(\d+);/g, (m, p) => {
  25. return String.fromCharCode(p);
  26. })
  27. .replace(/&lt;/g, '<')
  28. .replace(/&gt;/g, '>')
  29. .replace(/&amp;/g, '&');
  30. };
  31. const getIndexByWord = (word, text, caseSensitive) => {
  32. let wordLen = word.length;
  33. if (wordLen === 0) return [];
  34. let startPosition = 0;
  35. let position = [];
  36. let index = [];
  37. if (!caseSensitive) {
  38. text = text.toLowerCase();
  39. word = word.toLowerCase();
  40. }
  41. while ((position = text.indexOf(word, startPosition)) > -1) {
  42. index.push({ position, word });
  43. startPosition = position + wordLen;
  44. }
  45. return index;
  46. };
  47. // Merge hits into slices
  48. const mergeIntoSlice = (start, end, index, searchText) => {
  49. let item = index[index.length - 1];
  50. let { position, word } = item;
  51. let hits = [];
  52. let searchTextCountInSlice = 0;
  53. while (position + word.length <= end && index.length !== 0) {
  54. if (word === searchText) {
  55. searchTextCountInSlice++;
  56. }
  57. hits.push({
  58. position,
  59. length: word.length
  60. });
  61. let wordEnd = position + word.length;
  62. // Move to next position of hit
  63. index.pop();
  64. while (index.length !== 0) {
  65. item = index[index.length - 1];
  66. position = item.position;
  67. word = item.word;
  68. if (wordEnd > position) {
  69. index.pop();
  70. } else {
  71. break;
  72. }
  73. }
  74. }
  75. return {
  76. hits,
  77. start,
  78. end,
  79. searchTextCount: searchTextCountInSlice
  80. };
  81. };
  82. // Highlight title and content
  83. const highlightKeyword = (text, slice) => {
  84. let result = '';
  85. let prevEnd = slice.start;
  86. slice.hits.forEach(hit => {
  87. result += text.substring(prevEnd, hit.position);
  88. let end = hit.position + hit.length;
  89. result += `<b class="search-keyword">${text.substring(hit.position, end)}</b>`;
  90. prevEnd = end;
  91. });
  92. result += text.substring(prevEnd, slice.end);
  93. return result;
  94. };
  95. const inputEventFunction = () => {
  96. let searchText = input.value.trim().toLowerCase();
  97. let keywords = searchText.split(/[-\s]+/);
  98. if (keywords.length > 1) {
  99. keywords.push(searchText);
  100. }
  101. let resultItems = [];
  102. if (searchText.length > 0) {
  103. // Perform local searching
  104. datas.forEach(data => {
  105. // Only match articles with not empty titles
  106. if (!data.title) return;
  107. let searchTextCount = 0;
  108. let title = data.title.trim();
  109. let titleInLowerCase = title.toLowerCase();
  110. let content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '';
  111. if (CONFIG.localsearch.unescape) {
  112. content = unescapeHtml(content);
  113. }
  114. let contentInLowerCase = content.toLowerCase();
  115. let articleUrl = decodeURIComponent(data.url).replace(/\/{2,}/g, '/');
  116. let indexOfTitle = [];
  117. let indexOfContent = [];
  118. keywords.forEach(keyword => {
  119. indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false));
  120. indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false));
  121. });
  122. // Show search results
  123. if (indexOfTitle.length > 0 || indexOfContent.length > 0) {
  124. let hitCount = indexOfTitle.length + indexOfContent.length;
  125. // Sort index by position of keyword
  126. [indexOfTitle, indexOfContent].forEach(index => {
  127. index.sort((itemLeft, itemRight) => {
  128. if (itemRight.position !== itemLeft.position) {
  129. return itemRight.position - itemLeft.position;
  130. }
  131. return itemLeft.word.length - itemRight.word.length;
  132. });
  133. });
  134. let slicesOfTitle = [];
  135. if (indexOfTitle.length !== 0) {
  136. let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText);
  137. searchTextCount += tmp.searchTextCountInSlice;
  138. slicesOfTitle.push(tmp);
  139. }
  140. let slicesOfContent = [];
  141. while (indexOfContent.length !== 0) {
  142. let item = indexOfContent[indexOfContent.length - 1];
  143. let { position, word } = item;
  144. // Cut out 100 characters
  145. let start = position - 20;
  146. let end = position + 80;
  147. if (start < 0) {
  148. start = 0;
  149. }
  150. if (end < position + word.length) {
  151. end = position + word.length;
  152. }
  153. if (end > content.length) {
  154. end = content.length;
  155. }
  156. let tmp = mergeIntoSlice(start, end, indexOfContent, searchText);
  157. searchTextCount += tmp.searchTextCountInSlice;
  158. slicesOfContent.push(tmp);
  159. }
  160. // Sort slices in content by search text's count and hits' count
  161. slicesOfContent.sort((sliceLeft, sliceRight) => {
  162. if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) {
  163. return sliceRight.searchTextCount - sliceLeft.searchTextCount;
  164. } else if (sliceLeft.hits.length !== sliceRight.hits.length) {
  165. return sliceRight.hits.length - sliceLeft.hits.length;
  166. }
  167. return sliceLeft.start - sliceRight.start;
  168. });
  169. // Select top N slices in content
  170. let upperBound = parseInt(CONFIG.localsearch.top_n_per_article, 10);
  171. if (upperBound >= 0) {
  172. slicesOfContent = slicesOfContent.slice(0, upperBound);
  173. }
  174. let resultItem = '';
  175. if (slicesOfTitle.length !== 0) {
  176. resultItem += `<li><a href="${articleUrl}" class="search-result-title">${highlightKeyword(title, slicesOfTitle[0])}</a>`;
  177. } else {
  178. resultItem += `<li><a href="${articleUrl}" class="search-result-title">${title}</a>`;
  179. }
  180. slicesOfContent.forEach(slice => {
  181. resultItem += `<a href="${articleUrl}"><p class="search-result">${highlightKeyword(content, slice)}...</p></a>`;
  182. });
  183. resultItem += '</li>';
  184. resultItems.push({
  185. item: resultItem,
  186. id : resultItems.length,
  187. hitCount,
  188. searchTextCount
  189. });
  190. }
  191. });
  192. }
  193. if (keywords.length === 1 && keywords[0] === '') {
  194. resultContent.innerHTML = '<div id="no-result"><i class="fa fa-search fa-5x"></i></div>';
  195. } else if (resultItems.length === 0) {
  196. resultContent.innerHTML = '<div id="no-result"><i class="fa fa-frown-o fa-5x"></i></div>';
  197. } else {
  198. resultItems.sort((resultLeft, resultRight) => {
  199. if (resultLeft.searchTextCount !== resultRight.searchTextCount) {
  200. return resultRight.searchTextCount - resultLeft.searchTextCount;
  201. } else if (resultLeft.hitCount !== resultRight.hitCount) {
  202. return resultRight.hitCount - resultLeft.hitCount;
  203. }
  204. return resultRight.id - resultLeft.id;
  205. });
  206. let searchResultList = '<ul class="search-result-list">';
  207. resultItems.forEach(result => {
  208. searchResultList += result.item;
  209. });
  210. searchResultList += '</ul>';
  211. resultContent.innerHTML = searchResultList;
  212. window.pjax && window.pjax.refresh(resultContent);
  213. }
  214. };
  215. const fetchData = callback => {
  216. fetch(path)
  217. .then(response => response.text())
  218. .then(res => {
  219. // Get the contents from search data
  220. isfetched = true;
  221. datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => {
  222. return {
  223. title : element.querySelector('title').innerHTML,
  224. content: element.querySelector('content').innerHTML,
  225. url : element.querySelector('url').innerHTML
  226. };
  227. }) : JSON.parse(res);
  228. // Remove loading animation
  229. document.querySelector('.search-pop-overlay').innerHTML = '';
  230. document.body.style.overflow = '';
  231. if (callback) {
  232. callback();
  233. }
  234. });
  235. };
  236. if (CONFIG.localsearch.preload) {
  237. fetchData();
  238. }
  239. const proceedSearch = () => {
  240. document.body.style.overflow = 'hidden';
  241. document.querySelector('.search-pop-overlay').style.display = 'block';
  242. document.querySelector('.popup').style.display = 'block';
  243. document.querySelector('.search-input').focus();
  244. };
  245. // Search function
  246. const searchFunc = () => {
  247. document.querySelector('.search-pop-overlay').style.display = '';
  248. document.querySelector('.search-pop-overlay').innerHTML = '<div class="search-loading-icon"><i class="fa fa-spinner fa-pulse fa-5x fa-fw"></i></div>';
  249. fetchData(proceedSearch);
  250. };
  251. if (CONFIG.localsearch.trigger === 'auto') {
  252. input.addEventListener('input', inputEventFunction);
  253. } else {
  254. document.querySelector('.search-icon').addEventListener('click', inputEventFunction);
  255. input.addEventListener('keypress', event => {
  256. if (event.key === 'Enter') {
  257. inputEventFunction();
  258. }
  259. });
  260. }
  261. // Handle and trigger popup window
  262. document.querySelectorAll('.popup-trigger').forEach(element => {
  263. element.addEventListener('click', () => {
  264. isfetched ? proceedSearch() : searchFunc();
  265. });
  266. });
  267. // Monitor main search box
  268. const onPopupClose = () => {
  269. document.body.style.overflow = '';
  270. document.querySelector('.search-pop-overlay').style.display = 'none';
  271. document.querySelector('.popup').style.display = 'none';
  272. };
  273. document.querySelector('.search-pop-overlay').addEventListener('click', onPopupClose);
  274. document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose);
  275. window.addEventListener('pjax:success', onPopupClose);
  276. window.addEventListener('keyup', event => {
  277. if (event.key === 'Escape') {
  278. onPopupClose();
  279. }
  280. });
  281. });