From f5d45458fd55a4fdbff846f132d039518b07d3a3 Mon Sep 17 00:00:00 2001
From: Jimmy Cai <jimmehcai@gmail.com>
Date: Fri, 6 Nov 2020 11:33:51 +0100
Subject: [PATCH] refactor(search): create Search class

---
 assets/ts/search.tsx | 362 +++++++++++++++++++++++--------------------
 1 file changed, 193 insertions(+), 169 deletions(-)

diff --git a/assets/ts/search.tsx b/assets/ts/search.tsx
index 8be4b7d..9539caa 100644
--- a/assets/ts/search.tsx
+++ b/assets/ts/search.tsx
@@ -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();
-})
\ No newline at end of file
+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;
\ No newline at end of file