diff --git a/source/js/local-search.js b/source/js/local-search.js index 9a1d230..31f0c8f 100644 --- a/source/js/local-search.js +++ b/source/js/local-search.js @@ -1,297 +1,298 @@ /* global CONFIG */ window.addEventListener('DOMContentLoaded', () => { - // Popup Window - let isfetched = false; - let datas; - let isXml = true; - // Search DB path - let searchPath = CONFIG.path; - if (searchPath.length === 0) { - searchPath = 'search.xml'; - } else if (searchPath.endsWith('json')) { - isXml = false; + // Popup Window + let isfetched = false; + let datas; + let isXml = true; + // Search DB path + let searchPath = CONFIG.path; + if (!searchPath) return false; + if (searchPath.length === 0) { + searchPath = 'search.xml'; + } else if (searchPath.endsWith('json')) { + isXml = false; + } + const input = document.querySelector('.search-input'); + const resultContent = document.getElementById('search-result'); + + // Ref: https://github.com/ForbesLindesay/unescape-html + const unescapeHtml = html => { + return String(html) + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/:/g, ':') + // Replace all the other &#x; chars + .replace(/&#(\d+);/g, (m, p) => { + return String.fromCharCode(p); + }) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + }; + + const getIndexByWord = (word, text, caseSensitive) => { + let wordLen = word.length; + if (wordLen === 0) return []; + let startPosition = 0; + let position = []; + let index = []; + if (!caseSensitive) { + text = text.toLowerCase(); + word = word.toLowerCase(); } - const input = document.querySelector('.search-input'); - const resultContent = document.getElementById('search-result'); + while ((position = text.indexOf(word, startPosition)) > -1) { + index.push({position, word}); + startPosition = position + wordLen; + } + return index; + }; - // Ref: https://github.com/ForbesLindesay/unescape-html - const unescapeHtml = html => { - return String(html) - .replace(/"/g, '"') - .replace(/'/g, '\'') - .replace(/:/g, ':') - // Replace all the other &#x; chars - .replace(/&#(\d+);/g, (m, p) => { - return String.fromCharCode(p); - }) - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&'); - }; + // Merge hits into slices + const mergeIntoSlice = (start, end, index, searchText) => { + let item = index[index.length - 1]; + let {position, word} = item; + let hits = []; + let searchTextCountInSlice = 0; + while (position + word.length <= end && index.length !== 0) { + if (word === searchText) { + searchTextCountInSlice++; + } + hits.push({ + position, + length: word.length + }); + let wordEnd = position + word.length; - const getIndexByWord = (word, text, caseSensitive) => { - let wordLen = word.length; - if (wordLen === 0) return []; - let startPosition = 0; - let position = []; - let index = []; - if (!caseSensitive) { - text = text.toLowerCase(); - word = word.toLowerCase(); - } - while ((position = text.indexOf(word, startPosition)) > -1) { - index.push({position, word}); - startPosition = position + wordLen; - } - return index; - }; - - // Merge hits into slices - const mergeIntoSlice = (start, end, index, searchText) => { - let item = index[index.length - 1]; - let {position, word} = item; - let hits = []; - let searchTextCountInSlice = 0; - while (position + word.length <= end && index.length !== 0) { - if (word === searchText) { - searchTextCountInSlice++; - } - hits.push({ - position, - length: word.length - }); - let wordEnd = position + word.length; - - // Move to next position of hit - index.pop(); - while (index.length !== 0) { - item = index[index.length - 1]; - position = item.position; - word = item.word; - if (wordEnd > position) { - index.pop(); - } else { - break; - } - } - } - return { - hits, - start, - end, - searchTextCount: searchTextCountInSlice - }; - }; - - // Highlight title and content - const highlightKeyword = (text, slice) => { - let result = ''; - let prevEnd = slice.start; - slice.hits.forEach(hit => { - result += text.substring(prevEnd, hit.position); - let end = hit.position + hit.length; - result += `${text.substring(hit.position, end)}`; - prevEnd = end; - }); - result += text.substring(prevEnd, slice.end); - return result; - }; - - const inputEventFunction = () => { - if (!isfetched) return; - let searchText = input.value.trim().toLowerCase(); - let keywords = searchText.split(/[-\s]+/); - if (keywords.length > 1) { - keywords.push(searchText); - } - let resultItems = []; - if (searchText.length > 0) { - // Perform local searching - datas.forEach(({title, content, url}) => { - let titleInLowerCase = title.toLowerCase(); - let contentInLowerCase = content.toLowerCase(); - let indexOfTitle = []; - let indexOfContent = []; - let searchTextCount = 0; - keywords.forEach(keyword => { - indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false)); - indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false)); - }); - - // Show search results - if (indexOfTitle.length > 0 || indexOfContent.length > 0) { - let hitCount = indexOfTitle.length + indexOfContent.length; - // Sort index by position of keyword - [indexOfTitle, indexOfContent].forEach(index => { - index.sort((itemLeft, itemRight) => { - if (itemRight.position !== itemLeft.position) { - return itemRight.position - itemLeft.position; - } - return itemLeft.word.length - itemRight.word.length; - }); - }); - - let slicesOfTitle = []; - if (indexOfTitle.length !== 0) { - let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText); - searchTextCount += tmp.searchTextCountInSlice; - slicesOfTitle.push(tmp); - } - - let slicesOfContent = []; - while (indexOfContent.length !== 0) { - let item = indexOfContent[indexOfContent.length - 1]; - let {position, word} = item; - // Cut out 100 characters - let start = position - 20; - let end = position + 80; - if (start < 0) { - start = 0; - } - if (end < position + word.length) { - end = position + word.length; - } - if (end > content.length) { - end = content.length; - } - let tmp = mergeIntoSlice(start, end, indexOfContent, searchText); - searchTextCount += tmp.searchTextCountInSlice; - slicesOfContent.push(tmp); - } - - // Sort slices in content by search text's count and hits' count - slicesOfContent.sort((sliceLeft, sliceRight) => { - if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) { - return sliceRight.searchTextCount - sliceLeft.searchTextCount; - } else if (sliceLeft.hits.length !== sliceRight.hits.length) { - return sliceRight.hits.length - sliceLeft.hits.length; - } - return sliceLeft.start - sliceRight.start; - }); - - // Select top N slices in content - let upperBound = parseInt(CONFIG.localsearch.top_n_per_article ? CONFIG.localsearch.top_n_per_article : 1, 10); - if (upperBound >= 0) { - slicesOfContent = slicesOfContent.slice(0, upperBound); - } - - let resultItem = ''; - - if (slicesOfTitle.length !== 0) { - resultItem += `
  • ${highlightKeyword(title, slicesOfTitle[0])}`; - } else { - resultItem += `
  • ${title}`; - } - - slicesOfContent.forEach(slice => { - resultItem += `

    ${highlightKeyword(content, slice)}...

    `; - }); - - resultItem += '
  • '; - resultItems.push({ - item: resultItem, - id: resultItems.length, - hitCount, - searchTextCount - }); - } - }); - } - if (keywords.length === 1 && keywords[0] === '') { - resultContent.innerHTML = '
    '; - } else if (resultItems.length === 0) { - resultContent.innerHTML = '
    '; + // Move to next position of hit + index.pop(); + while (index.length !== 0) { + item = index[index.length - 1]; + position = item.position; + word = item.word; + if (wordEnd > position) { + index.pop(); } else { - resultItems.sort((resultLeft, resultRight) => { - if (resultLeft.searchTextCount !== resultRight.searchTextCount) { - return resultRight.searchTextCount - resultLeft.searchTextCount; - } else if (resultLeft.hitCount !== resultRight.hitCount) { - return resultRight.hitCount - resultLeft.hitCount; - } - return resultRight.id - resultLeft.id; - }); - let searchResultList = ''; - resultContent.innerHTML = searchResultList; - window.pjax && window.pjax.refresh(resultContent); + break; } - }; - - const fetchData = () => { - fetch(CONFIG.root + searchPath) - .then(response => response.text()) - .then(res => { - // Get the contents from search data - isfetched = true; - datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => { - return { - title: element.querySelector('title').textContent, - content: element.querySelector('content').textContent, - url: element.querySelector('url').textContent - }; - }) : JSON.parse(res); - // Only match articles with not empty titles - datas = datas.filter(data => data.title).map(data => { - data.title = data.title.trim(); - data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : ''; - if (CONFIG.localsearch.unescape) { - data.content = unescapeHtml(data.content); - } - data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/'); - return data; - }); - // Remove loading animation - document.getElementById('no-result').innerHTML = ''; - }); - }; - - if (CONFIG.localsearch.preload) { - fetchData(); + } } + return { + hits, + start, + end, + searchTextCount: searchTextCountInSlice + }; + }; - if (CONFIG.localsearch.trigger === 'auto') { - if (input) { - input.addEventListener('input', inputEventFunction); - } + // Highlight title and content + const highlightKeyword = (text, slice) => { + let result = ''; + let prevEnd = slice.start; + slice.hits.forEach(hit => { + result += text.substring(prevEnd, hit.position); + let end = hit.position + hit.length; + result += `${text.substring(hit.position, end)}`; + prevEnd = end; + }); + result += text.substring(prevEnd, slice.end); + return result; + }; - } else { - document.querySelector('.search-icon').addEventListener('click', inputEventFunction); - input.addEventListener('keypress', event => { - if (event.key === 'Enter') { - inputEventFunction(); + const inputEventFunction = () => { + if (!isfetched) return; + let searchText = input.value.trim().toLowerCase(); + let keywords = searchText.split(/[-\s]+/); + if (keywords.length > 1) { + keywords.push(searchText); + } + let resultItems = []; + if (searchText.length > 0) { + // Perform local searching + datas.forEach(({title, content, url}) => { + let titleInLowerCase = title.toLowerCase(); + let contentInLowerCase = content.toLowerCase(); + let indexOfTitle = []; + let indexOfContent = []; + let searchTextCount = 0; + keywords.forEach(keyword => { + indexOfTitle = indexOfTitle.concat(getIndexByWord(keyword, titleInLowerCase, false)); + indexOfContent = indexOfContent.concat(getIndexByWord(keyword, contentInLowerCase, false)); + }); + + // Show search results + if (indexOfTitle.length > 0 || indexOfContent.length > 0) { + let hitCount = indexOfTitle.length + indexOfContent.length; + // Sort index by position of keyword + [indexOfTitle, indexOfContent].forEach(index => { + index.sort((itemLeft, itemRight) => { + if (itemRight.position !== itemLeft.position) { + return itemRight.position - itemLeft.position; + } + return itemLeft.word.length - itemRight.word.length; + }); + }); + + let slicesOfTitle = []; + if (indexOfTitle.length !== 0) { + let tmp = mergeIntoSlice(0, title.length, indexOfTitle, searchText); + searchTextCount += tmp.searchTextCountInSlice; + slicesOfTitle.push(tmp); + } + + let slicesOfContent = []; + while (indexOfContent.length !== 0) { + let item = indexOfContent[indexOfContent.length - 1]; + let {position, word} = item; + // Cut out 100 characters + let start = position - 20; + let end = position + 80; + if (start < 0) { + start = 0; } + if (end < position + word.length) { + end = position + word.length; + } + if (end > content.length) { + end = content.length; + } + let tmp = mergeIntoSlice(start, end, indexOfContent, searchText); + searchTextCount += tmp.searchTextCountInSlice; + slicesOfContent.push(tmp); + } + + // Sort slices in content by search text's count and hits' count + slicesOfContent.sort((sliceLeft, sliceRight) => { + if (sliceLeft.searchTextCount !== sliceRight.searchTextCount) { + return sliceRight.searchTextCount - sliceLeft.searchTextCount; + } else if (sliceLeft.hits.length !== sliceRight.hits.length) { + return sliceRight.hits.length - sliceLeft.hits.length; + } + return sliceLeft.start - sliceRight.start; + }); + + // Select top N slices in content + let upperBound = parseInt(CONFIG.localsearch.top_n_per_article ? CONFIG.localsearch.top_n_per_article : 1, 10); + if (upperBound >= 0) { + slicesOfContent = slicesOfContent.slice(0, upperBound); + } + + let resultItem = ''; + + if (slicesOfTitle.length !== 0) { + resultItem += `
  • ${highlightKeyword(title, slicesOfTitle[0])}`; + } else { + resultItem += `
  • ${title}`; + } + + slicesOfContent.forEach(slice => { + resultItem += `

    ${highlightKeyword(content, slice)}...

    `; + }); + + resultItem += '
  • '; + resultItems.push({ + item: resultItem, + id: resultItems.length, + hitCount, + searchTextCount + }); + } + }); + } + if (keywords.length === 1 && keywords[0] === '') { + resultContent.innerHTML = '
    '; + } else if (resultItems.length === 0) { + resultContent.innerHTML = '
    '; + } else { + resultItems.sort((resultLeft, resultRight) => { + if (resultLeft.searchTextCount !== resultRight.searchTextCount) { + return resultRight.searchTextCount - resultLeft.searchTextCount; + } else if (resultLeft.hitCount !== resultRight.hitCount) { + return resultRight.hitCount - resultLeft.hitCount; + } + return resultRight.id - resultLeft.id; + }); + let searchResultList = ''; + resultContent.innerHTML = searchResultList; + window.pjax && window.pjax.refresh(resultContent); + } + }; + + const fetchData = () => { + fetch(CONFIG.root + searchPath) + .then(response => response.text()) + .then(res => { + // Get the contents from search data + isfetched = true; + datas = isXml ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => { + return { + title: element.querySelector('title').textContent, + content: element.querySelector('content').textContent, + url: element.querySelector('url').textContent + }; + }) : JSON.parse(res); + // Only match articles with not empty titles + datas = datas.filter(data => data.title).map(data => { + data.title = data.title.trim(); + data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : ''; + if (CONFIG.localsearch.unescape) { + data.content = unescapeHtml(data.content); + } + data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/'); + return data; }); + // Remove loading animation + document.getElementById('no-result').innerHTML = ''; + }); + }; + + if (CONFIG.localsearch.preload) { + fetchData(); + } + + if (CONFIG.localsearch.trigger === 'auto') { + if (input) { + input.addEventListener('input', inputEventFunction); } - // Handle and trigger popup window - document.querySelectorAll('.popup-trigger').forEach(element => { - element.addEventListener('click', () => { - document.body.style.overflow = 'hidden'; - document.querySelector('.search-pop-overlay').style.display = 'block'; - input.focus(); - if (!isfetched) fetchData(); - }); + } else { + document.querySelector('.search-icon').addEventListener('click', inputEventFunction); + input.addEventListener('keypress', event => { + if (event.key === 'Enter') { + inputEventFunction(); + } }); + } - // Monitor main search box - const onPopupClose = () => { - document.body.style.overflow = ''; - document.querySelector('.search-pop-overlay').style.display = ''; - }; + // Handle and trigger popup window + document.querySelectorAll('.popup-trigger').forEach(element => { + element.addEventListener('click', () => { + document.body.style.overflow = 'hidden'; + document.querySelector('.search-pop-overlay').style.display = 'block'; + input.focus(); + if (!isfetched) fetchData(); + }); + }); - document.querySelector('.search-pop-overlay').addEventListener('click', event => { - if (event.target === document.querySelector('.search-pop-overlay')) { - onPopupClose(); - } - }); - document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose); - window.addEventListener('pjax:success', onPopupClose); - window.addEventListener('keyup', event => { - if (event.key === 'Escape') { - onPopupClose(); - } - }); + // Monitor main search box + const onPopupClose = () => { + document.body.style.overflow = ''; + document.querySelector('.search-pop-overlay').style.display = ''; + }; + + document.querySelector('.search-pop-overlay').addEventListener('click', event => { + if (event.target === document.querySelector('.search-pop-overlay')) { + onPopupClose(); + } + }); + document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose); + window.addEventListener('pjax:success', onPopupClose); + window.addEventListener('keyup', event => { + if (event.key === 'Escape') { + onPopupClose(); + } + }); });