refactor(search): create Search class
This commit is contained in:
parent
b97e86a7a7
commit
f5d45458fd
@ -8,17 +8,6 @@ interface pageData {
|
||||
matchCount: number
|
||||
}
|
||||
|
||||
const searchForm = document.querySelector('.search-form') as HTMLFormElement;
|
||||
const searchInput = searchForm.querySelector('input') as HTMLInputElement;
|
||||
const searchResultList = document.querySelector('.search-result--list') as HTMLDivElement;
|
||||
const searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
|
||||
|
||||
let data: pageData[];
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML tags as HTML entities
|
||||
* Edited from:
|
||||
@ -40,187 +29,222 @@ function replaceHTMLEnt(str) {
|
||||
return str.replace(/[&<>"]/g, replaceTag);
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
if (!data) {
|
||||
/// Not fetched yet
|
||||
const jsonURL = searchForm.dataset.json;
|
||||
data = await fetch(jsonURL).then(res => res.json());
|
||||
}
|
||||
|
||||
return data;
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function updateQueryString(keywords: string, replaceState = false) {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
class Search {
|
||||
private data: pageData[];
|
||||
private form: HTMLFormElement;
|
||||
private input: HTMLInputElement;
|
||||
private list: HTMLDivElement;
|
||||
private resultTitle: HTMLHeadElement;
|
||||
|
||||
if (keywords === '') {
|
||||
pageURL.searchParams.delete('keyword')
|
||||
}
|
||||
else {
|
||||
pageURL.searchParams.set('keyword', keywords);
|
||||
constructor({ form, input, list, resultTitle }) {
|
||||
this.form = form;
|
||||
this.input = input;
|
||||
this.list = list;
|
||||
this.resultTitle = resultTitle;
|
||||
|
||||
this.handleQueryString();
|
||||
this.bindQueryStringChange();
|
||||
this.bindSearchForm();
|
||||
}
|
||||
|
||||
if (replaceState) {
|
||||
window.history.replaceState('', '', pageURL.toString());
|
||||
private async searchKeywords(keywords: string[]) {
|
||||
const rawData = await this.getData();
|
||||
let results: pageData[] = [];
|
||||
|
||||
/// Sort keywords by their length
|
||||
keywords.sort((a, b) => {
|
||||
return b.length - a.length
|
||||
});
|
||||
|
||||
for (const item of rawData) {
|
||||
let result = {
|
||||
...item,
|
||||
preview: '',
|
||||
matchCount: 0
|
||||
}
|
||||
|
||||
let matched = false;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (keyword === '') continue;
|
||||
|
||||
const regex = new RegExp(escapeRegExp(replaceHTMLEnt(keyword)), 'gi');
|
||||
|
||||
const contentMatch = regex.exec(result.content);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
|
||||
const titleMatch = regex.exec(result.title);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
|
||||
if (titleMatch) {
|
||||
result.title = result.title.replace(regex, Search.marker);
|
||||
}
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
matched = true;
|
||||
++result.matchCount;
|
||||
|
||||
let start = 0,
|
||||
end = 100;
|
||||
|
||||
if (contentMatch) {
|
||||
start = contentMatch.index - 20;
|
||||
end = contentMatch.index + 80
|
||||
|
||||
if (start < 0) start = 0;
|
||||
}
|
||||
|
||||
if (result.preview.indexOf(keyword) !== -1) {
|
||||
result.preview = result.preview.replace(regex, Search.marker);
|
||||
}
|
||||
else {
|
||||
if (start !== 0) result.preview += `[...] `;
|
||||
result.preview += `${result.content.slice(start, end).replace(regex, Search.marker)} `;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
result.preview += '[...]';
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Result with more matches appears first */
|
||||
return results.sort((a, b) => {
|
||||
return b.matchCount - a.matchCount;
|
||||
});
|
||||
}
|
||||
else {
|
||||
window.history.pushState('', '', pageURL.toString());
|
||||
|
||||
public static marker(match) {
|
||||
return '<mark>' + match + '</mark>';
|
||||
}
|
||||
}
|
||||
|
||||
function bindQueryStringChange() {
|
||||
window.addEventListener('popstate', (e) => {
|
||||
handleQueryString()
|
||||
})
|
||||
}
|
||||
private async doSearch(keywords: string[]) {
|
||||
const startTime = performance.now();
|
||||
|
||||
function handleQueryString() {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
const keywords = pageURL.searchParams.get('keyword');
|
||||
searchInput.value = keywords;
|
||||
const results = await this.searchKeywords(keywords);
|
||||
this.clear();
|
||||
|
||||
if (keywords) {
|
||||
doSearch(keywords.split(' '));
|
||||
for (const item of results) {
|
||||
this.list.append(Search.render(item));
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
this.resultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`;
|
||||
}
|
||||
else {
|
||||
clear()
|
||||
|
||||
public async getData() {
|
||||
if (!this.data) {
|
||||
/// Not fetched yet
|
||||
const jsonURL = this.form.dataset.json;
|
||||
this.data = await fetch(jsonURL).then(res => res.json());
|
||||
}
|
||||
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
function bindSearchForm() {
|
||||
let lastSearch = '';
|
||||
private bindSearchForm() {
|
||||
let lastSearch = '';
|
||||
|
||||
const eventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
const keywords = searchInput.value;
|
||||
const eventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
const keywords = this.input.value;
|
||||
|
||||
updateQueryString(keywords, true);
|
||||
Search.updateQueryString(keywords, true);
|
||||
|
||||
if (keywords === '') {
|
||||
return this.clear();
|
||||
}
|
||||
|
||||
if (lastSearch === keywords) return;
|
||||
lastSearch = keywords;
|
||||
|
||||
this.doSearch(keywords.split(' '));
|
||||
}
|
||||
|
||||
this.input.addEventListener('input', eventHandler);
|
||||
this.input.addEventListener('compositionend', eventHandler);
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.list.innerHTML = '';
|
||||
this.resultTitle.innerText = '';
|
||||
}
|
||||
|
||||
private bindQueryStringChange() {
|
||||
window.addEventListener('popstate', (e) => {
|
||||
this.handleQueryString()
|
||||
})
|
||||
}
|
||||
|
||||
private handleQueryString() {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
const keywords = pageURL.searchParams.get('keyword');
|
||||
this.input.value = keywords;
|
||||
|
||||
if (keywords) {
|
||||
this.doSearch(keywords.split(' '));
|
||||
}
|
||||
else {
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private static updateQueryString(keywords: string, replaceState = false) {
|
||||
const pageURL = new URL(window.location.toString());
|
||||
|
||||
if (keywords === '') {
|
||||
return clear();
|
||||
pageURL.searchParams.delete('keyword')
|
||||
}
|
||||
else {
|
||||
pageURL.searchParams.set('keyword', keywords);
|
||||
}
|
||||
|
||||
if (lastSearch === keywords) return;
|
||||
lastSearch = keywords;
|
||||
|
||||
doSearch(keywords.split(' '));
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', eventHandler);
|
||||
searchInput.addEventListener('compositionend', eventHandler);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
searchResultList.innerHTML = '';
|
||||
searchResultTitle.innerText = '';
|
||||
}
|
||||
|
||||
async function doSearch(keywords: string[]) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const results = await searchKeywords(keywords);
|
||||
clear();
|
||||
|
||||
for (const item of results) {
|
||||
searchResultList.append(render(item));
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
searchResultTitle.innerText = `${results.length} pages (${((endTime - startTime) / 1000).toPrecision(1)} seconds)`;
|
||||
}
|
||||
|
||||
function marker(match) {
|
||||
return '<mark>' + match + '</mark>';
|
||||
}
|
||||
|
||||
async function searchKeywords(keywords: string[]) {
|
||||
const rawData = await getData();
|
||||
let results: pageData[] = [];
|
||||
|
||||
/// Sort keywords by their length
|
||||
keywords.sort((a, b) => {
|
||||
return b.length - a.length
|
||||
});
|
||||
|
||||
for (const item of rawData) {
|
||||
let result = {
|
||||
...item,
|
||||
preview: '',
|
||||
matchCount: 0
|
||||
if (replaceState) {
|
||||
window.history.replaceState('', '', pageURL.toString());
|
||||
}
|
||||
|
||||
let matched = false;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (keyword === '') continue;
|
||||
|
||||
const regex = new RegExp(escapeRegExp(replaceHTMLEnt(keyword)), 'gi');
|
||||
|
||||
const contentMatch = regex.exec(result.content);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
|
||||
const titleMatch = regex.exec(result.title);
|
||||
regex.lastIndex = 0; /// Reset regex
|
||||
|
||||
if (titleMatch) {
|
||||
result.title = result.title.replace(regex, marker);
|
||||
}
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
matched = true;
|
||||
++result.matchCount;
|
||||
|
||||
let start = 0,
|
||||
end = 100;
|
||||
|
||||
if (contentMatch) {
|
||||
start = contentMatch.index - 20;
|
||||
end = contentMatch.index + 80
|
||||
|
||||
if (start < 0) start = 0;
|
||||
}
|
||||
|
||||
if (result.preview.indexOf(keyword) !== -1) {
|
||||
result.preview = result.preview.replace(regex, marker);
|
||||
}
|
||||
else {
|
||||
if (start !== 0) result.preview += `[...] `;
|
||||
result.preview += `${result.content.slice(start, end).replace(regex, marker)} `;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
result.preview += '[...]';
|
||||
results.push(result);
|
||||
else {
|
||||
window.history.pushState('', '', pageURL.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result with more matches appears first */
|
||||
return results.sort((a, b) => {
|
||||
return b.matchCount - a.matchCount;
|
||||
});
|
||||
}
|
||||
|
||||
const render = (item: pageData) => {
|
||||
return <article>
|
||||
<a href={item.permalink}>
|
||||
<div class="article-details">
|
||||
<h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
|
||||
<secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
|
||||
</div>
|
||||
{item.image &&
|
||||
<div class="article-image">
|
||||
<img src={item.image} loading="lazy" />
|
||||
public static render(item: pageData) {
|
||||
return <article>
|
||||
<a href={item.permalink}>
|
||||
<div class="article-details">
|
||||
<h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
|
||||
<secion class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></secion>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</article>;
|
||||
{item.image &&
|
||||
<div class="article-image">
|
||||
<img src={item.image} loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</article>;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
handleQueryString();
|
||||
bindQueryStringChange();
|
||||
bindSearchForm();
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(function () {
|
||||
const searchForm = document.querySelector('.search-form') as HTMLFormElement,
|
||||
searchInput = searchForm.querySelector('input') as HTMLInputElement,
|
||||
searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
|
||||
searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
|
||||
|
||||
new Search({
|
||||
form: searchForm,
|
||||
input: searchInput,
|
||||
list: searchResultList,
|
||||
resultTitle: searchResultTitle
|
||||
});
|
||||
}, 0);
|
||||
})
|
||||
|
||||
export default Search;
|
Loading…
Reference in New Issue
Block a user