@mhg/blog
Version:
3 lines (2 loc) • 14.2 kB
JavaScript
class SearchService{static instance=null;constructor(){if(!SearchService.instance){this.queryText=null,this.data=null,this.searchHistory=[],this.maxHistoryCount=5,this.isComposing=!1,this.debouncedInputHandler=this.debounce(t=>{this.isComposing||this.onSubmit(t)},300);const t=volantis.GLOBAL_CONFIG?.languages?.search||{};this.hitsEmpty=t.hits_empty||"未找到与「${query}」相关的内容",this.hitsFound=t.hits_found||"找到 ${totalResults} 个相关结果",this.normalText=t.placeholder||"搜索文章、标签或分类...",this.loadDataText=t.normal||"正在加载数据...",this.searchingText=t.searching||"搜索中...",this.searchHistoryText=t.history||"搜索历史",this.noSearchHistoryText=t.no_history||"暂无搜索历史",this.clearHistoryText=t.clear_history||"清除历史",this.filterAll=t.filter_all||"全部",this.filterPosts=t.filter_posts||"文章",this.filterPages=t.filter_pages||"页面",this.filterTags=t.filter_tags||"标签",this.labelPosts=t.label_posts||"文章",this.labelPages=t.label_pages||"页面",this.labelTags=t.label_tags||"标签",this.normal=`<div class="result-hits-empty"><p>${this.normalText}🔍</p></div>`,this.template=`\n <div class="modal">\n <header class="modal-header clearfix">\n <form id="u-search-modal-form" class="u-search-form" name="uSearchModalForm">\n <input type="text" id="u-search-modal-input" class="u-search-input" placeholder="${this.normalText}" autocomplete="off" />\n <button type="submit" id="u-search-modal-btn-submit" class="u-search-btn-submit">\n <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <circle cx="11" cy="11" r="8"></circle>\n <line x1="21" y1="21" x2="16.65" y2="16.65"></line>\n </svg>\n </button>\n </form>\n <a id="u-search-btn-close" class="btn-close">\n <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <line x1="18" y1="6" x2="6" y2="18"></line>\n <line x1="6" y1="6" x2="18" y2="18"></line>\n </svg>\n </a>\n </header>\n <div class="search-filters">\n <button class="filter-btn active" data-filter="all">${this.filterAll}</button>\n <button class="filter-btn" data-filter="posts">${this.filterPosts}</button>\n <button class="filter-btn" data-filter="pages">${this.filterPages}</button>\n <button class="filter-btn" data-filter="tags">${this.filterTags}</button>\n </div>\n <main class="modal-body">\n <div class="search-stats hidden"></div>\n <ul class="modal-results"></ul>\n <div class="search-history hidden">\n <h3>${this.searchHistoryText}</h3>\n <button class="clear-history">${this.clearHistoryText}</button>\n <ul class="history-list"></ul>\n </div>\n <div class="search-loading hidden">\n <div class="spinner"></div>\n <p>${this.loadDataText}</p>\n </div>\n </main>\n </div>\n <div id="modal-overlay" class="modal-overlay"></div>`,this.initInstance(),SearchService.instance=this}return SearchService.instance}static init(){SearchService.instance||new SearchService}initInstance(){if(!document.querySelector("#u-search")){const t=document.createElement("div");t.id="u-search",t.innerHTML=this.template,document.body.append(t)}this.bindEvents(),this.loadSearchHistory(),this.updateHistoryDisplay()}bindEvents(){const t=document.querySelector("#u-search-modal-input");t.hasAttribute("data-event-bound")||(t.setAttribute("data-event-bound","true"),t.addEventListener("compositionstart",()=>this.isComposing=!0),t.addEventListener("compositionend",t=>{this.isComposing=!1,this.onSubmit(t)}),t.addEventListener("input",this.debouncedInputHandler.bind(this)),document.querySelectorAll(".u-search-form").forEach(t=>{t.addEventListener("submit",this.onSubmit.bind(this))}),document.querySelector("#u-search-btn-close").addEventListener("click",this.close.bind(this)),document.querySelector("#modal-overlay").addEventListener("click",this.close.bind(this)),document.querySelectorAll(".filter-btn").forEach(t=>{t.addEventListener("click",t=>{document.querySelectorAll(".filter-btn").forEach(t=>t.classList.remove("active")),t.target.classList.add("active"),this.onSubmit({preventDefault:()=>{},target:document.querySelector("#u-search-modal-form")})})}),document.querySelector(".clear-history").addEventListener("click",()=>{this.clearSearchHistory()}),document.querySelector(".history-list").addEventListener("click",e=>{"LI"===e.target.tagName&&(t.value=e.target.textContent,this.onSubmit({preventDefault:()=>{},target:document.querySelector("#u-search-modal-form")}))}))}async onSubmit(t){t.preventDefault();const e=t.target.querySelector(".u-search-input")||t.target;this.queryText=e.value.trim(),await this.search()}async search(){const t=document.querySelectorAll(".u-search-input"),e=document.querySelector("#u-search"),s=document.querySelector("#modal-overlay"),i=document.querySelector("#u-search .modal-results"),r=document.querySelector("#u-search .modal"),n=document.querySelector(".search-stats"),a=document.querySelector(".search-loading"),o=a.querySelector("p"),l=document.querySelector(".search-history"),c=document.querySelector(".filter-btn.active").dataset.filter;t.forEach(t=>t.value=this.queryText),e.style.display="block",s.style.backdropFilter="blur(10px)",setTimeout(()=>{r.style.transform="translate(0px, 0px)"},100),i.innerHTML="",i.style.flex="",n.classList.add("hidden"),a.classList.remove("hidden"),l.classList.add("hidden"),this.data?o.textContent=this.searchingText:(o.textContent=this.loadDataText,this.data=await this.fetchData()),setTimeout(async()=>{let t="",s=0;if("all"===c||"posts"===c){const e=this.buildResultList(this.data.posts,"post");t+=e.html,s+=e.count}if("all"===c||"pages"===c){const e=this.buildResultList(this.data.pages,"page");t+=e.html,s+=e.count}if("all"===c||"tags"===c){const e=this.buildTagResults();t+=e.html,s+=e.count}a.classList.add("hidden"),t?(n.textContent=this.hitsFound.replace(/\$\{totalResults}/,s),n.classList.remove("hidden"),this.queryText&&this.queryText.length>0&&this.addToSearchHistory(this.queryText)):(i.style.flex=1,t=`<div class="result-hits-empty no-results">\n <i class="fa-solid fa-search"></i>\n <p>${this.hitsEmpty.replace(/\$\{query}/,this.queryText)}</p>\n </div>`,n.classList.add("hidden")),""===this.queryText&&(t=this.normal,n.classList.add("hidden"),l.classList.remove("hidden"),this.updateHistoryDisplay()),i.innerHTML=t,window.pjax&&pjax.refresh(e),window.lazyLoader&&window.lazyLoader.reinitObserver();const r=t=>{"Escape"===t.code&&(this.close(),document.removeEventListener("keydown",r))};document.addEventListener("keydown",r)},300)}close(){const t=document.querySelector("#u-search .modal"),e=document.querySelector("#u-search"),s=document.querySelector("#modal-overlay");t.style.transform="translateY(120%)",setTimeout(()=>{e.style.display="none",s.style.backdropFilter="blur(0)"},300)}async fetchData(){try{const t=await fetch(volantis.GLOBAL_CONFIG.search.dataPath);return await t.json()}catch(t){return console.error("Error fetching search data:",t),null}}buildResultList(t,e){if(!t||!this.queryText)return{html:"",count:0};const s=t.sort((t,e)=>new Date(e.updated)-new Date(t.updated));let i="",r=0;if(s.forEach(t=>{this.contentSearch(t)&&(i+=this.buildResult(t,e),r++)}),i&&r>0){const t="post"===e?this.labelPosts:this.labelPages;i=`<li class="result-group"><h3>${t} (${r})</h3></li>`+i}return{html:i,count:r}}buildTagResults(){if(!this.data.tags||!this.queryText)return{html:"",count:0};const t=this.queryText.toLowerCase();let e="",s=0;return this.data.tags.forEach(i=>{i.name.toLowerCase().includes(t)&&(e+=`\n <li class="tag-result">\n <a href="${i.permalink}">\n <span class="tag-icon">\n <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke="currentColor">\n <line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line>\n </svg>\n </span>\n <span class="tag-name">${this.highlightMatch(i.name)}</span>\n </a>\n </li>\n `,s++)}),e&&s>0&&(e=`<li class="result-group"><h3>${this.labelTags} (${s})</h3></li><ul class="result-tags-list">${e}</ul>`),{html:e,count:s}}contentSearch(t){if(!this.queryText)return!1;const e=this.queryText.toLowerCase().split(/[-\s]+/),s=t?.title?.toLowerCase()||"",i=(t?.content||t?.text)?.toLowerCase()||"",r=(t?.description||t?.excerpt)?.toLowerCase()||"",n=t?.tags?.map(t=>t.name.toLowerCase()).join(" ")||"";if(!e.some(t=>s.includes(t)||i.includes(t)||r.includes(t)||n.includes(t)))return!1;const a=i||r||s,o=e.reduce((t,e)=>{const s=a.indexOf(e);return-1!==s&&s<t?s:t},a.length),l=Math.max(o-40,0),c=Math.min(o+120,a.length),h=this.stripHTML(a).slice(l,c).replace(new RegExp(`(${e.join("|")})`,"gi"),"<mark>$1</mark>");return t.digest=`${h}......`,!0}buildResult(t,e){const s=new URL(t.permalink).pathname.split("?")[0],i=new Date(t.updated||t.date),r=`${i.getFullYear()}-${(i.getMonth()+1).toString().padStart(2,"0")}-${i.getDate().toString().padStart(2,"0")}`;let n="";t.tags&&t.tags.length>0&&(n=`<div class="tags">\n ${t.tags.map(t=>`<a href="${t.permalink}" class="tag">\n <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke="currentColor">\n <line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line>\n </svg>\n ${this.highlightMatch(t.name)}\n </a>`).join("")}\n </div>`);let a="";t.categories&&t.categories.length>0&&(a=`<div class="category">\n <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>\n </svg>\n ${t.categories.map(t=>`<a href="${t.permalink}">${t.name}</a>`).join("")}\n </div>`);let o="";return t.headimg&&(o=`<div class="result-image">\n <picture>\n <img src="${t.headimg}" alt="${t.title}">\n </picture>\n </div>`),`\n <li class="result-item ${e}-result">\n <a class="result-link" href="${s}?keyword=${encodeURIComponent(this.queryText)}">\n ${o}\n <div class="result-content">\n <h3 class="title">${this.highlightMatch(t.title)}</h3>\n <div class="meta">\n <span class="date">${r}</span>\n ${a}\n </div>\n ${n}\n ${t.digest?`<p class="digest">${t.digest}</p>`:""}\n </div>\n </a>\n </li>\n `}highlightMatch(t){if(!this.queryText)return t;const e=this.queryText.toLowerCase().split(/[-\s]+/);if(!e.length)return t;const s=new RegExp(`(${e.join("|")})`,"gi");return t.replace(s,"<mark>$1</mark>")}stripHTML(t){return t.replace(/\n+/g," ").replace(/<(img|figure)[^>]*>.*?<\/\1>/g,"").replace(/<[^>]+>/g,"").trim().replace(/\s+/g," ").replace(/(\S)(https:\/\/)/g,"$1 $2")}addToSearchHistory(t){this.searchHistory=[t,...this.searchHistory.filter(e=>e.toLowerCase()!==t.toLowerCase())].slice(0,this.maxHistoryCount),localStorage.setItem("searchHistory",JSON.stringify(this.searchHistory)),this.updateHistoryDisplay()}loadSearchHistory(){const t=localStorage.getItem("searchHistory");if(t)try{this.searchHistory=JSON.parse(t)}catch(e){console.error("Failed to parse search history from localStorage:",e),this.searchHistory=[]}}updateHistoryDisplay(){const t=document.querySelector(".search-history"),e=document.querySelector(".history-list");if(e){if(0===this.searchHistory.length)return e.innerHTML=`<p class="no-history">${this.noSearchHistoryText}</p>`,void t.classList.add("hidden");e.innerHTML=this.searchHistory.map((t,e)=>`<li>${t}\n <button class="remove-history" data-index="${e}">\n <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <line x1="18" y1="6" x2="6" y2="18"></line>\n <line x1="6" y1="6" x2="18" y2="18"></line>\n </svg>\n </button>\n </li>`).join(""),document.querySelectorAll(".remove-history").forEach(t=>{t.addEventListener("click",t=>{t.stopPropagation();const e=parseInt(t.target.closest(".remove-history").dataset.index);this.searchHistory.splice(e,1),localStorage.setItem("searchHistory",JSON.stringify(this.searchHistory)),this.updateHistoryDisplay()})})}}clearSearchHistory(){this.searchHistory=[],localStorage.removeItem("searchHistory"),this.updateHistoryDisplay()}debounce(t,e){let s=null;return function(...i){clearTimeout(s),s=setTimeout(()=>{t.apply(this,i)},e)}}static setQueryText(t){SearchService.init(),SearchService.instance.queryText=t}static async search(){SearchService.init(),await SearchService.instance.search()}}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>new SearchService):new SearchService,volantis.pjax.push(()=>new SearchService,"hexo-search"),volantis.pjax.send(()=>{const t=document.querySelector("#u-search");t&&(t.style.display="none")},"hexo-search");
//# sourceMappingURL=../../maps/js/search/hexo.js.map